diff --git a/allways/commitments.py b/allways/commitments.py index db28ed2..31d57d9 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/allways/utils/rate.py b/allways/utils/rate.py index 42ea538..7901037 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/axon_handlers.py b/allways/validator/axon_handlers.py index ad767ee..0fb2a0c 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/allways/validator/forward.py b/allways/validator/forward.py index 1bc586c..8a1f2a7 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,38 @@ 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. + # + # 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 + 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/allways/validator/scoring.py b/allways/validator/scoring.py index 7eed63c..c526a59 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/neurons/validator.py b/neurons/validator.py index 62a266e..5b5b856 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_axon_handlers.py b/tests/test_axon_handlers.py index 153f313..e460da5 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() diff --git a/tests/test_commitments.py b/tests/test_commitments.py index 53dc662..552acad 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'} diff --git a/tests/test_poll_commitments.py b/tests/test_poll_commitments.py index 62e234e..e99b8a8 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,158 @@ 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). 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( + '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 + # 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] + 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): + """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 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', '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 + ) + + with patch('neurons.validator.read_miner_commitments', return_value=[]): + Validator.bootstrap_miner_rates(v) + + with patch( + 'allways.validator.forward.read_miner_commitments', + return_value=[make_pair('hk_b', rate=0.00020, 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' + ] + assert [e['rate'] for e in a_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 — diff --git a/tests/test_scoring_v1.py b/tests/test_scoring_v1.py index 66bd228..5d4d04e 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,190 @@ 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. + + 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