diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 8651cbb8..4b0032e7 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -24,4 +24,4 @@ jobs: context: . file: Dockerfile push: true - tags: entrius/allways:latest + tags: entrius/allways:latest,entrius/allways:${{ github.sha }} diff --git a/allways/__init__.py b/allways/__init__.py index 8d9040a4..657be2e0 100644 --- a/allways/__init__.py +++ b/allways/__init__.py @@ -1,3 +1,3 @@ -__version__ = '1.0.7' +__version__ = '1.0.8' version_split = __version__.split('.') __spec_version__ = (1000 * int(version_split[0])) + (10 * int(version_split[1])) + (1 * int(version_split[2])) diff --git a/allways/validator/scoring.py b/allways/validator/scoring.py index c526a594..7eaa2a07 100644 --- a/allways/validator/scoring.py +++ b/allways/validator/scoring.py @@ -677,8 +677,9 @@ def snapshot_current_crown_holders( bt.logging.warning(f'swap-bounds read failed in live snapshot: {e}') min_swap_amount = max_swap_amount = 0 rows_by_direction: Dict[Tuple[str, str], List[Tuple[str, str, str, float, float, int]]] = {} + bounds_set = min_swap_amount > 0 or max_swap_amount > 0 for from_chain, to_chain in DIRECTION_POOLS: - rates, busy_count, active_set, pinned_rates = reconstruct_window_start_state( + rates, busy_count, active_set, pinned_rates, collaterals = reconstruct_window_start_state( self.state_store, self.event_watcher, from_chain, @@ -698,6 +699,15 @@ def snapshot_current_crown_holders( def executable_check(rate: float, from_chain=from_chain, to_chain=to_chain) -> bool: return is_executable_rate(rate, from_chain, to_chain, min_swap_amount, max_swap_amount) + def can_fund( + hotkey: str, rate: float, from_chain=from_chain, to_chain=to_chain, collaterals=collaterals + ) -> bool: + # Mirror the scoring path's boundary-squat gate so the live table + # never credits a holder whose collateral can't fund their own + # smallest legal leg, which the ledger drops. + min_leg = min_executable_tao_leg(rate, from_chain, to_chain, min_swap_amount, max_swap_amount) + return min_leg == 0 or collaterals.get(hotkey, 0) >= min_leg + holders = crown_holders_at_instant( rates, rewardable_hotkeys, @@ -705,6 +715,7 @@ def executable_check(rate: float, from_chain=from_chain, to_chain=to_chain) -> b active=active_set, lower_rate_wins=lower_rate_wins, executable_rate_check=executable_check, + can_fund_at_rate=can_fund if bounds_set else None, ) if holders: share = 1.0 / len(holders) diff --git a/neurons/validator.py b/neurons/validator.py index 94e200ae..cf2904e2 100644 --- a/neurons/validator.py +++ b/neurons/validator.py @@ -138,21 +138,6 @@ def __init__(self, config=None): metagraph_hotkeys=list(self.metagraph.hotkeys), contract_client=self.contract_client, ) - self.bootstrap_miner_rates() - - self.swap_tracker = SwapTracker(client=self.contract_client, metagraph=self.metagraph) - self.swap_tracker.initialize() - # Late-bind the tracker so TimeoutExtensionFinalized events can write - # the new timeout_block straight into the in-memory active swap. - self.event_watcher.swap_tracker = self.swap_tracker - bt.logging.debug(f'Validator components: fee_divisor={self.fee_divisor}, timeout={timeout_blocks}') - - self.swap_verifier = SwapVerifier( - chain_providers=self.chain_providers, - fee_divisor=self.fee_divisor, - metagraph=self.metagraph, - state_store=self.state_store, - ) # Separate subtensor/contract/providers for axon handlers (thread safety). # axon_lock serialises every call on axon_subtensor's websocket so two @@ -174,6 +159,24 @@ def __init__(self, config=None): lock=self.axon_lock, ) + # bootstrap_miner_rates reads bounds_cache, so the block above must + # run first. + self.bootstrap_miner_rates() + + self.swap_tracker = SwapTracker(client=self.contract_client, metagraph=self.metagraph) + self.swap_tracker.initialize() + # Late-bind the tracker so TimeoutExtensionFinalized events can write + # the new timeout_block straight into the in-memory active swap. + self.event_watcher.swap_tracker = self.swap_tracker + bt.logging.debug(f'Validator components: fee_divisor={self.fee_divisor}, timeout={timeout_blocks}') + + self.swap_verifier = SwapVerifier( + chain_providers=self.chain_providers, + fee_divisor=self.fee_divisor, + metagraph=self.metagraph, + state_store=self.state_store, + ) + # Attach synapse handlers to axon self.attach_axon_handlers() diff --git a/pyproject.toml b/pyproject.toml index 7bbe497b..d753fa6f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "allways" -version = "1.0.7" +version = "1.0.8" description = "Allways - Universal Transaction Layer: Trustless cross-chain swaps on Bittensor Subnet 7" license = "MIT" requires-python = ">=3.10,<3.15" diff --git a/tests/test_scoring_v1.py b/tests/test_scoring_v1.py index 5d4d04e6..cc4cd5ba 100644 --- a/tests/test_scoring_v1.py +++ b/tests/test_scoring_v1.py @@ -21,6 +21,7 @@ replay_crown_time_window, score_and_reward_miners, scoring_window_bounds, + snapshot_current_crown_holders, success_rate, ) from allways.validator.state_store import ValidatorStateStore @@ -729,6 +730,59 @@ def test_busy_state_at_window_start_is_reconstructed(self, tmp_path: Path): store.close() +class TestSnapshotCurrentCrownHolders: + """The per-forward-step live-crown snapshot must stay consistent with the + scoring ledger: it reconstructs the same 5-tuple window state and applies + the same boundary-squat gate.""" + + def _seed_rate(self, store: ValidatorStateStore, hotkey: str, rate: float) -> None: + conn = store.require_connection() + conn.execute( + 'INSERT INTO rate_events (hotkey, from_chain, to_chain, rate, block) VALUES (?, ?, ?, ?, ?)', + (hotkey, 'btc', 'tao', rate, 0), + ) + conn.commit() + + def test_runs_and_credits_funded_holder(self, tmp_path: Path): + """Regression for the 5-vs-4 unpack crash: reconstruct_window_start_state + returns 5 values and this caller must unpack all of them. A funded miner + with an executable rate shows up as the live crown holder.""" + v = make_validator( + tmp_path, + ['hk_funded'], + min_swap_amount=100_000_000, + max_swap_amount=500_000_000, + collaterals={'hk_funded': 500_000_000}, + ) + self._seed_rate(v.state_store, 'hk_funded', 326.0) + + rows = snapshot_current_crown_holders(v) + + holders = [row[2] for row in rows[('btc', 'tao')]] + assert holders == ['hk_funded'] + v.state_store.close() + + def test_boundary_squat_excluded_from_live_table(self, tmp_path: Path): + """The squatter posts the best, executable rate but their 0.15 TAO + collateral can't fund the 0.5 TAO leg it forces. The live table must + drop them to the funded runner-up, matching the ledger.""" + v = make_validator( + tmp_path, + ['hk_squat', 'hk_funded'], + min_swap_amount=100_000_000, + max_swap_amount=500_000_000, + collaterals={'hk_squat': 150_000_000, 'hk_funded': 500_000_000}, + ) + self._seed_rate(v.state_store, 'hk_squat', 50000.0) # best rate, can't fund + self._seed_rate(v.state_store, 'hk_funded', 326.0) # runner-up, can fund + + rows = snapshot_current_crown_holders(v) + + holders = [row[2] for row in rows[('btc', 'tao')]] + assert holders == ['hk_funded'] + v.state_store.close() + + class TestPinnedRateDuringReservation: """Crown calculation must use the pinned rate during the reserved-not-busy window, not the live rate. Closes the bump-after-pin loophole.""" diff --git a/uv.lock b/uv.lock index f6427db6..27624178 100644 --- a/uv.lock +++ b/uv.lock @@ -164,7 +164,7 @@ wheels = [ [[package]] name = "allways" -version = "1.0.7" +version = "1.0.8" source = { editable = "." } dependencies = [ { name = "base58" },