From 08148a6c481cae66f43eddf2b5d1b95a00f2bbe3 Mon Sep 17 00:00:00 2001 From: Landyn Date: Sun, 31 May 2026 10:23:49 -0500 Subject: [PATCH 01/10] Filter unexecutable commitments at parser layer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds optional min_swap_rao / max_swap_rao kwargs to parse_commitment_data, read_miner_commitment, and read_miner_commitments. When supplied, any positive rate that fails is_executable_rate drops the entire pair so the validator never sees sentinel-rate posters. Defaults preserve CLI behavior. Adds read_unexecutable_commitments helper returning hotkeys whose permissive parse succeeds but bounded parse drops — staged for the follow-up auto-deactivation PR; no live caller yet. --- allways/commitments.py | 123 ++++++++++++++++++++++++++++++++++++-- tests/test_commitments.py | 114 ++++++++++++++++++++++++++++++++++- 2 files changed, 230 insertions(+), 7 deletions(-) diff --git a/allways/commitments.py b/allways/commitments.py index db28ed2d..31d57d96 100644 --- a/allways/commitments.py +++ b/allways/commitments.py @@ -1,7 +1,7 @@ """Shared commitment parsing logic — used by validator, miner, and CLI.""" import math -from typing import List, Optional +from typing import List, Optional, Set import bittensor as bt from bittensor.utils import ss58_encode @@ -9,17 +9,28 @@ from allways.chains import SUPPORTED_CHAINS, canonical_pair from allways.classes import MinerPair from allways.constants import COMMITMENT_VERSION -from allways.utils.rate import normalize_rate +from allways.utils.rate import is_executable_rate, normalize_rate SS58_PREFIX = 42 -def parse_commitment_data(raw: str, uid: int = 0, hotkey: str = '') -> Optional[MinerPair]: +def parse_commitment_data( + raw: str, + uid: int = 0, + hotkey: str = '', + *, + min_swap_rao: int = 0, + max_swap_rao: int = 0, +) -> Optional[MinerPair]: """Parse a commitment string into a MinerPair. Format: v{VERSION}:{src_chain}:{src_addr}:{dst_chain}:{dst_addr}:{rate}:{counter_rate} Both rates are 'canonical_dest per 1 canonical_source'. rate is for source→dest, counter_rate for dest→source. Example: v1:btc:bc1q...:tao:5C...:340:350 + + When ``min_swap_rao`` / ``max_swap_rao`` are non-zero, any positive rate that + is not executable under those bounds drops the entire pair. Zero stays + opt-out semantics (one direction disabled), not sentinel. """ try: parts = raw.split(':') @@ -65,6 +76,14 @@ def parse_commitment_data(raw: str, uid: int = 0, hotkey: str = '') -> Optional[ rate, counter_rate = counter_rate, rate rate_str, counter_rate_str = counter_rate_str, rate_str + if min_swap_rao > 0 or max_swap_rao > 0: + if rate > 0 and not is_executable_rate(rate, src_chain, dst_chain, min_swap_rao, max_swap_rao): + return None + if counter_rate > 0 and not is_executable_rate( + counter_rate, dst_chain, src_chain, min_swap_rao, max_swap_rao + ): + return None + return MinerPair( uid=uid, hotkey=hotkey, @@ -125,6 +144,9 @@ def read_miner_commitment( hotkey: str, block: Optional[int] = None, metagraph: Optional['bt.Metagraph'] = None, + *, + min_swap_rao: int = 0, + max_swap_rao: int = 0, ) -> Optional[MinerPair]: """Read a single miner's commitment, optionally at a specific block. @@ -142,19 +164,36 @@ def read_miner_commitment( uid = resolved commitment = get_commitment(subtensor, netuid, hotkey, block=block) if commitment: - return parse_commitment_data(commitment, uid=uid, hotkey=hotkey) + return parse_commitment_data( + commitment, + uid=uid, + hotkey=hotkey, + min_swap_rao=min_swap_rao, + max_swap_rao=max_swap_rao, + ) return None -def read_miner_commitments(subtensor: bt.Subtensor, netuid: int) -> List[MinerPair]: +def read_miner_commitments( + subtensor: bt.Subtensor, + netuid: int, + *, + min_swap_rao: int = 0, + max_swap_rao: int = 0, +) -> List[MinerPair]: """Read all miner commitments for the netuid in a single RPC call. Uses substrate-interface's ``query_map`` over the ``CommitmentOf`` double map keyed by ``(netuid, hotkey)``. One RPC round-trip returns every committed hotkey on the subnet — cheaper than the old N-RPC for-loop, matters most on full validator polling cadence. + + When ``min_swap_rao`` / ``max_swap_rao`` are non-zero, pairs with any + unexecutable positive rate are dropped at the parser layer so the validator + never sees them. """ pairs: List[MinerPair] = [] + dropped = 0 try: metagraph = subtensor.metagraph(netuid) hotkey_to_uid = {metagraph.hotkeys[uid]: uid for uid in range(metagraph.n.item())} @@ -182,11 +221,83 @@ def read_miner_commitments(subtensor: bt.Subtensor, netuid: int) -> List[MinerPa commitment = decode_commitment_field(metadata) if not commitment: continue - pair = parse_commitment_data(commitment, uid=uid, hotkey=hotkey) + pair = parse_commitment_data( + commitment, + uid=uid, + hotkey=hotkey, + min_swap_rao=min_swap_rao, + max_swap_rao=max_swap_rao, + ) if pair: pairs.append(pair) + elif min_swap_rao > 0 or max_swap_rao > 0: + # Re-parse permissively to distinguish "unexecutable under bounds" + # from "malformed/garbage". Only the former counts as dropped. + if parse_commitment_data(commitment, uid=uid, hotkey=hotkey) is not None: + dropped += 1 except (ConnectionError, TimeoutError) as e: bt.logging.warning(f'Transient error reading commitments: {e}') except Exception as e: bt.logging.error(f'Error reading commitments: {e}') + if dropped > 0 and (min_swap_rao > 0 or max_swap_rao > 0): + bt.logging.info( + f'Commitments: dropped {dropped} pair(s) with unexecutable rates ' + f'under bounds [{min_swap_rao}, {max_swap_rao}]' + ) return pairs + + +def read_unexecutable_commitments( + subtensor: bt.Subtensor, + netuid: int, + min_swap_rao: int, + max_swap_rao: int, +) -> Set[str]: + """Hotkeys whose commitment parses permissively but drops under bounds. + + Distinct from malformed/garbage commitments — those don't return either way. + Staged for the follow-up auto-deactivate streak tracker; no live caller in + this PR. + """ + unexecutable: Set[str] = set() + if min_swap_rao <= 0 and max_swap_rao <= 0: + return unexecutable + try: + metagraph = subtensor.metagraph(netuid) + hotkey_to_uid = {metagraph.hotkeys[uid]: uid for uid in range(metagraph.n.item())} + result = subtensor.substrate.query_map( + module='Commitments', + storage_function='CommitmentOf', + params=[netuid], + ) + for key, metadata in result: + raw = key.value if hasattr(key, 'value') else key + if isinstance(raw, tuple) and len(raw) == 1: + raw = raw[0] + if isinstance(raw, (tuple, list)): + raw = bytes(raw) + if isinstance(raw, (bytes, bytearray)) and len(raw) == 32: + hotkey = ss58_encode(bytes(raw), SS58_PREFIX) + else: + hotkey = str(raw) + if hotkey not in hotkey_to_uid: + continue + commitment = decode_commitment_field(metadata) + if not commitment: + continue + permissive = parse_commitment_data(commitment, hotkey=hotkey) + if permissive is None: + continue + bounded = parse_commitment_data( + commitment, + hotkey=hotkey, + min_swap_rao=min_swap_rao, + max_swap_rao=max_swap_rao, + ) + if bounded is None: + unexecutable.add(hotkey) + except (ConnectionError, TimeoutError) as e: + bt.logging.warning(f'Transient error reading commitments: {e}') + except Exception as e: + bt.logging.error(f'Error reading commitments: {e}') + return unexecutable diff --git a/tests/test_commitments.py b/tests/test_commitments.py index 53dc6621..552acad9 100644 --- a/tests/test_commitments.py +++ b/tests/test_commitments.py @@ -3,7 +3,11 @@ from types import SimpleNamespace from unittest.mock import MagicMock, patch -from allways.commitments import parse_commitment_data, read_miner_commitments +from allways.commitments import ( + parse_commitment_data, + read_miner_commitments, + read_unexecutable_commitments, +) class TestParseCommitmentData: @@ -288,3 +292,111 @@ def test_transient_error_returns_empty_list(self): with patch('allways.commitments.bt.logging.warning'): pairs = read_miner_commitments(subtensor, netuid=7) assert pairs == [] + + +# Sentinel rates chosen so is_executable_rate returns False under any normal +# (min, max) BTC/TAO bounds: 1e9 TAO/BTC is so high even 1 sat maps above max, +# and 1e-9 TAO/BTC inverted is the symmetric low-side rejection. +BOUNDS_REASONABLE = {'min_swap_rao': 500_000_000, 'max_swap_rao': 5_000_000_000} # 0.5–5 TAO + + +class TestParseCommitmentDataExecutability: + def test_drops_pair_with_sentinel_forward_rate_when_bounds_set(self): + raw = 'v1:btc:bc1qaddr:tao:5Caddr:1e9:350' + assert parse_commitment_data(raw, **BOUNDS_REASONABLE) is None + + def test_drops_pair_with_sentinel_counter_rate_when_bounds_set(self): + raw = 'v1:btc:bc1qaddr:tao:5Caddr:340:1e-9' + assert parse_commitment_data(raw, **BOUNDS_REASONABLE) is None + + def test_admits_pair_with_one_sentinel_one_zero(self): + """Zero stays opt-out semantics — a pair with one direction at 0 and + the other direction executable must not be dropped just because 0 + looks sentinel-shaped.""" + raw = 'v1:btc:bc1qaddr:tao:5Caddr:340:0' + pair = parse_commitment_data(raw, **BOUNDS_REASONABLE) + assert pair is not None + assert pair.rate == 340.0 + assert pair.counter_rate == 0.0 + + def test_permissive_when_bounds_zero(self): + """Default-permissive: with bounds at 0/0, even absurd rates parse.""" + raw = 'v1:btc:bc1qaddr:tao:5Caddr:1e9:1e-9' + pair = parse_commitment_data(raw) + assert pair is not None + + def test_bounds_at_max_zero_uses_min_only(self): + """max_swap_rao=0 is the contract's 'unset' sentinel; min-only bounds + still admit a normal rate (no upper cap to enforce).""" + raw = 'v1:btc:bc1qaddr:tao:5Caddr:340:350' + pair = parse_commitment_data(raw, min_swap_rao=500_000_000, max_swap_rao=0) + assert pair is not None + + +class TestReadMinerCommitmentsExecutability: + def make_subtensor(self, hotkeys, rows): + subtensor = MagicMock() + metagraph = SimpleNamespace( + hotkeys=list(hotkeys), + n=SimpleNamespace(item=lambda: len(hotkeys)), + ) + subtensor.metagraph.return_value = metagraph + + def fake_query_map(module, storage_function, params): + for hotkey, raw in rows: + key = SimpleNamespace(value=hotkey) + metadata = SimpleNamespace(value={'info': {'fields': [{'Raw0': '0x' + raw.encode().hex()}]}}) + yield key, metadata + + subtensor.substrate.query_map.side_effect = fake_query_map + return subtensor + + def test_drops_sentinel_pair_when_bounds_supplied(self): + subtensor = self.make_subtensor( + hotkeys=['hk_a', 'hk_b'], + rows=[ + ('hk_a', 'v1:btc:a:tao:a:340:350'), + ('hk_b', 'v1:btc:b:tao:b:1e9:350'), + ], + ) + pairs = read_miner_commitments(subtensor, netuid=7, **BOUNDS_REASONABLE) + assert [p.hotkey for p in pairs] == ['hk_a'] + + def test_permissive_when_no_bounds_admits_sentinel(self): + subtensor = self.make_subtensor( + hotkeys=['hk_a'], + rows=[('hk_a', 'v1:btc:a:tao:a:1e9:350')], + ) + pairs = read_miner_commitments(subtensor, netuid=7) + assert [p.hotkey for p in pairs] == ['hk_a'] + + +class TestReadUnexecutableCommitments: + def make_subtensor(self, hotkeys, rows): + subtensor = MagicMock() + metagraph = SimpleNamespace( + hotkeys=list(hotkeys), + n=SimpleNamespace(item=lambda: len(hotkeys)), + ) + subtensor.metagraph.return_value = metagraph + + def fake_query_map(module, storage_function, params): + for hotkey, raw in rows: + key = SimpleNamespace(value=hotkey) + metadata = SimpleNamespace(value={'info': {'fields': [{'Raw0': '0x' + raw.encode().hex()}]}}) + yield key, metadata + + subtensor.substrate.query_map.side_effect = fake_query_map + return subtensor + + def test_returns_only_bounded_drops_not_malformed(self): + subtensor = self.make_subtensor( + hotkeys=['hk_good', 'hk_sentinel', 'hk_garbage'], + rows=[ + ('hk_good', 'v1:btc:a:tao:a:340:350'), + ('hk_sentinel', 'v1:btc:b:tao:b:1e9:350'), + ('hk_garbage', 'x'), + ], + ) + out = read_unexecutable_commitments(subtensor, netuid=7, **BOUNDS_REASONABLE) + assert out == {'hk_sentinel'} From 34a6640ce961b9cf9ae638b92d0eedb7b3a39352 Mon Sep 17 00:00:00 2001 From: Landyn Date: Sun, 31 May 2026 10:26:13 -0500 Subject: [PATCH 02/10] Terminate stale rates when commitment drops (closes free-rider hole) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A miner who builds credibility on a sane rate and then overwrites their commitment with parser-rejected garbage (wrong version, NaN, malformed, unsupported chain) leaves a stale positive rate in state_store. Scoring keeps crediting the dead rate until deregistration — they free-ride emissions while being unreachable. refresh_miner_rates now runs a second sweep after the per-direction loop: any (hotkey, from, to) that was previously > 0 in last_known_rates but is absent from this poll's admitted set gets a 0-terminator emitted to state_store. Covers both parser-poisoned commitments and rates that just dropped below executability bounds. bootstrap_miner_rates hydrates last_known_rates from persisted state before seeding admitted pairs so the same defense fires on the first poll after a restart that crossed a miner's parser-poison flip. Validator bounds_cache values are also threaded into read_miner_commitments so the parser drops sentinel-rate pairs before they ever reach the loop. --- allways/validator/forward.py | 45 ++++++++++++- neurons/validator.py | 33 +++++++++- tests/test_poll_commitments.py | 111 +++++++++++++++++++++++++++++++++ 3 files changed, 187 insertions(+), 2 deletions(-) diff --git a/allways/validator/forward.py b/allways/validator/forward.py index 1bc586c4..ef3f4e91 100644 --- a/allways/validator/forward.py +++ b/allways/validator/forward.py @@ -378,12 +378,29 @@ def poll_commitments(self: Validator) -> None: def refresh_miner_rates(self: Validator) -> None: try: - pairs = read_miner_commitments(self.subtensor, self.config.netuid) + max_swap_amount = int(self.bounds_cache.max_swap_amount()) + except Exception as e: + bt.logging.warning(f'max_swap_amount read failed: {e}') + max_swap_amount = 0 + try: + min_swap_amount = int(self.bounds_cache.min_swap_amount()) + except Exception as e: + bt.logging.warning(f'min_swap_amount read failed: {e}') + min_swap_amount = 0 + + try: + pairs = read_miner_commitments( + self.subtensor, + self.config.netuid, + min_swap_rao=min_swap_amount, + max_swap_rao=max_swap_amount, + ) except Exception as e: bt.logging.warning(f'Commitment poll failed: {e}') return current_hotkeys = set(self.metagraph.hotkeys) + admitted_keys: set[tuple[str, str, str]] = set() for pair in pairs: if pair.hotkey not in current_hotkeys: @@ -406,6 +423,7 @@ def refresh_miner_rates(self: Validator) -> None: ) self.last_known_rates[key] = 0.0 continue + admitted_keys.add(key) if self.last_known_rates.get(key) == r: continue self.state_store.insert_rate_event( @@ -417,6 +435,31 @@ def refresh_miner_rates(self: Validator) -> None: ) self.last_known_rates[key] = r + # SECOND SWEEP: terminate previously-positive directions that vanished from + # this poll. Covers parser-poison (commitment overwritten with garbage) and + # bounds-tighten exits (rate dropped below executability). Without this a + # miner's stale positive rate keeps earning crown until deregistration. + for key, rate in list(self.last_known_rates.items()): + if rate <= 0: + continue + hk, from_c, to_c = key + if hk not in current_hotkeys: + continue # purge_deregistered_hotkeys handles dereg + if key in admitted_keys: + continue + latest = self.state_store.get_latest_rate_before(hk, from_c, to_c, self.block) + if latest is None or latest[0] <= 0: + continue + self.state_store.insert_rate_event( + hotkey=hk, + from_chain=from_c, + to_chain=to_c, + rate=0.0, + block=self.block, + ) + self.last_known_rates[key] = 0.0 + bt.logging.info(f'forward: terminating rate for {hk[:8]} {from_c}->{to_c} — commitment dropped') + def purge_deregistered_hotkeys(self: Validator) -> None: current_hotkeys = set(self.metagraph.hotkeys) diff --git a/neurons/validator.py b/neurons/validator.py index 62a266ec..5b5b856e 100644 --- a/neurons/validator.py +++ b/neurons/validator.py @@ -24,6 +24,7 @@ from allways.commitments import read_miner_commitments from allways.constants import ( DEFAULT_FULFILLMENT_TIMEOUT_BLOCKS, + DIRECTION_POOLS, FEE_DIVISOR, FORWARD_STALL_THRESHOLD_SECONDS, SCORING_WINDOW_BLOCKS, @@ -200,13 +201,43 @@ def bootstrap_miner_rates(self) -> None: seed one anchor event per (hotkey, direction) at cursor — mirrors the active-flag anchor that event_watcher.initialize already does.""" try: - pairs = read_miner_commitments(self.subtensor, self.config.netuid) + max_swap_amount = int(self.bounds_cache.max_swap_amount()) + except Exception as e: + bt.logging.warning(f'max_swap_amount read failed: {e}') + max_swap_amount = 0 + try: + min_swap_amount = int(self.bounds_cache.min_swap_amount()) + except Exception as e: + bt.logging.warning(f'min_swap_amount read failed: {e}') + min_swap_amount = 0 + + try: + pairs = read_miner_commitments( + self.subtensor, + self.config.netuid, + min_swap_rao=min_swap_amount, + max_swap_rao=max_swap_amount, + ) except Exception as e: bt.logging.warning(f'Rate bootstrap: commitment read failed: {e}') return anchor_block = max(0, self.block - SCORING_WINDOW_BLOCKS) current_hotkeys = set(self.metagraph.hotkeys) + + # Hydrate last_known_rates from persisted state BEFORE seeding the + # admitted-pairs cache. Without this, a validator restart on or after + # a miner's parser-poison (or sentinel) flip would never see the prior + # positive in cache, so refresh_miner_rates' second sweep couldn't + # emit a terminator and the stale rate would keep earning crown until + # the next genuine on-chain event resets it. + for from_chain, to_chain in DIRECTION_POOLS: + for hk, (rate, _block) in self.state_store.get_latest_rates_before( + from_chain, to_chain, anchor_block + ).items(): + if hk in current_hotkeys and rate > 0: + self.last_known_rates[(hk, from_chain, to_chain)] = rate + seeded = 0 for pair in pairs: if pair.hotkey not in current_hotkeys: diff --git a/tests/test_poll_commitments.py b/tests/test_poll_commitments.py index 62e234e1..2e28743c 100644 --- a/tests/test_poll_commitments.py +++ b/tests/test_poll_commitments.py @@ -33,6 +33,9 @@ def make_validator(tmp_path: Path, hotkeys=None) -> SimpleNamespace: store = ValidatorStateStore(db_path=tmp_path / 'state.db') metagraph = SimpleNamespace(hotkeys=list(hotkeys or ['hk_a', 'hk_b'])) config = SimpleNamespace(netuid=2) + bounds_cache = MagicMock() + bounds_cache.min_swap_amount.return_value = 0 + bounds_cache.max_swap_amount.return_value = 0 return SimpleNamespace( block=1000, subtensor=MagicMock(), @@ -41,6 +44,7 @@ def make_validator(tmp_path: Path, hotkeys=None) -> SimpleNamespace: state_store=store, contract_client=MagicMock(), event_watcher=MagicMock(), + bounds_cache=bounds_cache, last_known_rates={}, ) @@ -245,6 +249,113 @@ def raiser(*args, **kwargs): v.state_store.close() +class TestPollCommitmentsSentinel: + def test_previously_positive_direction_terminated_when_pair_drops(self, tmp_path: Path): + """Regression guard for the parser-poison free-rider hole. + + Miner posts a sane rate, then overwrites their commitment with garbage + (or rate goes unexecutable). Pair vanishes from the poll, but the + prior positive rate is still in state_store. The second sweep must + emit a 0-terminator so scoring stops crediting the stale rate. + """ + v = make_validator(tmp_path) + + with patch( + 'allways.validator.forward.read_miner_commitments', + return_value=[make_pair('hk_a', rate=0.00015, counter_rate=6500.0)], + ): + poll_commitments(v) + + v.block += 1 + # Pair vanishes entirely on the next poll (parser-poisoned commitment). + with patch('allways.validator.forward.read_miner_commitments', return_value=[]): + poll_commitments(v) + + tao_btc = v.state_store.get_rate_events_in_range('tao', 'btc', 0, 10_000) + btc_tao = v.state_store.get_rate_events_in_range('btc', 'tao', 0, 10_000) + assert [e['rate'] for e in tao_btc] == [0.00015, 0.0] + assert [e['rate'] for e in btc_tao] == [6500.0, 0.0] + assert v.last_known_rates[('hk_a', 'tao', 'btc')] == 0.0 + assert v.last_known_rates[('hk_a', 'btc', 'tao')] == 0.0 + v.state_store.close() + + def test_no_terminator_when_never_offered(self, tmp_path: Path): + """Direction that was never positive must not get a spurious 0 event.""" + v = make_validator(tmp_path) + + with patch('allways.validator.forward.read_miner_commitments', return_value=[]): + poll_commitments(v) + + assert v.state_store.get_rate_events_in_range('tao', 'btc', 0, 10_000) == [] + assert v.state_store.get_rate_events_in_range('btc', 'tao', 0, 10_000) == [] + v.state_store.close() + + def test_bounds_threaded_into_read(self, tmp_path: Path): + """Validator bounds_cache values must flow into read_miner_commitments + so the parser drops unexecutable pairs before they ever reach the loop. + """ + v = make_validator(tmp_path) + v.bounds_cache.min_swap_amount.return_value = 500_000_000 + v.bounds_cache.max_swap_amount.return_value = 5_000_000_000 + + with patch('allways.validator.forward.read_miner_commitments', return_value=[]) as mock_read: + poll_commitments(v) + + assert mock_read.call_args.kwargs['min_swap_rao'] == 500_000_000 + assert mock_read.call_args.kwargs['max_swap_rao'] == 5_000_000_000 + v.state_store.close() + + +class TestBootstrapHydratesLastKnownRates: + """bootstrap_miner_rates must seed last_known_rates from persisted state so + the runtime second sweep catches stale positives from miners + parser-poisoned before this restart.""" + + def _make_validator_with_bootstrap(self, tmp_path: Path, hotkeys=None) -> SimpleNamespace: + v = make_validator(tmp_path, hotkeys=hotkeys) + # bootstrap_miner_rates reads self.block and SCORING_WINDOW_BLOCKS to + # pick an anchor; default v.block=1000 is fine. + return v + + def test_bootstrap_seeds_from_state_store_for_stale_positives(self, tmp_path: Path): + """A positive rate persisted before restart but absent from this poll + must still be in last_known_rates after bootstrap.""" + from neurons.validator import Validator + + v = self._make_validator_with_bootstrap(tmp_path, hotkeys=['hk_a']) + anchor_block = max(0, v.block - SCORING_WINDOW_BLOCKS) + v.state_store.insert_rate_event( + hotkey='hk_a', from_chain='tao', to_chain='btc', rate=0.00015, block=anchor_block - 10 + ) + + with patch('neurons.validator.read_miner_commitments', return_value=[]): + Validator.bootstrap_miner_rates(v) + + assert v.last_known_rates.get(('hk_a', 'tao', 'btc')) == 0.00015 + v.state_store.close() + + def test_post_bootstrap_first_poll_terminates_parser_poisoned_miner(self, tmp_path: Path): + """End-to-end: persisted positive → bootstrap hydrates → next poll sees + no commitment → 0-terminator emitted.""" + from neurons.validator import Validator + + v = self._make_validator_with_bootstrap(tmp_path, hotkeys=['hk_a']) + anchor_block = max(0, v.block - SCORING_WINDOW_BLOCKS) + v.state_store.insert_rate_event( + hotkey='hk_a', from_chain='tao', to_chain='btc', rate=0.00015, block=anchor_block - 10 + ) + + with patch('neurons.validator.read_miner_commitments', return_value=[]): + Validator.bootstrap_miner_rates(v) + + with patch('allways.validator.forward.read_miner_commitments', return_value=[]): + poll_commitments(v) + + tao_btc = v.state_store.get_rate_events_in_range('tao', 'btc', 0, 10_000) + assert [e['rate'] for e in tao_btc] == [0.00015, 0.0] + v.state_store.close() + + class TestPollCommitmentsPruning: def test_prune_runs_via_scoring_pass_not_commitment_poll(self, tmp_path: Path): """Pruning moved out of the per-tick path and into the scoring round — From 9630d69daf97b2ee62ea522658a5332b263131b7 Mon Sep 17 00:00:00 2001 From: Landyn Date: Sun, 31 May 2026 10:29:16 -0500 Subject: [PATCH 03/10] Add executability gates to reserve + activate handlers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit handle_swap_reserve now checks is_executable_rate against the miner's quoted rate using cached swap bounds before voting reserve. handle_miner_activate threads the same bounds into its commitment read so a sentinel-rate poster can't get re-activated. handle_swap_confirm is intentionally untouched — the reservation pins the rate at reserve block, and re-gating against shifted bounds at confirm time would re-introduce the failure mode pinning was designed to prevent. --- allways/validator/axon_handlers.py | 18 +++++++-- tests/test_axon_handlers.py | 65 +++++++++++++++++++++++++++++- 2 files changed, 77 insertions(+), 6 deletions(-) diff --git a/allways/validator/axon_handlers.py b/allways/validator/axon_handlers.py index ad767ee0..0fb2a0c8 100644 --- a/allways/validator/axon_handlers.py +++ b/allways/validator/axon_handlers.py @@ -23,7 +23,12 @@ from allways.synapses import MinerActivateSynapse, SwapConfirmSynapse, SwapReserveSynapse from allways.utils.logging import miner_label as _miner_label from allways.utils.proofs import reserve_proof_message, swap_proof_message -from allways.utils.rate import calculate_to_amount, derive_tao_leg, quote_within_slippage +from allways.utils.rate import ( + calculate_to_amount, + derive_tao_leg, + is_executable_rate, + quote_within_slippage, +) from allways.utils.scale import encode_bytes, encode_str, encode_u128 from allways.validator.state_store import PendingConfirm, ReservationPin @@ -227,9 +232,11 @@ async def handle_miner_activate( netuid=validator.config.netuid, hotkey=miner_hotkey, metagraph=validator.metagraph, + min_swap_rao=validator.bounds_cache.min_swap_amount(), + max_swap_rao=validator.bounds_cache.max_swap_amount(), ) if commitment is None: - reject_synapse(synapse, 'No commitment found', ctx) + reject_synapse(synapse, 'No valid commitment (missing, malformed, or rate not executable)', ctx) return synapse collateral, active, _, _, _ = contract.get_miner_snapshot(miner_hotkey) @@ -358,6 +365,11 @@ async def handle_swap_reserve( if reserve_rate <= 0: reject_synapse(synapse, 'Miner does not support this swap direction', ctx) return synapse + min_swap = validator.bounds_cache.min_swap_amount() + max_swap = validator.bounds_cache.max_swap_amount() + if not is_executable_rate(reserve_rate, synapse.from_chain, synapse.to_chain, min_swap, max_swap): + reject_synapse(synapse, 'Miner rate is not executable under current swap bounds', ctx) + return synapse bt.logging.info( f'{ctx}: commitment ok — miner_rate={reserve_rate_str or reserve_rate} ' f'miner_from={commitment.from_address} miner_to={commitment.to_address}' @@ -413,8 +425,6 @@ async def handle_swap_reserve( reject_synapse(synapse, 'Miner collateral below minimum', ctx) return synapse - min_swap = validator.bounds_cache.min_swap_amount() - max_swap = validator.bounds_cache.max_swap_amount() if min_swap > 0 and synapse.tao_amount < min_swap: reject_synapse(synapse, f'Swap amount below minimum ({synapse.tao_amount} < {min_swap} rao)', ctx) return synapse diff --git a/tests/test_axon_handlers.py b/tests/test_axon_handlers.py index 153f313f..e460da5c 100644 --- a/tests/test_axon_handlers.py +++ b/tests/test_axon_handlers.py @@ -14,8 +14,8 @@ from allways.chain_providers.base import TransactionInfo from allways.classes import MinerPair, Reservation from allways.contract_client import ContractError -from allways.synapses import SwapConfirmSynapse, SwapReserveSynapse -from allways.validator.axon_handlers import handle_swap_confirm, handle_swap_reserve +from allways.synapses import MinerActivateSynapse, SwapConfirmSynapse, SwapReserveSynapse +from allways.validator.axon_handlers import handle_miner_activate, handle_swap_confirm, handle_swap_reserve from allways.validator.state_store import PendingConfirm, ReservationPin, ValidatorStateStore @@ -868,3 +868,64 @@ def test_successful_reserve_pins_synchronously(self): assert pin.miner_from_address == 'bc1-miner' assert pin.miner_to_address == '5miner' assert pin.reserved_until == 1050 + + +class TestReserveExecutabilityGate: + def test_handle_swap_reserve_rejects_sentinel_rate(self): + """An executable-bounded rate that is unexecutable under cached bounds + must be rejected at reserve time so no reservation is voted on.""" + validator = make_reserve_validator() + # Bounds that make BTC/TAO rate 1e9 unexecutable on the BTC→TAO leg. + validator.bounds_cache.min_swap_amount.return_value = 500_000_000 + validator.bounds_cache.max_swap_amount.return_value = 5_000_000_000 + unexecutable = make_commitment() + unexecutable.rate = 1e9 + unexecutable.rate_str = '1e9' + + result = run_reserve_handler(validator, make_reserve_synapse(), commitment=unexecutable) + + assert result.accepted is False + assert 'not executable' in result.rejection_reason + validator.axon_contract_client.vote_reserve.assert_not_called() + + +class TestMinerActivateExecutability: + def _activate_synapse( + self, hotkey: str = '5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY' + ) -> MinerActivateSynapse: + from bittensor.core.synapse import TerminalInfo + + synapse = MinerActivateSynapse(hotkey=hotkey, signature='sig', message='msg') + synapse.dendrite = TerminalInfo(hotkey=hotkey) + return synapse + + def _activate_validator(self) -> MagicMock: + validator = MagicMock() + validator.config.netuid = 2 + validator.axon_lock = threading.Lock() + validator.axon_subtensor.is_hotkey_registered.return_value = True + validator.bounds_cache = MagicMock() + validator.bounds_cache.min_collateral.return_value = 0 + validator.bounds_cache.min_swap_amount.return_value = 0 + validator.bounds_cache.max_swap_amount.return_value = 0 + validator.axon_contract_client.get_miner_snapshot.return_value = (10_000_000_000, False, False, 0, 0) + validator.wallet = MagicMock() + return validator + + def test_handle_miner_activate_rejects_sentinel_commitment(self): + """When the bounded commitment read returns None (sentinel/unexecutable), + activate must surface the no-valid-commitment rejection rather than + voting the miner active.""" + validator = self._activate_validator() + validator.bounds_cache.min_swap_amount.return_value = 500_000_000 + validator.bounds_cache.max_swap_amount.return_value = 5_000_000_000 + + with patch('allways.validator.axon_handlers.read_miner_commitment', return_value=None) as mock_read: + result = asyncio.run(handle_miner_activate(validator, self._activate_synapse())) + + assert result.accepted is False + assert 'No valid commitment' in result.rejection_reason + # Bounds must flow through so the parser drops sentinels before activate sees them. + assert mock_read.call_args.kwargs['min_swap_rao'] == 500_000_000 + assert mock_read.call_args.kwargs['max_swap_rao'] == 5_000_000_000 + validator.axon_contract_client.vote_activate.assert_not_called() From 0c16158d988809a76ed3bd53f6f4c4453e1f8682 Mon Sep 17 00:00:00 2001 From: Landyn Date: Sun, 31 May 2026 10:32:25 -0500 Subject: [PATCH 04/10] Hide unexecutable miners from user-facing CLI views MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit view rates filters rows whose neither direction is executable under cached bounds and surfaces a count in the footer. view miners keeps all rows (operator view) but decorates per-direction rate cells with [red]N ✗[/red] when the rate is unexecutable. swap quote adds an "every miner is sentinel" summary branch so the copy matches the actual failure mode instead of suggesting the user retry with a smaller amount. swap now hoists the bounds read above the miner table and drops miners whose rates fail is_executable_rate before sorting, so the ranked picker never offers a sentinel as the default choice. --- allways/cli/swap_commands/quote.py | 6 +++++ allways/cli/swap_commands/swap.py | 38 ++++++++++++++++++++------- allways/cli/swap_commands/view.py | 41 +++++++++++++++++++++++++++--- tests/test_swap_quote.py | 18 +++++++++++++ 4 files changed, 91 insertions(+), 12 deletions(-) diff --git a/allways/cli/swap_commands/quote.py b/allways/cli/swap_commands/quote.py index 064eaae9..2b43d5d0 100644 --- a/allways/cli/swap_commands/quote.py +++ b/allways/cli/swap_commands/quote.py @@ -238,6 +238,12 @@ def quote_command(from_chain: str, to_chain: str, amount: float): f' [yellow]No miner can accept this — every routable miner is below the contract min ' f'({from_rao(min_swap_rao):.4f} TAO equivalent). Increase --amount.[/yellow]\n' ) + elif len(available) > 0 and all( + not is_executable_rate(p.rate, from_chain, to_chain, min_swap_rao, max_swap_rao) for (p, _, _) in available + ): + console.print( + ' [yellow]Every miner is posting an unexecutable rate (sentinel). Try again later.[/yellow]\n' + ) else: console.print( ' [yellow]No miner can fulfill this swap at the requested amount — try a smaller amount ' diff --git a/allways/cli/swap_commands/swap.py b/allways/cli/swap_commands/swap.py index 73ba3d64..a7d50c65 100644 --- a/allways/cli/swap_commands/swap.py +++ b/allways/cli/swap_commands/swap.py @@ -41,7 +41,13 @@ from allways.contract_client import ContractError from allways.synapses import SwapConfirmSynapse, SwapReserveSynapse from allways.utils.proofs import reserve_proof_message, swap_proof_message -from allways.utils.rate import apply_fee_deduction, calculate_to_amount, check_swap_viability, derive_tao_leg +from allways.utils.rate import ( + apply_fee_deduction, + calculate_to_amount, + check_swap_viability, + derive_tao_leg, + is_executable_rate, +) def to_smallest_unit(amount: float, chain_id: str) -> int: @@ -767,6 +773,28 @@ def swap_now_command( console.print('[yellow]Cancelled[/yellow]') return + # Read swap bounds early so we can filter out sentinel-rate posters before + # they pollute the ranked miner table. + try: + with loading('Reading protocol swap bounds...'): + min_swap = client.get_min_swap_amount() + max_swap = client.get_max_swap_amount() + except ContractError: + console.print('[yellow]Warning: could not verify swap bounds (contract unreachable)[/yellow]') + min_swap, max_swap = 0, 0 + + if min_swap > 0 or max_swap > 0: + available_miners = [ + (p, c) + for (p, c) in available_miners + if is_executable_rate(p.rate, from_chain, to_chain, min_swap, max_swap) + ] + if not available_miners: + console.print( + f'[yellow]Every miner posts an unexecutable rate for {from_chain.upper()}/{to_chain.upper()}.[/yellow]' + ) + return + # Rate is TAO/BTC: highest is best when receiving TAO, lowest when sending TAO. canon_from, canon_to = canonical_pair(from_chain, to_chain) canon_is_reverse = from_chain != canon_from @@ -876,14 +904,6 @@ def swap_now_command( # collateral. Mirrors vote_reserve (bounds) and vote_initiate # (collateral) so we fail loudly here instead of after the user has # reserved and sent funds. - try: - with loading('Reading protocol swap bounds...'): - min_swap = client.get_min_swap_amount() - max_swap = client.get_max_swap_amount() - except ContractError: - console.print('[yellow]Warning: could not verify swap bounds (contract unreachable)[/yellow]') - min_swap, max_swap = 0, 0 - viable, reason = check_swap_viability(tao_amount, selected_collateral, min_swap, max_swap) if not viable: console.print( diff --git a/allways/cli/swap_commands/view.py b/allways/cli/swap_commands/view.py index 0c2462bc..4b4c3b94 100644 --- a/allways/cli/swap_commands/view.py +++ b/allways/cli/swap_commands/view.py @@ -37,6 +37,7 @@ MAX_EXTENSIONS_PER_SWAP, ) from allways.contract_client import ContractError +from allways.utils.rate import is_executable_rate def _dashboard_url() -> str: @@ -151,6 +152,13 @@ def view_miners( fulfillment_timeout = 0 current_block = 0 + try: + min_swap_rao = client.get_min_swap_amount() + max_swap_rao = client.get_max_swap_amount() + except ContractError: + min_swap_rao = 0 + max_swap_rao = 0 + def _trunc(s: str) -> str: if full or not s: return s @@ -232,13 +240,22 @@ def _trunc(s: str) -> str: status_str = '[dim]offline[/dim]' status_sort_token = 'offline' - fwd_display = f'{pair.rate:g}' if pair.rate > 0 else '[dim]—[/dim]' + def _rate_cell(rate: float, from_c: str, to_c: str) -> str: + if rate <= 0: + return '[dim]—[/dim]' + if (min_swap_rao > 0 or max_swap_rao > 0) and not is_executable_rate( + rate, from_c, to_c, min_swap_rao, max_swap_rao + ): + return f'[red]{rate:g} ✗[/red]' + return f'{rate:g}' + + fwd_display = _rate_cell(pair.rate, pair.from_chain, pair.to_chain) if pair.counter_rate > 0: - ctr_display = f'{pair.counter_rate:g}' + ctr_display = _rate_cell(pair.counter_rate, pair.to_chain, pair.from_chain) elif pair.counter_rate_str: ctr_display = '[dim]—[/dim]' else: - ctr_display = f'{pair.rate:g}' + ctr_display = _rate_cell(pair.rate, pair.from_chain, pair.to_chain) rows.append( { @@ -413,6 +430,20 @@ def view_rates( # aggregated. None if the API is unreachable; the table still renders. reliability = fetch_miner_reliability() + unexecutable_hidden = 0 + if min_swap_rao > 0 or max_swap_rao > 0: + kept: list[tuple] = [] + for p, c in pairs_with_collateral: + fwd_ok = p.rate > 0 and is_executable_rate(p.rate, p.from_chain, p.to_chain, min_swap_rao, max_swap_rao) + rev_ok = p.counter_rate > 0 and is_executable_rate( + p.counter_rate, p.to_chain, p.from_chain, min_swap_rao, max_swap_rao + ) + if fwd_ok or rev_ok: + kept.append((p, c)) + else: + unexecutable_hidden += 1 + pairs_with_collateral = kept + if pair: parts = pair.lower().split('-') if len(parts) != 2: @@ -559,6 +590,10 @@ def _trunc(s: str) -> str: shown = len(pairs_with_collateral) if shown != total_before_filter: console.print(f'[dim]Showing {shown} of {total_before_filter} miners after filters.[/dim]') + if unexecutable_hidden: + console.print( + f'[dim]Hid {unexecutable_hidden} miner(s) with unexecutable rates under current swap bounds.[/dim]' + ) if reliability is None: console.print('[yellow]Reliability unavailable — swap-history API unreachable.[/yellow]') else: diff --git a/tests/test_swap_quote.py b/tests/test_swap_quote.py index 0ecb078c..74131344 100644 --- a/tests/test_swap_quote.py +++ b/tests/test_swap_quote.py @@ -155,3 +155,21 @@ def test_sentinel_low_rate_labeled_unexecutable(self): sentinel_line = next((line for line in result.output.splitlines() if '193' in line), '') assert 'unexecutable' in sentinel_line, f'expected sentinel-row marked unexecutable; got: {sentinel_line!r}' assert 'available' not in sentinel_line + + +class TestQuoteAllSentinelSummary: + """When every available miner posts an unexecutable rate, the summary + must surface that explicitly so the user doesn't think it's a transient + no-viable-amount problem.""" + + def test_quote_summary_lists_all_sentinel_case(self): + pairs = [ + _pair(14, 'btc', 'tao', 1.797e308), + _pair(189, 'btc', 'tao', 1.797e308), + ] + collaterals = {'hk14': 10**12, 'hk189': 10**12} + result = _invoke(pairs, collaterals, ['--from', 'btc', '--to', 'tao', '--amount', '0.0005']) + assert result.exit_code == 0, result.output + assert 'unexecutable rate (sentinel)' in result.output + # Make sure the generic "try a smaller amount" branch isn't also emitted. + assert 'try a smaller amount' not in result.output From 945f596b9f57c165b045a2edcbfc2c71859a4a26 Mon Sep 17 00:00:00 2001 From: Landyn Date: Sun, 31 May 2026 10:34:13 -0500 Subject: [PATCH 05/10] Cover bounds-transition non-retroactivity in scoring Codifies that scoring is per-window: bounds-tightening between scoring rounds does not retroactively zero credit earned in the previous round. The replay function is idempotent and only reads bounds for the window it's invoked on, so prior rounds' results are preserved. --- tests/test_scoring_v1.py | 66 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) diff --git a/tests/test_scoring_v1.py b/tests/test_scoring_v1.py index 66bd2280..27be337f 100644 --- a/tests/test_scoring_v1.py +++ b/tests/test_scoring_v1.py @@ -333,6 +333,72 @@ def test_sentinel_rate_earns_no_crown_when_bounds_set(self, tmp_path: Path): assert crown == {'hk_sane': 1000.0} store.close() + def test_bounds_transition_does_not_retroactively_zero_pre_bounds_credit(self, tmp_path: Path): + """Bounds-tightening between scoring rounds must not zero out a miner's + credit from the previous round. + + Production scoring runs per ~hour window; each round reads bounds fresh + and applies them to its window only. If bounds tighten between round N + (permissive) and round N+1 (strict), round N's credit stays as it was — + scoring is per-window, never re-evaluated. + + Verified by replaying the same rate-event store twice for adjacent + windows: the permissive window credits the miner, the strict window + does not, and the permissive replay's result is unchanged. + """ + store = ValidatorStateStore(db_path=tmp_path / 'state.db') + watcher = make_watcher(store, active={'hk_a'}) + conn = store.require_connection() + # A rate that lands in [min, max] = [0, very-large] but is unexecutable + # once max is tightened to a small value: 1e10 TAO/BTC has no fundable + # source in [0.1, 0.5] TAO (every sat maps above max). + conn.execute( + 'INSERT INTO rate_events (hotkey, from_chain, to_chain, rate, block) VALUES (?, ?, ?, ?, ?)', + ('hk_a', 'btc', 'tao', 1e10, 0), + ) + conn.commit() + + # Round N — permissive bounds, full window credited. + permissive = replay_crown_time_window( + store=store, + event_watcher=watcher, + from_chain='btc', + to_chain='tao', + window_start=100, + window_end=1100, + rewardable_hotkeys={'hk_a'}, + ) + assert permissive == {'hk_a': 1000.0} + + # Round N+1 — bounds tightened mid-day. The next window's replay zeros + # the miner, but the prior result must still be exactly what it was. + strict = replay_crown_time_window( + store=store, + event_watcher=watcher, + from_chain='btc', + to_chain='tao', + window_start=1100, + window_end=2100, + rewardable_hotkeys={'hk_a'}, + min_swap_rao=100_000_000, + max_swap_rao=500_000_000, + ) + assert strict == {} + + # Re-run round N — same inputs, same result. Confirms the strict round + # did not mutate state in a way that retroactively wipes earlier credit. + permissive_replay = replay_crown_time_window( + store=store, + event_watcher=watcher, + from_chain='btc', + to_chain='tao', + window_start=100, + window_end=1100, + rewardable_hotkeys={'hk_a'}, + ) + assert permissive_replay == permissive + store.close() + def test_sentinel_rate_still_wins_when_bounds_unset(self, tmp_path: Path): """Without configured bounds the executability filter is permissive — preserves legacy behavior on chains/networks that haven't yet From 1a9314a515bfce5b11a20f5a543239c797e086a5 Mon Sep 17 00:00:00 2001 From: Landyn Date: Sun, 31 May 2026 10:38:28 -0500 Subject: [PATCH 06/10] Drop quote "all sentinel" summary branch Per-row "unexecutable rate" status label already covers this case; expecting every miner on the subnet to post a sentinel simultaneously is not a realistic scenario worth a dedicated message. --- allways/cli/swap_commands/quote.py | 6 ------ tests/test_swap_quote.py | 18 ------------------ 2 files changed, 24 deletions(-) diff --git a/allways/cli/swap_commands/quote.py b/allways/cli/swap_commands/quote.py index 2b43d5d0..064eaae9 100644 --- a/allways/cli/swap_commands/quote.py +++ b/allways/cli/swap_commands/quote.py @@ -238,12 +238,6 @@ def quote_command(from_chain: str, to_chain: str, amount: float): f' [yellow]No miner can accept this — every routable miner is below the contract min ' f'({from_rao(min_swap_rao):.4f} TAO equivalent). Increase --amount.[/yellow]\n' ) - elif len(available) > 0 and all( - not is_executable_rate(p.rate, from_chain, to_chain, min_swap_rao, max_swap_rao) for (p, _, _) in available - ): - console.print( - ' [yellow]Every miner is posting an unexecutable rate (sentinel). Try again later.[/yellow]\n' - ) else: console.print( ' [yellow]No miner can fulfill this swap at the requested amount — try a smaller amount ' diff --git a/tests/test_swap_quote.py b/tests/test_swap_quote.py index 74131344..0ecb078c 100644 --- a/tests/test_swap_quote.py +++ b/tests/test_swap_quote.py @@ -155,21 +155,3 @@ def test_sentinel_low_rate_labeled_unexecutable(self): sentinel_line = next((line for line in result.output.splitlines() if '193' in line), '') assert 'unexecutable' in sentinel_line, f'expected sentinel-row marked unexecutable; got: {sentinel_line!r}' assert 'available' not in sentinel_line - - -class TestQuoteAllSentinelSummary: - """When every available miner posts an unexecutable rate, the summary - must surface that explicitly so the user doesn't think it's a transient - no-viable-amount problem.""" - - def test_quote_summary_lists_all_sentinel_case(self): - pairs = [ - _pair(14, 'btc', 'tao', 1.797e308), - _pair(189, 'btc', 'tao', 1.797e308), - ] - collaterals = {'hk14': 10**12, 'hk189': 10**12} - result = _invoke(pairs, collaterals, ['--from', 'btc', '--to', 'tao', '--amount', '0.0005']) - assert result.exit_code == 0, result.output - assert 'unexecutable rate (sentinel)' in result.output - # Make sure the generic "try a smaller amount" branch isn't also emitted. - assert 'try a smaller amount' not in result.output From 008829a7df9363ebaeea677289fe875352d32e6d Mon Sep 17 00:00:00 2001 From: Landyn Date: Sun, 31 May 2026 10:39:53 -0500 Subject: [PATCH 07/10] Drop swap-now pre-filter for sentinel rates check_swap_viability further down already rejects sentinel-rate miners when their derived TAO leg fails bounds. The pre-filter was duplicate defense for an unlikely scenario. --- allways/cli/swap_commands/swap.py | 38 ++++++++----------------------- 1 file changed, 9 insertions(+), 29 deletions(-) diff --git a/allways/cli/swap_commands/swap.py b/allways/cli/swap_commands/swap.py index a7d50c65..73ba3d64 100644 --- a/allways/cli/swap_commands/swap.py +++ b/allways/cli/swap_commands/swap.py @@ -41,13 +41,7 @@ from allways.contract_client import ContractError from allways.synapses import SwapConfirmSynapse, SwapReserveSynapse from allways.utils.proofs import reserve_proof_message, swap_proof_message -from allways.utils.rate import ( - apply_fee_deduction, - calculate_to_amount, - check_swap_viability, - derive_tao_leg, - is_executable_rate, -) +from allways.utils.rate import apply_fee_deduction, calculate_to_amount, check_swap_viability, derive_tao_leg def to_smallest_unit(amount: float, chain_id: str) -> int: @@ -773,28 +767,6 @@ def swap_now_command( console.print('[yellow]Cancelled[/yellow]') return - # Read swap bounds early so we can filter out sentinel-rate posters before - # they pollute the ranked miner table. - try: - with loading('Reading protocol swap bounds...'): - min_swap = client.get_min_swap_amount() - max_swap = client.get_max_swap_amount() - except ContractError: - console.print('[yellow]Warning: could not verify swap bounds (contract unreachable)[/yellow]') - min_swap, max_swap = 0, 0 - - if min_swap > 0 or max_swap > 0: - available_miners = [ - (p, c) - for (p, c) in available_miners - if is_executable_rate(p.rate, from_chain, to_chain, min_swap, max_swap) - ] - if not available_miners: - console.print( - f'[yellow]Every miner posts an unexecutable rate for {from_chain.upper()}/{to_chain.upper()}.[/yellow]' - ) - return - # Rate is TAO/BTC: highest is best when receiving TAO, lowest when sending TAO. canon_from, canon_to = canonical_pair(from_chain, to_chain) canon_is_reverse = from_chain != canon_from @@ -904,6 +876,14 @@ def swap_now_command( # collateral. Mirrors vote_reserve (bounds) and vote_initiate # (collateral) so we fail loudly here instead of after the user has # reserved and sent funds. + try: + with loading('Reading protocol swap bounds...'): + min_swap = client.get_min_swap_amount() + max_swap = client.get_max_swap_amount() + except ContractError: + console.print('[yellow]Warning: could not verify swap bounds (contract unreachable)[/yellow]') + min_swap, max_swap = 0, 0 + viable, reason = check_swap_viability(tao_amount, selected_collateral, min_swap, max_swap) if not viable: console.print( From 74a28fd3bb96ee92687b69c594b5a8354dc686b0 Mon Sep 17 00:00:00 2001 From: Landyn Date: Sun, 31 May 2026 10:44:48 -0500 Subject: [PATCH 08/10] Drop CLI view decorations for unexecutable rates Validator-side fix (parser drop + terminator + scoring gate) removes the incentive to post unexecutable rates: no crown, no reservations. Surfacing them in view rates/miners adds code for a state miners are no longer motivated to be in. --- allways/cli/swap_commands/view.py | 41 +++---------------------------- 1 file changed, 3 insertions(+), 38 deletions(-) diff --git a/allways/cli/swap_commands/view.py b/allways/cli/swap_commands/view.py index 4b4c3b94..0c2462bc 100644 --- a/allways/cli/swap_commands/view.py +++ b/allways/cli/swap_commands/view.py @@ -37,7 +37,6 @@ MAX_EXTENSIONS_PER_SWAP, ) from allways.contract_client import ContractError -from allways.utils.rate import is_executable_rate def _dashboard_url() -> str: @@ -152,13 +151,6 @@ def view_miners( fulfillment_timeout = 0 current_block = 0 - try: - min_swap_rao = client.get_min_swap_amount() - max_swap_rao = client.get_max_swap_amount() - except ContractError: - min_swap_rao = 0 - max_swap_rao = 0 - def _trunc(s: str) -> str: if full or not s: return s @@ -240,22 +232,13 @@ def _trunc(s: str) -> str: status_str = '[dim]offline[/dim]' status_sort_token = 'offline' - def _rate_cell(rate: float, from_c: str, to_c: str) -> str: - if rate <= 0: - return '[dim]—[/dim]' - if (min_swap_rao > 0 or max_swap_rao > 0) and not is_executable_rate( - rate, from_c, to_c, min_swap_rao, max_swap_rao - ): - return f'[red]{rate:g} ✗[/red]' - return f'{rate:g}' - - fwd_display = _rate_cell(pair.rate, pair.from_chain, pair.to_chain) + fwd_display = f'{pair.rate:g}' if pair.rate > 0 else '[dim]—[/dim]' if pair.counter_rate > 0: - ctr_display = _rate_cell(pair.counter_rate, pair.to_chain, pair.from_chain) + ctr_display = f'{pair.counter_rate:g}' elif pair.counter_rate_str: ctr_display = '[dim]—[/dim]' else: - ctr_display = _rate_cell(pair.rate, pair.from_chain, pair.to_chain) + ctr_display = f'{pair.rate:g}' rows.append( { @@ -430,20 +413,6 @@ def view_rates( # aggregated. None if the API is unreachable; the table still renders. reliability = fetch_miner_reliability() - unexecutable_hidden = 0 - if min_swap_rao > 0 or max_swap_rao > 0: - kept: list[tuple] = [] - for p, c in pairs_with_collateral: - fwd_ok = p.rate > 0 and is_executable_rate(p.rate, p.from_chain, p.to_chain, min_swap_rao, max_swap_rao) - rev_ok = p.counter_rate > 0 and is_executable_rate( - p.counter_rate, p.to_chain, p.from_chain, min_swap_rao, max_swap_rao - ) - if fwd_ok or rev_ok: - kept.append((p, c)) - else: - unexecutable_hidden += 1 - pairs_with_collateral = kept - if pair: parts = pair.lower().split('-') if len(parts) != 2: @@ -590,10 +559,6 @@ def _trunc(s: str) -> str: shown = len(pairs_with_collateral) if shown != total_before_filter: console.print(f'[dim]Showing {shown} of {total_before_filter} miners after filters.[/dim]') - if unexecutable_hidden: - console.print( - f'[dim]Hid {unexecutable_hidden} miner(s) with unexecutable rates under current swap bounds.[/dim]' - ) if reliability is None: console.print('[yellow]Reliability unavailable — swap-history API unreachable.[/yellow]') else: From 0c4b45b8a08b80b62feda2a6dd674b55e8730b37 Mon Sep 17 00:00:00 2001 From: Landyn Date: Sun, 31 May 2026 10:56:08 -0500 Subject: [PATCH 09/10] Skip second-sweep terminator when poll returns empty pairs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit read_miner_commitments swallows transient RPC errors (ConnectionError / TimeoutError) and returns []. Without a guard, a single websocket flake would terminate every previously-positive miner. If pairs is empty for any reason — RPC dead or genuinely no commitments — skip the sweep. The next successful poll catches whatever genuinely vanished. Stale positives persisting one extra poll is acceptable; nuking every miner on a flake is not. --- allways/validator/forward.py | 7 ++++ tests/test_poll_commitments.py | 69 ++++++++++++++++++++++++++++------ 2 files changed, 64 insertions(+), 12 deletions(-) diff --git a/allways/validator/forward.py b/allways/validator/forward.py index ef3f4e91..8a1f2a7f 100644 --- a/allways/validator/forward.py +++ b/allways/validator/forward.py @@ -439,6 +439,13 @@ def refresh_miner_rates(self: Validator) -> None: # this poll. Covers parser-poison (commitment overwritten with garbage) and # bounds-tighten exits (rate dropped below executability). Without this a # miner's stale positive rate keeps earning crown until deregistration. + # + # Guard: read_miner_commitments swallows transient RPC errors and returns + # an empty list. If pairs is empty, we can't distinguish "RPC dead" from + # "nobody posting" — either way, terminating every miner is wrong. Skip + # the sweep; the next successful poll catches whatever genuinely vanished. + if not pairs: + return for key, rate in list(self.last_known_rates.items()): if rate <= 0: continue diff --git a/tests/test_poll_commitments.py b/tests/test_poll_commitments.py index 2e28743c..e99b8a80 100644 --- a/tests/test_poll_commitments.py +++ b/tests/test_poll_commitments.py @@ -254,10 +254,48 @@ def test_previously_positive_direction_terminated_when_pair_drops(self, tmp_path """Regression guard for the parser-poison free-rider hole. Miner posts a sane rate, then overwrites their commitment with garbage - (or rate goes unexecutable). Pair vanishes from the poll, but the - prior positive rate is still in state_store. The second sweep must + (or rate goes unexecutable). hk_a's pair vanishes from the poll, but + the prior positive rate is still in state_store. The second sweep must emit a 0-terminator so scoring stops crediting the stale rate. + + hk_b stays in the poll throughout — so pairs is non-empty (proving + this isn't the RPC-failure case where the sweep is skipped). """ + v = make_validator(tmp_path, hotkeys=['hk_a', 'hk_b']) + + with patch( + 'allways.validator.forward.read_miner_commitments', + return_value=[ + make_pair('hk_a', rate=0.00015, counter_rate=6500.0), + make_pair('hk_b', rate=0.00016, counter_rate=6400.0), + ], + ): + poll_commitments(v) + + v.block += 1 + # hk_a's commitment is parser-poisoned (vanishes); hk_b is still posting. + with patch( + 'allways.validator.forward.read_miner_commitments', + return_value=[make_pair('hk_b', rate=0.00016, counter_rate=6400.0)], + ): + poll_commitments(v) + + a_tao_btc = [ + e for e in v.state_store.get_rate_events_in_range('tao', 'btc', 0, 10_000) if e['hotkey'] == 'hk_a' + ] + a_btc_tao = [ + e for e in v.state_store.get_rate_events_in_range('btc', 'tao', 0, 10_000) if e['hotkey'] == 'hk_a' + ] + assert [e['rate'] for e in a_tao_btc] == [0.00015, 0.0] + assert [e['rate'] for e in a_btc_tao] == [6500.0, 0.0] + assert v.last_known_rates[('hk_a', 'tao', 'btc')] == 0.0 + assert v.last_known_rates[('hk_a', 'btc', 'tao')] == 0.0 + v.state_store.close() + + def test_empty_pairs_does_not_terminate_known_positives(self, tmp_path: Path): + """read_miner_commitments swallows transient RPC errors and returns []. + If we treated [] as 'every miner vanished', a single websocket flake + would zero every previously-positive miner. Skip the sweep instead.""" v = make_validator(tmp_path) with patch( @@ -267,16 +305,15 @@ def test_previously_positive_direction_terminated_when_pair_drops(self, tmp_path poll_commitments(v) v.block += 1 - # Pair vanishes entirely on the next poll (parser-poisoned commitment). + # Simulate RPC failure: empty pairs (could be RPC dead OR genuine). with patch('allways.validator.forward.read_miner_commitments', return_value=[]): poll_commitments(v) tao_btc = v.state_store.get_rate_events_in_range('tao', 'btc', 0, 10_000) btc_tao = v.state_store.get_rate_events_in_range('btc', 'tao', 0, 10_000) - assert [e['rate'] for e in tao_btc] == [0.00015, 0.0] - assert [e['rate'] for e in btc_tao] == [6500.0, 0.0] - assert v.last_known_rates[('hk_a', 'tao', 'btc')] == 0.0 - assert v.last_known_rates[('hk_a', 'btc', 'tao')] == 0.0 + assert [e['rate'] for e in tao_btc] == [0.00015] + assert [e['rate'] for e in btc_tao] == [6500.0] + assert v.last_known_rates[('hk_a', 'tao', 'btc')] == 0.00015 v.state_store.close() def test_no_terminator_when_never_offered(self, tmp_path: Path): @@ -336,10 +373,13 @@ def test_bootstrap_seeds_from_state_store_for_stale_positives(self, tmp_path: Pa def test_post_bootstrap_first_poll_terminates_parser_poisoned_miner(self, tmp_path: Path): """End-to-end: persisted positive → bootstrap hydrates → next poll sees - no commitment → 0-terminator emitted.""" + no commitment for the poisoned miner → 0-terminator emitted. + + hk_b posts throughout so pairs is non-empty (the empty-pairs sweep + guard would otherwise skip termination).""" from neurons.validator import Validator - v = self._make_validator_with_bootstrap(tmp_path, hotkeys=['hk_a']) + v = self._make_validator_with_bootstrap(tmp_path, hotkeys=['hk_a', 'hk_b']) anchor_block = max(0, v.block - SCORING_WINDOW_BLOCKS) v.state_store.insert_rate_event( hotkey='hk_a', from_chain='tao', to_chain='btc', rate=0.00015, block=anchor_block - 10 @@ -348,11 +388,16 @@ def test_post_bootstrap_first_poll_terminates_parser_poisoned_miner(self, tmp_pa with patch('neurons.validator.read_miner_commitments', return_value=[]): Validator.bootstrap_miner_rates(v) - with patch('allways.validator.forward.read_miner_commitments', return_value=[]): + with patch( + 'allways.validator.forward.read_miner_commitments', + return_value=[make_pair('hk_b', rate=0.00020, counter_rate=6400.0)], + ): poll_commitments(v) - tao_btc = v.state_store.get_rate_events_in_range('tao', 'btc', 0, 10_000) - assert [e['rate'] for e in tao_btc] == [0.00015, 0.0] + a_tao_btc = [ + e for e in v.state_store.get_rate_events_in_range('tao', 'btc', 0, 10_000) if e['hotkey'] == 'hk_a' + ] + assert [e['rate'] for e in a_tao_btc] == [0.00015, 0.0] v.state_store.close() From dc31404036242090fdf7a7f92b7b4c580a93aed0 Mon Sep 17 00:00:00 2001 From: Landyn Date: Sun, 31 May 2026 11:33:00 -0500 Subject: [PATCH 10/10] Drop boundary-squat from crown per-block MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A miner can post a live, executable rate whose smallest in-band TAO leg exceeds their collateral — winning crown but funding zero swaps. Survives the stale-rate terminator (commitment is real) and the executability filter (rate is technically routable). Per-block gate inside replay_crown_time_window: a holder whose collateral_at_block can't fund the smallest in-band TAO leg at their rate is dropped, cascading to the next-best funded rate via crown_holders_at_instant. Uses per-block collateral from the watcher's CollateralEvent series, matching #409's no-snapshot semantics. min_executable_tao_leg exposes the band math shared with is_executable_rate. --- allways/utils/rate.py | 27 ++++++++ allways/validator/scoring.py | 15 ++++- tests/test_scoring_v1.py | 123 +++++++++++++++++++++++++++++++++++ 3 files changed, 164 insertions(+), 1 deletion(-) diff --git a/allways/utils/rate.py b/allways/utils/rate.py index 42ea5383..79010377 100644 --- a/allways/utils/rate.py +++ b/allways/utils/rate.py @@ -193,6 +193,33 @@ def _has_integer_routable_source(forward_rate: float, src_chain: str) -> bool: return True +def min_executable_tao_leg( + rate: float, + from_chain: str, + to_chain: str, + min_swap_rao: int, + max_swap_rao: int, +) -> int: + """Smallest TAO leg (rao) the rate produces among in-band fundable swaps. + + Shares band math with is_executable_rate. Returns 0 when no in-band + fundable swap exists (rate unexecutable) — caller treats as "no constraint". + """ + if not is_executable_rate(rate, from_chain, to_chain, min_swap_rao, max_swap_rao): + return 0 + if from_chain == 'tao': + return max(get_chain('tao').min_onchain_amount, max(0, min_swap_rao)) + if to_chain == 'tao': + src = get_chain(from_chain) + decimal_factor = 10 ** (get_chain('tao').decimals - src.decimals) + denom = rate * decimal_factor + if not math.isfinite(denom) or denom <= 0: + return 0 + min_source = max(src.min_onchain_amount, math.ceil(max(1, min_swap_rao) / denom)) + return int(min_source * denom) + return 0 + + def check_swap_viability( tao_amount_rao: int, miner_collateral_rao: int, diff --git a/allways/validator/scoring.py b/allways/validator/scoring.py index 7eed63ca..c526a594 100644 --- a/allways/validator/scoring.py +++ b/allways/validator/scoring.py @@ -27,7 +27,7 @@ SUCCESS_EXPONENT, VOLUME_WEIGHT_ALPHA, ) -from allways.utils.rate import is_executable_rate +from allways.utils.rate import is_executable_rate, min_executable_tao_leg from allways.validator.event_watcher import ContractEventWatcher from allways.validator.scoring_trace import WeightingTrace, log_scoring_trace from allways.validator.state_store import ValidatorStateStore @@ -571,6 +571,15 @@ def effective_rates() -> Dict[str, float]: merged.update(pinned_rates) return merged + bounds_set = min_swap_rao > 0 or max_swap_rao > 0 + + def can_fund(hotkey: str, rate: float) -> bool: + # Boundary-squat per-block gate: a miner whose own rate forces a TAO + # leg larger than their collateral_at_block earns no crown for that + # block. Cascades to the next-best rate via crown_holders_at_instant. + min_leg = min_executable_tao_leg(rate, from_chain, to_chain, min_swap_rao, max_swap_rao) + return min_leg == 0 or collaterals.get(hotkey, 0) >= min_leg + def credit_interval(interval_start: int, interval_end: int) -> None: duration = interval_end - interval_start if duration <= 0: @@ -584,6 +593,7 @@ def credit_interval(interval_start: int, interval_end: int) -> None: 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 not holders: if trace is not None: @@ -735,6 +745,7 @@ def crown_holders_at_instant( active: Optional[Set[str]] = None, lower_rate_wins: bool = False, executable_rate_check: Optional[Callable[[float], bool]] = None, + can_fund_at_rate: Optional[Callable[[str, float], bool]] = None, ) -> List[str]: """Take the miners posting the best rate, but only if they satisfy every other condition (rewardable, active, not busy, rate > 0, executable). @@ -772,6 +783,8 @@ def qualifies(hotkey: str) -> bool: return False if executable_rate_check is not None and not executable_rate_check(rate): return False + if can_fund_at_rate is not None and not can_fund_at_rate(hotkey, rate): + return False return hotkey in rewardable and hotkey not in busy by_rate: Dict[float, List[str]] = {} diff --git a/tests/test_scoring_v1.py b/tests/test_scoring_v1.py index 27be337f..5d4d04e6 100644 --- a/tests/test_scoring_v1.py +++ b/tests/test_scoring_v1.py @@ -86,6 +86,7 @@ def make_validator( block: int = 10_000, *, max_swap_amount: int = 0, + min_swap_amount: int = 0, collaterals: dict[str, int] | None = None, baseline_credibility: bool = True, ) -> SimpleNamespace: @@ -115,6 +116,7 @@ def make_validator( seed_collateral(watcher, hotkey, amount, block=0) bounds_cache = MagicMock() bounds_cache.max_swap_amount.return_value = max_swap_amount + bounds_cache.min_swap_amount.return_value = min_swap_amount contract_client = MagicMock() contract_client.get_miner_collateral.side_effect = lambda hk: collaterals.get(hk, 0) database_storage = MagicMock() @@ -308,6 +310,9 @@ def test_sentinel_rate_earns_no_crown_when_bounds_set(self, tmp_path: Path): earn zero crown, and the sane miner takes the entire window.""" store = ValidatorStateStore(db_path=tmp_path / 'state.db') watcher = make_watcher(store, active={'hk_sentinel', 'hk_sane'}) + # Seed enough collateral so hk_sane clears the per-block boundary-squat + # gate — this test isn't exercising that gate. + seed_collateral(watcher, 'hk_sane', 500_000_000, block=0) conn = store.require_connection() for row in ( ('hk_sentinel', 'btc', 'tao', 1e10, 0), @@ -333,6 +338,124 @@ def test_sentinel_rate_earns_no_crown_when_bounds_set(self, tmp_path: Path): assert crown == {'hk_sane': 1000.0} store.close() + def test_boundary_squat_dropped_per_block(self, tmp_path: Path): + """Squatter posts a live, executable rate (50000 TAO/BTC) whose smallest + in-band leg (0.5 TAO at 1000 sats) exceeds their 0.15 TAO collateral. + Survives is_executable_rate but the per-block gate drops them — entire + window unfilled (no other holders).""" + store = ValidatorStateStore(db_path=tmp_path / 'state.db') + watcher = make_watcher(store, active={'hk_squat'}) + seed_collateral(watcher, 'hk_squat', 150_000_000, block=0) + conn = store.require_connection() + conn.execute( + 'INSERT INTO rate_events (hotkey, from_chain, to_chain, rate, block) VALUES (?, ?, ?, ?, ?)', + ('hk_squat', 'btc', 'tao', 50000.0, 0), + ) + conn.commit() + + crown = replay_crown_time_window( + store=store, + event_watcher=watcher, + from_chain='btc', + to_chain='tao', + window_start=100, + window_end=1100, + rewardable_hotkeys={'hk_squat'}, + min_swap_rao=100_000_000, + max_swap_rao=500_000_000, + ) + assert crown == {} + store.close() + + def test_boundary_squat_loses_to_funded_runner_up(self, tmp_path: Path): + """Squatter has the best rate but their per-block gate drops every + block to the funded runner-up — same shape as the busy-runner-up case.""" + store = ValidatorStateStore(db_path=tmp_path / 'state.db') + watcher = make_watcher(store, active={'hk_squat', 'hk_funded'}) + seed_collateral(watcher, 'hk_squat', 150_000_000, block=0) + seed_collateral(watcher, 'hk_funded', 500_000_000, block=0) + conn = store.require_connection() + for row in ( + ('hk_squat', 'btc', 'tao', 50000.0, 0), # best rate, can't fund + ('hk_funded', 'btc', 'tao', 326.0, 0), # runner-up, can fund + ): + conn.execute( + 'INSERT INTO rate_events (hotkey, from_chain, to_chain, rate, block) VALUES (?, ?, ?, ?, ?)', + row, + ) + conn.commit() + + crown = replay_crown_time_window( + store=store, + event_watcher=watcher, + from_chain='btc', + to_chain='tao', + window_start=100, + window_end=1100, + rewardable_hotkeys={'hk_squat', 'hk_funded'}, + min_swap_rao=100_000_000, + max_swap_rao=500_000_000, + ) + assert crown == {'hk_funded': 1000.0} + store.close() + + def test_squat_gate_skipped_when_bounds_unset(self, tmp_path: Path): + """Cold-start fail-safe: bounds at 0 → gate skipped (matches + is_executable_rate's permissive branch).""" + store = ValidatorStateStore(db_path=tmp_path / 'state.db') + watcher = make_watcher(store, active={'hk_squat'}) + seed_collateral(watcher, 'hk_squat', 1, block=0) + conn = store.require_connection() + conn.execute( + 'INSERT INTO rate_events (hotkey, from_chain, to_chain, rate, block) VALUES (?, ?, ?, ?, ?)', + ('hk_squat', 'btc', 'tao', 50000.0, 0), + ) + conn.commit() + + crown = replay_crown_time_window( + store=store, + event_watcher=watcher, + from_chain='btc', + to_chain='tao', + window_start=100, + window_end=1100, + rewardable_hotkeys={'hk_squat'}, + ) + assert crown == {'hk_squat': 1000.0} + store.close() + + def test_squat_gate_uses_per_block_collateral(self, tmp_path: Path): + """A miner who tops up collateral mid-window earns crown only for + blocks after the top-up — proves the gate uses per-block state, not + a window-end snapshot.""" + store = ValidatorStateStore(db_path=tmp_path / 'state.db') + watcher = make_watcher(store, active={'hk_squat'}) + seed_collateral(watcher, 'hk_squat', 150_000_000, block=0) + # Top-up mid-window — collateral becomes enough to fund the 0.5 TAO leg. + watcher.apply_event(600, 'CollateralPosted', {'miner': 'hk_squat', 'amount': 350_000_000, 'total': 500_000_000}) + conn = store.require_connection() + conn.execute( + 'INSERT INTO rate_events (hotkey, from_chain, to_chain, rate, block) VALUES (?, ?, ?, ?, ?)', + ('hk_squat', 'btc', 'tao', 50000.0, 0), + ) + conn.commit() + + crown = replay_crown_time_window( + store=store, + event_watcher=watcher, + from_chain='btc', + to_chain='tao', + window_start=100, + window_end=1100, + rewardable_hotkeys={'hk_squat'}, + min_swap_rao=100_000_000, + max_swap_rao=500_000_000, + ) + # Blocks (100, 600] dropped (collateral 0.15 < 0.5 TAO leg). + # Blocks (600, 1100] credited (collateral 0.5 TAO). + assert crown == {'hk_squat': 500.0} + store.close() + def test_bounds_transition_does_not_retroactively_zero_pre_bounds_credit(self, tmp_path: Path): """Bounds-tightening between scoring rounds must not zero out a miner's credit from the previous round.