diff --git a/allways/contract_client.py b/allways/contract_client.py index bc37991..403694b 100644 --- a/allways/contract_client.py +++ b/allways/contract_client.py @@ -73,6 +73,7 @@ 'claim_slash': bytes.fromhex('cf3c3dd9'), 'deactivate': bytes.fromhex('339db2a5'), 'vote_activate': bytes.fromhex('00088a2d'), + 'vote_deactivate': bytes.fromhex('dac13f65'), 'transfer_ownership': bytes.fromhex('107e33ea'), 'add_validator': bytes.fromhex('82f48fa6'), 'remove_validator': bytes.fromhex('62135acd'), @@ -152,6 +153,7 @@ ('rate', 'str'), ], 'vote_activate': [('miner', 'AccountId')], + 'vote_deactivate': [('miner', 'AccountId')], 'mark_fulfilled': [('swap_id', 'u64'), ('to_tx_hash', 'str'), ('to_tx_block', 'u32'), ('to_amount', 'u128')], 'confirm_swap': [('swap_id', 'u64')], 'timeout_swap': [('swap_id', 'u64')], @@ -269,6 +271,10 @@ def compact_encode_len(length: int) -> bytes: 24: ('HashMismatch', 'Request hash does not match computed hash'), 25: ('PendingConflict', 'A pending vote exists for a different request'), 26: ('SameChain', 'Source and destination chains must be different'), + 27: ('SystemHalted', 'System is halted — no new activity allowed'), + 28: ('SufficientCollateral', 'Miner collateral is at or above floor; vote_deactivate not applicable'), + 29: ('HasActiveSwap', 'Cannot deactivate: miner has an active swap in progress'), + 30: ('CurrentlyReserved', 'Cannot deactivate: miner is currently reserved'), } @@ -1074,6 +1080,15 @@ def vote_activate(self, wallet: bt.Wallet, miner_hotkey: str) -> str: bt.logging.info(f'Vote activate for miner {miner_hotkey}: {tx_hash}') return tx_hash + def vote_deactivate(self, wallet: bt.Wallet, miner_hotkey: str) -> str: + """Vote to deactivate a miner that has fallen below the collateral + floor. Validator-quorum gated; contract rejects unless + ``collateral < min_collateral``.""" + self.ensure_initialized() + tx_hash = self.exec_contract_raw('vote_deactivate', args={'miner': miner_hotkey}, keypair=wallet.hotkey) + bt.logging.info(f'Vote deactivate for miner {miner_hotkey}: {tx_hash}') + return tx_hash + def mark_fulfilled( self, wallet: bt.Wallet, diff --git a/allways/validator/event_watcher.py b/allways/validator/event_watcher.py index 482d7e5..7c2f206 100644 --- a/allways/validator/event_watcher.py +++ b/allways/validator/event_watcher.py @@ -3,10 +3,12 @@ Each forward step calls ``sync_to(current_block)``; the watcher replays ``Contracts::ContractEmitted`` events from its cursor up to ``current_block`` and applies them to in-memory state used by the crown-time scoring replay. -Cold start backfills one scoring window so the first scoring pass after a -restart already has a populated history. Swap outcomes are forwarded into -``ValidatorStateStore.insert_swap_outcome`` so the credibility ledger -survives restarts. +Tracks three things: the current on-chain active set (for rate-gating), +per-hotkey busy deltas (reservations → swap resolution), and swap outcomes +forwarded into ``ValidatorStateStore.insert_swap_outcome`` so the +credibility ledger survives restarts. Collateral and config scalars are +trusted to the contract — see ``vote_deactivate`` for the min-raise +remediation path. """ from __future__ import annotations @@ -209,13 +211,6 @@ def to_bytes(val: Any) -> bytes: # ─── The watcher ──────────────────────────────────────────────────────────── -@dataclass -class CollateralEvent: - hotkey: str - collateral_rao: int - block: int - - @dataclass class BusyEvent: """``delta`` is +1 on SwapInitiated and -1 on SwapCompleted/SwapTimedOut. @@ -237,22 +232,6 @@ class ActiveEvent: block: int -@dataclass -class ConfigEvent: - """Transition of a global contract config scalar (min_collateral, - max_collateral, halted). Replayed per-block so scoring evaluates - eligibility against the config that was in force at each block, not - the current value — an admin-side config change doesn't retroactively - disqualify blocks the miner legitimately earned.""" - - key: str - value: int - block: int - - -CONFIG_KEYS_TRACKED = ('min_collateral', 'max_collateral', 'halted') - - MAX_BLOCKS_PER_SYNC = 50 @@ -265,7 +244,6 @@ def __init__( contract_address: str, metadata_path: Path, state_store: ValidatorStateStore, - default_min_collateral: int = 0, ): self.substrate = substrate self.contract_address = contract_address @@ -273,61 +251,14 @@ def __init__( self.registry = load_event_registry(metadata_path) self.cursor: int = 0 - self.collateral: Dict[str, int] = {} self.active_miners: Set[str] = set() - self.min_collateral: int = default_min_collateral - self.collateral_events: List[CollateralEvent] = [] - # Per-hotkey view of collateral_events for O(log n) latest-before lookups. - self.collateral_events_by_hotkey: Dict[str, List[CollateralEvent]] = {} self.open_swap_count: Dict[str, int] = {} self.busy_events: List[BusyEvent] = [] self.active_events: List[ActiveEvent] = [] self.active_events_by_hotkey: Dict[str, List[ActiveEvent]] = {} - # Current-value mirror + historical log for tracked config scalars. - # ``max_collateral``/``halted`` default to 0 until the first event is - # seen, matching the contract constructor defaults. ``config_initial`` - # is the frozen pre-event snapshot — used as the fallback for - # ``get_config_at`` when the caller asks about a block before any - # event fired, since ``config_current`` reflects the LATEST state and - # would be wrong as a pre-event fallback. - self.config_current: Dict[str, int] = { - 'min_collateral': default_min_collateral, - 'max_collateral': 0, - 'halted': 0, - } - self.config_initial: Dict[str, int] = dict(self.config_current) - self.config_events: List[ConfigEvent] = [] - self.config_events_by_key: Dict[str, List[ConfigEvent]] = {} # ─── Public API consumed by scoring ───────────────────────────────── - def get_latest_collateral_before(self, hotkey: str, block: int) -> Optional[Tuple[int, int]]: - """Most recent collateral for ``hotkey`` at or before ``block``. If - events exist but none fall at/before ``block``, returns None — the - bootstrap snapshot reflects post-event state and is invalid for the - pre-event gap.""" - from bisect import bisect_right - - events = self.collateral_events_by_hotkey.get(hotkey) - if not events: - snapshot = self.collateral.get(hotkey) - return (snapshot, 0) if snapshot is not None else None - idx = bisect_right([e.block for e in events], block) - 1 - if idx < 0: - return None - ev = events[idx] - return ev.collateral_rao, ev.block - - def get_collateral_events_in_range(self, start_block: int, end_block: int) -> List[dict]: - out: List[dict] = [] - for ev in self.collateral_events: - if ev.block <= start_block: - continue - if ev.block > end_block: - break - out.append({'hotkey': ev.hotkey, 'collateral_rao': ev.collateral_rao, 'block': ev.block}) - return out - def get_busy_events_in_range(self, start_block: int, end_block: int) -> List[dict]: out: List[dict] = [] for ev in self.busy_events: @@ -372,40 +303,6 @@ def get_active_miners_at(self, block: int) -> Set[str]: latest[ev.hotkey] = ev.active return {hk for hk, is_active in latest.items() if is_active} - def get_config_at(self, key: str, block: int) -> int: - """Scalar config value (``min_collateral``, ``max_collateral``, - ``halted``) at ``block``, reconstructed from the config event log. - - Fallback semantics: - - No events for key → return ``config_current[key]`` (= ``config_initial`` - unchanged, mirrors the collateral snapshot fallback). - - Events exist but none at/before ``block`` → return - ``config_initial[key]``, not current. ``config_current`` reflects - the latest event and would be wrong as a pre-event fallback.""" - events = self.config_events_by_key.get(key) - if not events: - return int(self.config_current.get(key, 0)) - latest_value: Optional[int] = None - for ev in events: - if ev.block > block: - break - latest_value = ev.value - if latest_value is None: - return int(self.config_initial.get(key, 0)) - return int(latest_value) - - def get_config_events_in_range(self, start_block: int, end_block: int) -> List[dict]: - """Config transitions in ``(start_block, end_block]``, oldest first. - Emits only keys in ``CONFIG_KEYS_TRACKED``.""" - out: List[dict] = [] - for ev in self.config_events: - if ev.block <= start_block: - continue - if ev.block > end_block: - break - out.append({'key': ev.key, 'value': ev.value, 'block': ev.block}) - return out - # ─── Sync loop ────────────────────────────────────────────────────── def initialize( @@ -419,39 +316,11 @@ def initialize( before the first scoring pass runs.""" if metagraph_hotkeys and contract_client is not None: for hotkey in metagraph_hotkeys: - try: - collateral = contract_client.get_miner_collateral(hotkey) or 0 - except Exception as e: - bt.logging.debug(f'EventWatcher bootstrap: collateral read failed for {hotkey[:8]}: {e}') - collateral = 0 - if collateral > 0: - self.collateral[hotkey] = collateral try: if contract_client.get_miner_active_flag(hotkey): self.active_miners.add(hotkey) except Exception as e: bt.logging.debug(f'EventWatcher bootstrap: active flag read failed for {hotkey[:8]}: {e}') - try: - raw_min = contract_client.get_min_collateral() or 0 - if raw_min > 0: - self.min_collateral = raw_min - self.config_current['min_collateral'] = int(raw_min) - self.config_initial['min_collateral'] = int(raw_min) - except Exception as e: - bt.logging.debug(f'EventWatcher bootstrap: min_collateral read failed: {e}') - try: - raw_max = contract_client.get_max_collateral() or 0 - self.config_current['max_collateral'] = int(raw_max) - self.config_initial['max_collateral'] = int(raw_max) - except Exception as e: - bt.logging.debug(f'EventWatcher bootstrap: max_collateral read failed: {e}') - try: - raw_halted = contract_client.get_halted() - halted_int = 1 if bool(raw_halted) else 0 - self.config_current['halted'] = halted_int - self.config_initial['halted'] = halted_int - except Exception as e: - bt.logging.debug(f'EventWatcher bootstrap: halted read failed: {e}') # Without this seed, a miner already serving a swap at startup # would be treated as idle until the next terminal event. try: @@ -470,10 +339,7 @@ def initialize( bt.logging.info(f'EventWatcher bootstrap: seeded {len(seen_hotkeys)} miners as busy from contract') except Exception as e: bt.logging.debug(f'EventWatcher bootstrap: active swaps read failed: {e}') - bt.logging.info( - f'EventWatcher initialized: {len(self.collateral)} collateral entries, ' - f'{len(self.active_miners)} active miners, min_collateral={self.min_collateral}' - ) + bt.logging.info(f'EventWatcher initialized: {len(self.active_miners)} active miners') self.cursor = max(0, current_block - SCORING_WINDOW_BLOCKS) # Anchor the historical active set at the cursor so scoring sees the # bootstrap state at window_start. Subsequent MinerActivated events @@ -482,14 +348,6 @@ def initialize( event = ActiveEvent(hotkey=hotkey, active=True, block=self.cursor) self.active_events.append(event) self.active_events_by_hotkey.setdefault(hotkey, []).append(event) - # Anchor the config state at cursor for the same reason. A subsequent - # ConfigUpdated event within the window is a no-op at cursor but - # correctly overrides for later blocks. - for key in CONFIG_KEYS_TRACKED: - value = int(self.config_current.get(key, 0)) - event = ConfigEvent(key=key, value=value, block=self.cursor) - self.config_events.append(event) - self.config_events_by_key.setdefault(key, []).append(event) def sync_to(self, current_block: int) -> None: """Catch up from cursor to ``current_block`` in MAX_BLOCKS_PER_SYNC @@ -500,7 +358,7 @@ def sync_to(self, current_block: int) -> None: for block_num in range(self.cursor + 1, end + 1): self.process_block(block_num) self.cursor = end - self.prune_old_collateral_events(current_block) + self.prune_old_events(current_block) def process_block(self, block_num: int) -> None: try: @@ -560,39 +418,12 @@ def decode_contract_event(self, event_record: Any) -> Optional[Tuple[str, Dict[s return event_def.name, values def apply_event(self, block_num: int, name: str, values: Dict[str, Any]) -> None: - if name == 'CollateralPosted': - # Prefer ``total`` (authoritative post-event balance) so we don't - # drift when the replay window misses prior events. - hotkey = values.get('miner', '') - total = values.get('total') - if total is not None: - self.set_collateral(block_num, hotkey, int(total)) - else: - self.adjust_collateral(block_num, hotkey, +int(values.get('amount', 0))) - elif name == 'CollateralWithdrawn': - hotkey = values.get('miner', '') - remaining = values.get('remaining') - if remaining is not None: - self.set_collateral(block_num, hotkey, int(remaining)) - else: - self.adjust_collateral(block_num, hotkey, -int(values.get('amount', 0))) - elif name == 'CollateralSlashed': - # Slashed only carries the slash amount, no post-event balance. - self.adjust_collateral(block_num, values.get('miner', ''), -int(values.get('amount', 0))) - elif name == 'MinerActivated': + if name == 'MinerActivated': hotkey = values.get('miner', '') if not hotkey: return active = bool(values.get('active')) self.record_active_transition(block_num, hotkey, active) - elif name == 'ConfigUpdated': - key = values.get('key', '') - raw = values.get('value', 0) - try: - val = int(raw) - except (TypeError, ValueError): - return - self.record_config_transition(block_num, key, val) elif name == 'SwapInitiated': miner = values.get('miner', '') if miner: @@ -620,25 +451,6 @@ def apply_event(self, block_num: int, name: str, values: Dict[str, Any]) -> None ) self.apply_busy_delta(block_num, miner, -1) - def record_config_transition(self, block_num: int, key: str, value: int) -> None: - """Apply a contract config scalar transition to both the current- - state mirror and the historical event log. No-op if the value is - unchanged — duplicate ``ConfigUpdated`` emissions don't bloat the - log. Only tracks keys in ``CONFIG_KEYS_TRACKED``; unknown keys are - silently ignored.""" - if key not in CONFIG_KEYS_TRACKED: - return - current = int(self.config_current.get(key, 0)) - if current == int(value): - return - self.config_current[key] = int(value) - if key == 'min_collateral': - # Legacy mirror — some call sites still read ``self.min_collateral``. - self.min_collateral = int(value) - event = ConfigEvent(key=key, value=int(value), block=block_num) - self.config_events.append(event) - self.config_events_by_key.setdefault(key, []).append(event) - def record_active_transition(self, block_num: int, hotkey: str, active: bool) -> None: """Apply an on-chain active-flag transition to both the current-state snapshot and the historical event log. A no-op if the flag already @@ -668,43 +480,14 @@ def apply_busy_delta(self, block_num: int, hotkey: str, delta: int) -> None: self.open_swap_count[hotkey] = new_count self.busy_events.append(BusyEvent(hotkey=hotkey, delta=delta, block=block_num)) - def set_collateral(self, block_num: int, hotkey: str, new_total: int) -> None: - if not hotkey: - return - new_total = max(0, new_total) - self.collateral[hotkey] = new_total - event = CollateralEvent(hotkey=hotkey, collateral_rao=new_total, block=block_num) - self.collateral_events.append(event) - self.collateral_events_by_hotkey.setdefault(hotkey, []).append(event) - - def adjust_collateral(self, block_num: int, hotkey: str, delta: int) -> None: - if not hotkey: - return - new_total = max(0, self.collateral.get(hotkey, 0) + delta) - self.set_collateral(block_num, hotkey, new_total) - - def prune_old_collateral_events(self, current_block: int) -> None: - """Drop collateral and busy events older than one scoring window. The - latest collateral row per hotkey is preserved as a state-reconstruction - anchor; busy events are kept while the open-swap count is still > 0 - so the matching -1 isn't orphaned.""" + def prune_old_events(self, current_block: int) -> None: + """Drop busy and active events older than one scoring window. Latest + active event per hotkey is preserved as a state-reconstruction anchor; + busy events are kept while the open-swap count is still > 0 so the + matching -1 isn't orphaned.""" cutoff = current_block - SCORING_WINDOW_BLOCKS if cutoff <= 0: return - if self.collateral_events: - latest_per_hotkey = {} - for ev in self.collateral_events: - latest_per_hotkey[ev.hotkey] = ev # last write wins (events are append-order) - self.collateral_events = [ - ev for ev in self.collateral_events if ev.block >= cutoff or latest_per_hotkey.get(ev.hotkey) is ev - ] - for hotkey, events in list(self.collateral_events_by_hotkey.items()): - latest = events[-1] if events else None - pruned = [ev for ev in events if ev.block >= cutoff or ev is latest] - if pruned: - self.collateral_events_by_hotkey[hotkey] = pruned - else: - del self.collateral_events_by_hotkey[hotkey] if self.busy_events: open_now = {hk for hk, c in self.open_swap_count.items() if c > 0} self.busy_events = [ev for ev in self.busy_events if ev.block >= cutoff or ev.hotkey in open_now] @@ -722,17 +505,3 @@ def prune_old_collateral_events(self, current_block: int) -> None: self.active_events_by_hotkey[hotkey] = pruned else: del self.active_events_by_hotkey[hotkey] - if self.config_events: - latest_per_key: Dict[str, ConfigEvent] = {} - for ev in self.config_events: - latest_per_key[ev.key] = ev - self.config_events = [ - ev for ev in self.config_events if ev.block >= cutoff or latest_per_key.get(ev.key) is ev - ] - for key, events in list(self.config_events_by_key.items()): - latest = events[-1] if events else None - pruned = [ev for ev in events if ev.block >= cutoff or ev is latest] - if pruned: - self.config_events_by_key[key] = pruned - else: - del self.config_events_by_key[key] diff --git a/allways/validator/scoring.py b/allways/validator/scoring.py index b60a7ee..26f8313 100644 --- a/allways/validator/scoring.py +++ b/allways/validator/scoring.py @@ -30,7 +30,10 @@ def score_and_reward_miners(self: Validator) -> None: try: - rewards, miner_uids = calculate_miner_rewards(self) + if _contract_is_halted(self): + rewards, miner_uids = build_halted_rewards(self) + else: + rewards, miner_uids = calculate_miner_rewards(self) self.update_scores(rewards, miner_uids) prune_rate_events(self) prune_swap_outcomes(self) @@ -38,6 +41,28 @@ def score_and_reward_miners(self: Validator) -> None: bt.logging.error(f'Scoring failed: {e}') +def _contract_is_halted(self: Validator) -> bool: + """Best-effort halt check. RPC flakiness should not zero every miner's + reward, so any exception falls through to normal scoring.""" + try: + return bool(self.contract_client.get_halted()) + except Exception as e: + bt.logging.warning(f'halt RPC check failed, proceeding as not-halted: {e}') + return False + + +def build_halted_rewards(self: Validator) -> Tuple[np.ndarray, Set[int]]: + """During a halt, no miner earns crown; the full pool recycles.""" + n_uids = self.metagraph.n.item() + rewards = np.zeros(n_uids, dtype=np.float32) + if n_uids == 0: + return rewards, set() + recycle_uid = RECYCLE_UID if RECYCLE_UID < n_uids else 0 + rewards[recycle_uid] = 1.0 + bt.logging.info('V1 scoring: halted, recycled full pool') + return rewards, set(range(n_uids)) + + def prune_rate_events(self: Validator) -> None: cutoff = self.block - SCORING_WINDOW_BLOCKS if cutoff > 0: @@ -60,11 +85,12 @@ def calculate_miner_rewards(self: Validator) -> Tuple[np.ndarray, Set[int]]: window_end = self.block window_start = max(0, window_end - SCORING_WINDOW_BLOCKS) - # A miner's *current* active flag / collateral is irrelevant to whether - # they earned crown during the replay window. The only at-scoring-time - # check is metagraph membership, because a dereg'd miner has no UID to - # credit. Active, collateral, rate, and busy are all evaluated per-block - # via event replay inside replay_crown_time_window. + # A miner's *current* active flag is irrelevant to whether they earned + # crown during the replay window. The only at-scoring-time check is + # metagraph membership, because a dereg'd miner has no UID to credit. + # Active, rate, and busy are all evaluated per-block via event replay + # inside replay_crown_time_window. Collateral-floor invariants are + # trusted to the contract's active flag. rewardable_hotkeys: Set[str] = set(self.metagraph.hotkeys) hotkey_to_uid: Dict[str, int] = {self.metagraph.hotkeys[uid]: uid for uid in range(n_uids)} @@ -123,31 +149,24 @@ def success_rate(stats: Optional[Tuple[int, int]]) -> float: class EventKind(IntEnum): """Ordering of coincident-block transitions in the crown-time replay. - CONFIG applies first because a contract-wide scalar change (halt, - min/max collateral) can't be scoped to any one miner — the block - *after* a halt must credit nobody regardless of what else happens - that block. ACTIVE applies next because the on-chain active flag is - the per-miner tell-all. Then BUSY (reservation ends crown for that - miner), then COLLATERAL, then RATE. So if a user reserves a miner - in the same block that miner's best rate was posted, the reservation - ends crown credit *before* the rate attribution — matching the intent - that a busy miner doesn't earn a new interval. + ACTIVE applies first because the on-chain active flag is the per-miner + tell-all. Then BUSY (reservation ends crown for that miner), then + RATE. So if a user reserves a miner in the same block that miner's + best rate was posted, the reservation ends crown credit *before* the + rate attribution — matching the intent that a busy miner doesn't earn + a new interval. """ - CONFIG = 0 - ACTIVE = 1 - BUSY = 2 - COLLATERAL = 3 - RATE = 4 + ACTIVE = 0 + BUSY = 1 + RATE = 2 @dataclass class ReplayEvent: """One transition in the chronological replay stream. ``value`` is - polymorphic on ``kind``: rate as float, collateral as rao, busy delta - of ±1, active as 0/1, or config scalar. For CONFIG events ``hotkey`` - carries the config key name (``min_collateral``, ``max_collateral``, - ``halted``) — the field is reused as a union slot, not a real hotkey.""" + polymorphic on ``kind``: rate as float, busy delta of ±1, or active as + 0/1.""" block: int hotkey: str @@ -166,37 +185,19 @@ def reconstruct_window_start_state( to_chain: str, window_start: int, rewardable_hotkeys: Set[str], -) -> Tuple[Dict[str, float], Dict[str, int], Dict[str, int], Set[str], Dict[str, int]]: - """Snapshot rates, collateral, busy counts, active set, and contract - config (min_collateral, max_collateral, halted) as they stood at +) -> Tuple[Dict[str, float], Dict[str, int], Set[str]]: + """Snapshot rates, busy counts, and the active set as they stood at window_start.""" rates: Dict[str, float] = {} - collateral: Dict[str, int] = {} busy_count: Dict[str, int] = dict(event_watcher.get_busy_miners_at(window_start)) active_set: Set[str] = set(event_watcher.get_active_miners_at(window_start)) - config: Dict[str, int] = { - 'min_collateral': event_watcher.get_config_at('min_collateral', window_start), - 'max_collateral': event_watcher.get_config_at('max_collateral', window_start), - 'halted': event_watcher.get_config_at('halted', window_start), - } for hotkey in rewardable_hotkeys: latest_rate = store.get_latest_rate_before(hotkey, from_chain, to_chain, window_start) if latest_rate is not None: rates[hotkey] = latest_rate[0] - latest_col = event_watcher.get_latest_collateral_before(hotkey, window_start) - if latest_col is not None: - collateral[hotkey] = latest_col[0] - else: - # No event before window_start — fall back to the watcher's current - # snapshot so a miner whose only collateral event predates retention - # still gets credited accurately. - snapshot = event_watcher.collateral.get(hotkey) - if snapshot is not None: - collateral[hotkey] = snapshot - - return rates, collateral, busy_count, active_set, config + return rates, busy_count, active_set def merge_replay_events( @@ -207,13 +208,10 @@ def merge_replay_events( window_start: int, window_end: int, ) -> List[ReplayEvent]: - """Merge in-window config, active, busy, collateral, and rate transitions - into one chronologically-sorted stream.""" + """Merge in-window active, busy, and rate transitions into one + chronologically-sorted stream.""" events: List[ReplayEvent] = [] - for e in event_watcher.get_config_events_in_range(window_start, window_end): - events.append(ReplayEvent(block=e['block'], hotkey=e['key'], kind=EventKind.CONFIG, value=float(e['value']))) - for e in event_watcher.get_active_events_in_range(window_start, window_end): events.append( ReplayEvent(block=e['block'], hotkey=e['hotkey'], kind=EventKind.ACTIVE, value=1.0 if e['active'] else 0.0) @@ -222,13 +220,6 @@ def merge_replay_events( for e in event_watcher.get_busy_events_in_range(window_start, window_end): events.append(ReplayEvent(block=e['block'], hotkey=e['hotkey'], kind=EventKind.BUSY, value=float(e['delta']))) - for e in event_watcher.get_collateral_events_in_range(window_start, window_end): - events.append( - ReplayEvent( - block=e['block'], hotkey=e['hotkey'], kind=EventKind.COLLATERAL, value=float(e['collateral_rao']) - ) - ) - for e in store.get_rate_events_in_range(from_chain, to_chain, window_start, window_end): events.append(ReplayEvent(block=e['block'], hotkey=e['hotkey'], kind=EventKind.RATE, value=float(e['rate']))) @@ -247,14 +238,13 @@ def replay_crown_time_window( ) -> Dict[str, float]: """Walk the merged event stream, return ``{hotkey: crown_blocks_float}``. Ties at the same rate split credit evenly. A miner qualifies for crown - at an instant iff the contract is not halted, they are on the current - metagraph, were active at that instant, not busy, had ``min_collateral - <= collateral <= max_collateral`` (``max_collateral=0`` disables the - upper bound, matching contract semantics), and had a positive rate - posted. Active/collateral/rate/busy/config are all evaluated per-block - via the replay — a miner's status at scoring time is irrelevant other - than metagraph membership (used to credit the UID).""" - rates, collateral, busy_count, active_set, config = reconstruct_window_start_state( + at an instant iff they are on the current metagraph, were active at + that instant, not busy, and had a positive rate posted. Active/rate/busy + are evaluated per-block via the replay — a miner's status at scoring + time is irrelevant other than metagraph membership (used to credit the + UID). Collateral-floor invariants are trusted to the contract's active + flag; halt state is handled at ``score_and_reward_miners`` entry.""" + rates, busy_count, active_set = reconstruct_window_start_state( store, event_watcher, from_chain, to_chain, window_start, rewardable_hotkeys ) replay_events = merge_replay_events(store, event_watcher, from_chain, to_chain, window_start, window_end) @@ -269,13 +259,9 @@ def credit_interval(interval_start: int, interval_end: int) -> None: busy_set = {hk for hk, c in busy_count.items() if c > 0} holders = crown_holders_at_instant( rates, - collateral, - config['min_collateral'], rewardable_hotkeys, busy=busy_set, active=active_set, - max_collateral=config['max_collateral'], - halted=bool(config['halted']), ) if not holders: return @@ -286,22 +272,17 @@ def credit_interval(interval_start: int, interval_end: int) -> None: def apply_event(event: ReplayEvent) -> None: if event.kind is EventKind.RATE: rates[event.hotkey] = event.value - elif event.kind is EventKind.COLLATERAL: - collateral[event.hotkey] = int(event.value) elif event.kind is EventKind.BUSY: new_count = busy_count.get(event.hotkey, 0) + int(event.value) if new_count > 0: busy_count[event.hotkey] = new_count else: busy_count.pop(event.hotkey, None) - elif event.kind is EventKind.ACTIVE: + else: # ACTIVE if event.value > 0: active_set.add(event.hotkey) else: active_set.discard(event.hotkey) - else: # CONFIG - # ``hotkey`` carries the config key name for CONFIG events. - config[event.hotkey] = int(event.value) for event in replay_events: credit_interval(prev_block, event.block) @@ -314,41 +295,27 @@ def apply_event(event: ReplayEvent) -> None: def crown_holders_at_instant( rates: Dict[str, float], - collaterals: Dict[str, int], - min_collateral: int, rewardable: Set[str], busy: Optional[Set[str]] = None, active: Optional[Set[str]] = None, - max_collateral: int = 0, - halted: bool = False, ) -> List[str]: """Take the miners posting the best rate, but only if they satisfy every - other condition (not halted, rewardable, active, not busy, ``min <= - collateral <= max``, rate > 0). If the best rate has no qualified miner, - fall through to the next-best rate. - - ``max_collateral=0`` disables the upper bound — matches the contract's - ``post_collateral`` semantics where 0 means "no cap". ``halted=True`` - short-circuits to return []: during a contract halt no miner holds - crown, the pool recycles via the caller. - - ``active`` defaults to None, which means "no active-state gating" — this - keeps the helper usable in isolation for tests that don't care about - the historical active flag. Callers that replay events should pass the - reconstructed active set explicitly. - """ - if halted: - return [] + other condition (rewardable, active, not busy, rate > 0). If the best + rate has no qualified miner, fall through to the next-best rate. + + Collateral-floor gating is trusted to the contract's active flag — + miners who drop below the floor get auto-deactivated on-chain (fee / + slash paths) or kicked via ``vote_deactivate`` in the min-raise edge + case. Halt state is handled at ``score_and_reward_miners`` entry, not + in this helper. + + ``active`` defaults to None for tests that don't care about the + historical active flag; replay callers pass the reconstructed set.""" busy = busy or set() def qualifies(hotkey: str) -> bool: if active is not None and hotkey not in active: return False - collateral = collaterals.get(hotkey, 0) - if collateral < min_collateral: - return False - if max_collateral > 0 and collateral > max_collateral: - return False return hotkey in rewardable and hotkey not in busy and rates.get(hotkey, 0) > 0 by_rate: Dict[float, List[str]] = {} diff --git a/neurons/validator.py b/neurons/validator.py index a1c5a49..1da03f5 100644 --- a/neurons/validator.py +++ b/neurons/validator.py @@ -20,8 +20,6 @@ from allways.constants import ( DEFAULT_FULFILLMENT_TIMEOUT_BLOCKS, FEE_DIVISOR, - MIN_COLLATERAL_TAO, - TAO_TO_RAO, ) from allways.contract_client import AllwaysContractClient from allways.validator.axon_handlers import ( @@ -76,18 +74,16 @@ def __init__(self, config=None): # extension round is open. self.extend_reservation_voted_at: dict[tuple[str, str], int] = {} - # Event-sourced miner state. Replaces the old _poll_collaterals + - # _refresh_min_collateral polling loops. ``sync_to(current_block)`` - # runs each forward step; scoring reads collateral/active/min from - # the watcher's in-memory dicts. - fallback_min_collateral = int(MIN_COLLATERAL_TAO * TAO_TO_RAO) + # Event-sourced miner state. ``sync_to(current_block)`` runs each + # forward step; scoring reads the active set from the watcher's + # in-memory dicts and trusts the contract's active flag for all + # collateral-floor invariants. metadata_path = Path(__file__).resolve().parent.parent / 'allways' / 'metadata' / 'allways_swap_manager.json' self.event_watcher = ContractEventWatcher( substrate=self.subtensor.substrate, contract_address=self.contract_client.contract_address, metadata_path=metadata_path, state_store=self.state_store, - default_min_collateral=fallback_min_collateral, ) self.event_watcher.initialize( current_block=self.block, diff --git a/smart-contracts/ink/errors.rs b/smart-contracts/ink/errors.rs index 4812c78..19b14d7 100644 --- a/smart-contracts/ink/errors.rs +++ b/smart-contracts/ink/errors.rs @@ -60,4 +60,10 @@ pub enum Error { SameChain, /// System is halted — no new activity allowed SystemHalted, + /// Miner collateral meets or exceeds floor; vote_deactivate not applicable + SufficientCollateral, + /// Miner has an active swap; self-deactivation blocked until swap resolves + HasActiveSwap, + /// Miner is currently reserved; self-deactivation blocked until reservation expires + CurrentlyReserved, } diff --git a/smart-contracts/ink/lib.rs b/smart-contracts/ink/lib.rs index 3fab71a..38ea5ca 100644 --- a/smart-contracts/ink/lib.rs +++ b/smart-contracts/ink/lib.rs @@ -82,6 +82,7 @@ mod allways_swap_manager { const REQ_INITIATE: u8 = 2; const REQ_EXTEND: u8 = 3; const REQ_EXTEND_TIMEOUT: u8 = 4; + const REQ_DEACTIVATE: u8 = 5; // Hardcoded 1% protocol fee. Immutable — not even the owner can change it. // Callers on both the miner and validator side hardcode the same value so @@ -984,13 +985,21 @@ mod allways_swap_manager { // Miner Activation / Deactivation // ===================================================================== - /// Deactivate a miner — only the miner themselves can deactivate. + /// Deactivate a miner — only the miner themselves can deactivate, and only + /// when idle. Blocks mid-swap or while reserved so miners cannot dodge + /// in-flight obligations via self-deactivation. #[ink(message)] pub fn deactivate(&mut self, miner: AccountId) -> Result<(), Error> { let caller = self.env().caller(); if caller != miner { return Err(Error::NotAssignedMiner); } + if self.miner_has_active_swap.get(miner).unwrap_or(false) { + return Err(Error::HasActiveSwap); + } + if self.miner_reserved_until.get(miner).unwrap_or(0) >= self.env().block_number() { + return Err(Error::CurrentlyReserved); + } self.miner_deactivation_block.insert(miner, &self.env().block_number()); self.miner_active.insert(miner, &false); self.env().emit_event(MinerActivated { miner, active: false }); @@ -1035,6 +1044,50 @@ mod allways_swap_manager { Ok(()) } + /// Vote to deactivate a miner — validator-only, quorum required. + /// + /// Dormant escape hatch for the `min_collateral` raise case: if the owner + /// raises the collateral floor above an active miner's balance, validators + /// can use this to bring the active flag back in sync with on-chain reality. + /// Gated on `collateral < min_collateral` so validators cannot use it to + /// deactivate compliant miners by collusion. + /// + /// Not blocked mid-swap: the existing swap lifecycle proceeds via persisted + /// assignment. Miner cannot re-activate until they top up collateral because + /// `vote_activate` already checks the floor. + #[ink(message)] + pub fn vote_deactivate(&mut self, miner: AccountId) -> Result<(), Error> { + self.ensure_validator()?; + let caller = self.env().caller(); + + if !self.miner_active.get(miner).unwrap_or(false) { + return Err(Error::InvalidStatus); + } + let miner_collateral = self.collateral.get(miner).unwrap_or(0); + if miner_collateral >= self.min_collateral { + return Err(Error::SufficientCollateral); + } + + let request_id = match self.get_active_request(miner, REQ_DEACTIVATE) { + Some(id) => id, + None => { + let hash = Hash::default(); + self.new_request(miner, REQ_DEACTIVATE, hash) + } + }; + + let vote_count = self.record_vote(request_id, caller)?; + + if vote_count >= self.get_required_votes() { + self.miner_active.insert(miner, &false); + self.miner_deactivation_block.insert(miner, &self.env().block_number()); + self.clear_request(miner, REQ_DEACTIVATE); + self.env().emit_event(MinerActivated { miner, active: false }); + } + + Ok(()) + } + // ===================================================================== // Owner Configuration // ===================================================================== diff --git a/tests/test_event_watcher.py b/tests/test_event_watcher.py index fbc5eca..17e59cf 100644 --- a/tests/test_event_watcher.py +++ b/tests/test_event_watcher.py @@ -73,32 +73,6 @@ def test_registry_has_expected_events(self): assert expected in names, f'missing event {expected}' -class TestCollateralDelta: - def test_posted_increments_collateral(self, tmp_path: Path): - w = make_watcher(tmp_path) - w.apply_event(100, 'CollateralPosted', {'miner': 'hk_a', 'amount': 500_000_000}) - assert w.collateral['hk_a'] == 500_000_000 - events = w.get_collateral_events_in_range(0, 1000) - assert len(events) == 1 - assert events[0]['block'] == 100 - w.state_store.close() - - def test_withdrawn_decrements(self, tmp_path: Path): - w = make_watcher(tmp_path) - w.apply_event(100, 'CollateralPosted', {'miner': 'hk_a', 'amount': 1_000}) - w.apply_event(200, 'CollateralWithdrawn', {'miner': 'hk_a', 'amount': 300}) - assert w.collateral['hk_a'] == 700 - w.state_store.close() - - def test_slashed_decrements_and_floors_at_zero(self, tmp_path: Path): - w = make_watcher(tmp_path) - w.apply_event(100, 'CollateralPosted', {'miner': 'hk_a', 'amount': 500}) - w.apply_event(200, 'CollateralSlashed', {'miner': 'hk_a', 'amount': 1_000}) - # Slashed for more than we have — floor at 0 - assert w.collateral['hk_a'] == 0 - w.state_store.close() - - class TestActiveFlag: def test_activation_adds_to_set(self, tmp_path: Path): w = make_watcher(tmp_path) @@ -109,17 +83,6 @@ def test_activation_adds_to_set(self, tmp_path: Path): w.state_store.close() -class TestConfigUpdated: - def test_min_collateral_config_updates_field(self, tmp_path: Path): - w = make_watcher(tmp_path) - w.apply_event(100, 'ConfigUpdated', {'key': 'min_collateral', 'value': 250_000_000}) - assert w.min_collateral == 250_000_000 - # Unrelated config keys do not affect min_collateral - w.apply_event(200, 'ConfigUpdated', {'key': 'reservation_ttl', 'value': 1200}) - assert w.min_collateral == 250_000_000 - w.state_store.close() - - class TestSwapOutcomePersistence: def test_completed_writes_ledger(self, tmp_path: Path): w = make_watcher(tmp_path) @@ -193,9 +156,7 @@ def test_bootstrap_seeds_busy_from_active_swaps(self, tmp_path: Path): w = make_watcher(tmp_path) client = MagicMock() - client.get_miner_collateral.return_value = 0 client.get_miner_active_flag.return_value = False - client.get_min_collateral.return_value = 0 client.get_active_swaps.return_value = [ type('S', (), {'miner_hotkey': 'hk_a', 'initiated_block': 50})(), type('S', (), {'miner_hotkey': 'hk_b', 'initiated_block': 80})(), @@ -299,21 +260,17 @@ def test_decoder_stops_on_unknown_type(self): class TestBootstrap: """initialize() snapshotting behavior — the M1 fix.""" - def test_bootstrap_seeds_collateral_and_active_from_contract(self, tmp_path: Path): + def test_bootstrap_seeds_active_from_contract(self, tmp_path: Path): from allways.constants import SCORING_WINDOW_BLOCKS w = make_watcher(tmp_path) client = MagicMock() - client.get_miner_collateral.side_effect = lambda hk: {'hk_a': 10, 'hk_b': 20}.get(hk, 0) client.get_miner_active_flag.side_effect = lambda hk: hk == 'hk_a' - client.get_min_collateral.return_value = 5 current_block = SCORING_WINDOW_BLOCKS + 500 # well past the backfill floor w.initialize(current_block=current_block, metagraph_hotkeys=['hk_a', 'hk_b'], contract_client=client) - assert w.collateral == {'hk_a': 10, 'hk_b': 20} assert w.active_miners == {'hk_a'} - assert w.min_collateral == 5 # Cursor rewinds one scoring window so sync_to backfills the crown-time history. assert w.cursor == current_block - SCORING_WINDOW_BLOCKS w.state_store.close() @@ -321,76 +278,12 @@ def test_bootstrap_seeds_collateral_and_active_from_contract(self, tmp_path: Pat def test_bootstrap_tolerates_contract_read_failures(self, tmp_path: Path): w = make_watcher(tmp_path) client = MagicMock() - client.get_miner_collateral.side_effect = RuntimeError('rpc down') client.get_miner_active_flag.side_effect = RuntimeError('rpc down') - client.get_min_collateral.side_effect = RuntimeError('rpc down') # Pre-window start (current_block < SCORING_WINDOW_BLOCKS) — cursor clamps at 0. w.initialize(current_block=500, metagraph_hotkeys=['hk_a'], contract_client=client) # Everything defaults to empty/starting state, no exception propagated - assert w.collateral == {} assert w.active_miners == set() assert w.cursor == 0 w.state_store.close() - - -class TestSetCollateral: - def test_set_collateral_uses_total_from_collateral_posted(self, tmp_path: Path): - w = make_watcher(tmp_path) - w.apply_event(100, 'CollateralPosted', {'miner': 'hk_a', 'amount': 1_000, 'total': 10_000}) - # total (not amount) is authoritative - assert w.collateral['hk_a'] == 10_000 - w.state_store.close() - - def test_set_collateral_uses_remaining_from_withdrawn(self, tmp_path: Path): - w = make_watcher(tmp_path) - w.apply_event(100, 'CollateralPosted', {'miner': 'hk_a', 'amount': 10_000, 'total': 10_000}) - w.apply_event(200, 'CollateralWithdrawn', {'miner': 'hk_a', 'amount': 3_000, 'remaining': 7_000}) - assert w.collateral['hk_a'] == 7_000 - w.state_store.close() - - def test_latest_before_uses_bisect_index(self, tmp_path: Path): - w = make_watcher(tmp_path) - for block, amount in [(100, 1), (200, 2), (300, 3), (400, 4)]: - w.set_collateral(block, 'hk_a', amount) - # Before first event → None - assert w.get_latest_collateral_before('hk_a', block=50) is None - # On-boundary → hit that event - assert w.get_latest_collateral_before('hk_a', block=200) == (2, 200) - # Between events → previous event - assert w.get_latest_collateral_before('hk_a', block=250) == (2, 200) - # After last event → last event - assert w.get_latest_collateral_before('hk_a', block=9999) == (4, 400) - w.state_store.close() - - def test_latest_before_falls_back_to_snapshot_when_no_events(self, tmp_path: Path): - w = make_watcher(tmp_path) - # No events yet; only bootstrap-seeded collateral - w.collateral['hk_seed'] = 500 - result = w.get_latest_collateral_before('hk_seed', block=1000) - assert result == (500, 0) - w.state_store.close() - - -class TestCollateralEventsInRange: - def test_events_are_block_filtered(self, tmp_path: Path): - w = make_watcher(tmp_path) - w.apply_event(100, 'CollateralPosted', {'miner': 'hk_a', 'amount': 1_000}) - w.apply_event(200, 'CollateralPosted', {'miner': 'hk_a', 'amount': 2_000}) - w.apply_event(300, 'CollateralPosted', {'miner': 'hk_a', 'amount': 3_000}) - # Range is (start, end]: block 100 is excluded, 200/300 included - events = w.get_collateral_events_in_range(100, 300) - assert [e['block'] for e in events] == [200, 300] - w.state_store.close() - - def test_latest_before_returns_most_recent(self, tmp_path: Path): - w = make_watcher(tmp_path) - w.apply_event(100, 'CollateralPosted', {'miner': 'hk_a', 'amount': 1_000}) - w.apply_event(200, 'CollateralPosted', {'miner': 'hk_a', 'amount': 500}) - result = w.get_latest_collateral_before('hk_a', block=150) - assert result is not None - collateral, block = result - assert collateral == 1_000 - assert block == 100 - w.state_store.close() diff --git a/tests/test_scoring_v1.py b/tests/test_scoring_v1.py index a7670d2..33f54fb 100644 --- a/tests/test_scoring_v1.py +++ b/tests/test_scoring_v1.py @@ -7,11 +7,12 @@ import numpy as np from allways.constants import RECYCLE_UID, SUCCESS_EXPONENT -from allways.validator.event_watcher import ActiveEvent, ConfigEvent, ContractEventWatcher +from allways.validator.event_watcher import ActiveEvent, ContractEventWatcher from allways.validator.scoring import ( calculate_miner_rewards, crown_holders_at_instant, replay_crown_time_window, + score_and_reward_miners, success_rate, ) from allways.validator.state_store import ValidatorStateStore @@ -34,7 +35,6 @@ def make_watcher(store: ValidatorStateStore, active: set[str]) -> ContractEventW contract_address='5contract', metadata_path=METADATA_PATH, state_store=store, - default_min_collateral=MIN_COLLATERAL, ) w.active_miners = set(active) # Seed an anchor active=True event at block 0 for each bootstrapped @@ -46,11 +46,6 @@ def make_watcher(store: ValidatorStateStore, active: set[str]) -> ContractEventW return w -def seed_collateral(watcher: ContractEventWatcher, hotkey: str, collateral_rao: int, block: int) -> None: - """Insert a collateral event directly into the watcher's in-memory state.""" - watcher.set_collateral(block, hotkey, collateral_rao) - - def seed_active(watcher: ContractEventWatcher, hotkey: str, active: bool, block: int) -> None: """Insert an active-flag event directly into the watcher's in-memory state. Bypasses ``record_active_transition``'s no-op-on-same-state guard so tests @@ -66,20 +61,6 @@ def seed_active(watcher: ContractEventWatcher, hotkey: str, active: bool, block: watcher.active_miners.discard(hotkey) -def seed_config(watcher: ContractEventWatcher, key: str, value: int, block: int) -> None: - """Insert a contract config event directly into the watcher's in-memory - state and advance the current-state mirror. Bypasses - ``record_config_transition``'s no-op-on-same-value guard.""" - event = ConfigEvent(key=key, value=int(value), block=block) - watcher.config_events.append(event) - watcher.config_events_by_key.setdefault(key, []).append(event) - watcher.config_events.sort(key=lambda ev: ev.block) - watcher.config_events_by_key[key].sort(key=lambda ev: ev.block) - watcher.config_current[key] = int(value) - if key == 'min_collateral': - watcher.min_collateral = int(value) - - def make_validator(tmp_path: Path, hotkeys: list[str], block: int = 10_000) -> SimpleNamespace: store = ValidatorStateStore(db_path=tmp_path / 'state.db') watcher = make_watcher(store, active=set(hotkeys)) @@ -113,37 +94,27 @@ def test_ratio_is_completed_over_total(self): class TestCrownHoldersHelper: def test_excludes_rate_zero(self): rates = {'a': 0.0, 'b': 0.00015} - collaterals = {'a': MIN_COLLATERAL, 'b': MIN_COLLATERAL} - assert crown_holders_at_instant(rates, collaterals, MIN_COLLATERAL, {'a', 'b'}) == ['b'] - - def test_excludes_below_min_collateral(self): - rates = {'a': 0.00020, 'b': 0.00015} - collaterals = {'a': MIN_COLLATERAL - 1, 'b': MIN_COLLATERAL} - assert crown_holders_at_instant(rates, collaterals, MIN_COLLATERAL, {'a', 'b'}) == ['b'] + assert crown_holders_at_instant(rates, {'a', 'b'}) == ['b'] def test_excludes_not_eligible(self): rates = {'a': 0.00020, 'b': 0.00015} - collaterals = {'a': MIN_COLLATERAL, 'b': MIN_COLLATERAL} - assert crown_holders_at_instant(rates, collaterals, MIN_COLLATERAL, {'b'}) == ['b'] + assert crown_holders_at_instant(rates, {'b'}) == ['b'] def test_tied_best_rate_returns_all(self): rates = {'a': 0.00020, 'b': 0.00020} - collaterals = {'a': MIN_COLLATERAL, 'b': MIN_COLLATERAL} - holders = set(crown_holders_at_instant(rates, collaterals, MIN_COLLATERAL, {'a', 'b'})) + holders = set(crown_holders_at_instant(rates, {'a', 'b'})) assert holders == {'a', 'b'} def test_busy_best_rate_loses_to_idle_runner_up(self): """Miner A has the best rate but is mid-swap — crown goes to B.""" rates = {'a': 0.00030, 'b': 0.00020} - collaterals = {'a': MIN_COLLATERAL, 'b': MIN_COLLATERAL} - holders = crown_holders_at_instant(rates, collaterals, MIN_COLLATERAL, {'a', 'b'}, busy={'a'}) + holders = crown_holders_at_instant(rates, {'a', 'b'}, busy={'a'}) assert holders == ['b'] def test_all_busy_returns_empty(self): """Every eligible miner is busy → no crown → pool recycles.""" rates = {'a': 0.00030, 'b': 0.00020} - collaterals = {'a': MIN_COLLATERAL, 'b': MIN_COLLATERAL} - holders = crown_holders_at_instant(rates, collaterals, MIN_COLLATERAL, {'a', 'b'}, busy={'a', 'b'}) + holders = crown_holders_at_instant(rates, {'a', 'b'}, busy={'a', 'b'}) assert holders == [] @@ -157,7 +128,6 @@ def test_single_miner_holds_full_window(self, tmp_path: Path): ('hk_a', 'tao', 'btc', 0.00015, 0), ) conn.commit() - seed_collateral(watcher, 'hk_a', MIN_COLLATERAL, 0) crown = replay_crown_time_window( store=store, @@ -189,8 +159,6 @@ def test_two_miners_alternate_rate_leadership(self, tmp_path: Path): ('hk_a', 'tao', 'btc', 0.00030, 600), ) conn.commit() - seed_collateral(watcher, 'hk_a', MIN_COLLATERAL, 0) - seed_collateral(watcher, 'hk_b', MIN_COLLATERAL, 0) crown = replay_crown_time_window( store=store, @@ -214,7 +182,6 @@ def test_tie_splits_credit_evenly(self, tmp_path: Path): 'INSERT INTO rate_events (hotkey, from_chain, to_chain, rate, block) VALUES (?, ?, ?, ?, ?)', (hk, 'tao', 'btc', 0.00020, 0), ) - seed_collateral(watcher, hk, MIN_COLLATERAL, 0) conn.commit() crown = replay_crown_time_window( @@ -229,31 +196,6 @@ def test_tie_splits_credit_evenly(self, tmp_path: Path): assert crown == {'hk_a': 500.0, 'hk_b': 500.0} store.close() - def test_collateral_drop_mid_window_forfeits_remaining_interval(self, tmp_path: Path): - store = ValidatorStateStore(db_path=tmp_path / 'state.db') - watcher = make_watcher(store, active={'hk_a'}) - conn = store.require_connection() - conn.execute( - 'INSERT INTO rate_events (hotkey, from_chain, to_chain, rate, block) VALUES (?, ?, ?, ?, ?)', - ('hk_a', 'tao', 'btc', 0.00020, 0), - ) - conn.commit() - # Initial collateral at block 0, drop at block 600 - seed_collateral(watcher, 'hk_a', MIN_COLLATERAL, 0) - seed_collateral(watcher, 'hk_a', MIN_COLLATERAL - 1, 600) - - crown = replay_crown_time_window( - store=store, - event_watcher=watcher, - from_chain='tao', - to_chain='btc', - window_start=100, - window_end=1100, - rewardable_hotkeys={'hk_a'}, - ) - assert crown == {'hk_a': 500.0} - store.close() - def test_window_start_state_reconstruction_from_pre_window_events(self, tmp_path: Path): """A miner posted before window_start and never updated — replay reads initial state.""" store = ValidatorStateStore(db_path=tmp_path / 'state.db') @@ -264,7 +206,6 @@ def test_window_start_state_reconstruction_from_pre_window_events(self, tmp_path ('hk_a', 'tao', 'btc', 0.00020, 5_000), ) conn.commit() - seed_collateral(watcher, 'hk_a', MIN_COLLATERAL, 5_000) crown = replay_crown_time_window( store=store, @@ -293,8 +234,6 @@ def test_best_rate_miner_goes_busy_credit_flows_to_runner_up(self, tmp_path: Pat row, ) conn.commit() - seed_collateral(watcher, 'hk_a', MIN_COLLATERAL, 0) - seed_collateral(watcher, 'hk_b', MIN_COLLATERAL, 0) # A goes busy with a swap at 400, completes at 800. watcher.apply_event(400, 'SwapInitiated', {'swap_id': 1, 'miner': 'hk_a'}) @@ -325,7 +264,6 @@ def test_solo_miner_busy_pool_recycles(self, tmp_path: Path): ('hk_a', 'tao', 'btc', 0.00020, 0), ) conn.commit() - seed_collateral(watcher, 'hk_a', MIN_COLLATERAL, 0) watcher.apply_event(400, 'SwapInitiated', {'swap_id': 1, 'miner': 'hk_a'}) watcher.apply_event(900, 'SwapTimedOut', {'swap_id': 1, 'miner': 'hk_a'}) @@ -360,8 +298,6 @@ def test_busy_state_at_window_start_is_reconstructed(self, tmp_path: Path): row, ) conn.commit() - seed_collateral(watcher, 'hk_a', MIN_COLLATERAL, 0) - seed_collateral(watcher, 'hk_b', MIN_COLLATERAL, 0) # A's swap started BEFORE the window opens and completes inside it. watcher.apply_event(50, 'SwapInitiated', {'swap_id': 1, 'miner': 'hk_a'}) @@ -405,7 +341,6 @@ def test_single_miner_full_pool_with_perfect_success(self, tmp_path: Path): ('hk_a', direction[0], direction[1], 0.00020, 0), ) conn.commit() - seed_collateral(v.event_watcher, 'hk_a', MIN_COLLATERAL, 0) v.state_store.insert_swap_outcome(swap_id=1, miner_hotkey='hk_a', completed=True, resolved_block=100) rewards, _ = calculate_miner_rewards(v) @@ -423,7 +358,6 @@ def test_partial_success_reduces_reward_by_cube(self, tmp_path: Path): ('hk_a', 'tao', 'btc', 0.00020, 0), ) conn.commit() - seed_collateral(v.event_watcher, 'hk_a', MIN_COLLATERAL, 0) for i in range(8): v.state_store.insert_swap_outcome(i + 1, 'hk_a', True, 100 + i) for i in range(2): @@ -448,7 +382,6 @@ def test_dereg_mid_window_forfeits_credit(self, tmp_path: Path): 'INSERT INTO rate_events (hotkey, from_chain, to_chain, rate, block) VALUES (?, ?, ?, ?, ?)', (hk, 'tao', 'btc', rate, 0), ) - seed_collateral(v.event_watcher, hk, MIN_COLLATERAL, 0) conn.commit() rewards, _ = calculate_miner_rewards(v) @@ -491,7 +424,6 @@ def test_never_active_miner_gets_no_credit(self, tmp_path: Path): ('hk_a', 'tao', 'btc', 0.00020, 0), ) conn.commit() - seed_collateral(v.event_watcher, 'hk_a', MIN_COLLATERAL, 0) rewards, _ = calculate_miner_rewards(v) @@ -520,7 +452,6 @@ def seed_one_miner( (hotkey, from_chain, to_chain, rate, 0), ) conn.commit() - seed_collateral(v.event_watcher, hotkey, collateral, 0) def test_deactivation_mid_window_truncates_credit(self, tmp_path: Path): """Active from window_start, deactivates at block 600 of a 1000-block @@ -533,7 +464,6 @@ def test_deactivation_mid_window_truncates_credit(self, tmp_path: Path): ('hk_a', 'tao', 'btc', 0.00020, 0), ) conn.commit() - seed_collateral(watcher, 'hk_a', MIN_COLLATERAL, 0) # Deactivate mid-window at block 600 (window is (100, 1100]). watcher.apply_event(600, 'MinerActivated', {'miner': 'hk_a', 'active': False}) @@ -560,7 +490,6 @@ def test_activation_mid_window_starts_credit(self, tmp_path: Path): ('hk_a', 'tao', 'btc', 0.00020, 0), ) conn.commit() - seed_collateral(watcher, 'hk_a', MIN_COLLATERAL, 0) watcher.apply_event(400, 'MinerActivated', {'miner': 'hk_a', 'active': True}) crown = replay_crown_time_window( @@ -606,7 +535,6 @@ def test_multiple_active_cycles(self, tmp_path: Path): ('hk_a', 'tao', 'btc', 0.00020, 0), ) conn.commit() - seed_collateral(watcher, 'hk_a', MIN_COLLATERAL, 0) watcher.apply_event(300, 'MinerActivated', {'miner': 'hk_a', 'active': False}) watcher.apply_event(700, 'MinerActivated', {'miner': 'hk_a', 'active': True}) watcher.apply_event(900, 'MinerActivated', {'miner': 'hk_a', 'active': False}) @@ -635,8 +563,6 @@ def test_leader_deactivates_runner_up_inherits_crown(self, tmp_path: Path): (hk, 'tao', 'btc', rate, 0), ) conn.commit() - seed_collateral(watcher, 'hk_a', MIN_COLLATERAL, 0) - seed_collateral(watcher, 'hk_b', MIN_COLLATERAL, 0) watcher.apply_event(500, 'MinerActivated', {'miner': 'hk_a', 'active': False}) crown = replay_crown_time_window( @@ -662,7 +588,6 @@ def test_only_miner_deactivated_mid_window_pool_partially_recycles(self, tmp_pat ('hk_a', 'tao', 'btc', 0.00020, 0), ) conn.commit() - seed_collateral(v.event_watcher, 'hk_a', MIN_COLLATERAL, 0) v.event_watcher.apply_event(600, 'MinerActivated', {'miner': 'hk_a', 'active': False}) rewards, _ = calculate_miner_rewards(v) @@ -686,7 +611,6 @@ def test_pre_window_activation_is_reconstructed(self, tmp_path: Path): ('hk_a', 'tao', 'btc', 0.00020, 0), ) conn.commit() - seed_collateral(watcher, 'hk_a', MIN_COLLATERAL, 0) # Pre-window activation at block 50. watcher.apply_event(50, 'MinerActivated', {'miner': 'hk_a', 'active': True}) @@ -713,7 +637,6 @@ def test_pre_window_deactivation_is_reconstructed(self, tmp_path: Path): ('hk_a', 'tao', 'btc', 0.00020, 0), ) conn.commit() - seed_collateral(watcher, 'hk_a', MIN_COLLATERAL, 0) watcher.apply_event(50, 'MinerActivated', {'miner': 'hk_a', 'active': False}) crown = replay_crown_time_window( @@ -747,8 +670,6 @@ def test_active_applies_before_rate_at_same_block(self, tmp_path: Path): ('hk_a', 'tao', 'btc', 0.00030, 500), ) conn.commit() - seed_collateral(watcher, 'hk_a', MIN_COLLATERAL, 0) - seed_collateral(watcher, 'hk_b', MIN_COLLATERAL, 0) watcher.apply_event(500, 'MinerActivated', {'miner': 'hk_a', 'active': True}) crown = replay_crown_time_window( @@ -768,25 +689,22 @@ def test_crown_helper_with_active_filter(self): """crown_holders_at_instant: explicit active filter excludes otherwise- qualified miners.""" rates = {'a': 0.00030, 'b': 0.00020} - collaterals = {'a': MIN_COLLATERAL, 'b': MIN_COLLATERAL} # a has best rate but isn't in active set. - holders = crown_holders_at_instant(rates, collaterals, MIN_COLLATERAL, rewardable={'a', 'b'}, active={'b'}) + holders = crown_holders_at_instant(rates, rewardable={'a', 'b'}, active={'b'}) assert holders == ['b'] def test_crown_helper_active_none_disables_filter(self): """When active is None, the filter is disabled (backwards-compat for the helper's isolated-test use case).""" rates = {'a': 0.00020} - collaterals = {'a': MIN_COLLATERAL} - holders = crown_holders_at_instant(rates, collaterals, MIN_COLLATERAL, rewardable={'a'}) + holders = crown_holders_at_instant(rates, rewardable={'a'}) assert holders == ['a'] def test_crown_helper_empty_active_excludes_everyone(self): """Explicit empty active set → nobody qualifies, even with rate + collateral.""" rates = {'a': 0.00020, 'b': 0.00015} - collaterals = {'a': MIN_COLLATERAL, 'b': MIN_COLLATERAL} - holders = crown_holders_at_instant(rates, collaterals, MIN_COLLATERAL, rewardable={'a', 'b'}, active=set()) + holders = crown_holders_at_instant(rates, rewardable={'a', 'b'}, active=set()) assert holders == [] def test_active_transition_plus_busy_transition_at_same_block(self, tmp_path: Path): @@ -802,8 +720,6 @@ def test_active_transition_plus_busy_transition_at_same_block(self, tmp_path: Pa (hk, 'tao', 'btc', rate, 0), ) conn.commit() - seed_collateral(watcher, 'hk_a', MIN_COLLATERAL, 0) - seed_collateral(watcher, 'hk_b', MIN_COLLATERAL, 0) # At block 500: A both deactivates and picks up a swap. Both events # apply; both end A's crown credit. A remains out until deactivation # reverses (it doesn't). @@ -842,7 +758,6 @@ def test_dereg_plus_deactivation_no_credit(self, tmp_path: Path): 'INSERT INTO rate_events (hotkey, from_chain, to_chain, rate, block) VALUES (?, ?, ?, ?, ?)', (hk, 'tao', 'btc', rate, 0), ) - seed_collateral(v.event_watcher, hk, MIN_COLLATERAL, 0) conn.commit() v.event_watcher.apply_event(9_000, 'MinerActivated', {'miner': 'hk_a', 'active': False}) @@ -940,7 +855,7 @@ def test_prune_keeps_latest_active_event_per_hotkey(self, tmp_path: Path): # current_block=10_000, SCORING_WINDOW_BLOCKS=1200 → cutoff=8_800. # All events below cutoff except the latest-per-hotkey should drop. - watcher.prune_old_collateral_events(10_000) + watcher.prune_old_events(10_000) blocks_a = [ev.block for ev in watcher.active_events_by_hotkey['hk_a']] assert blocks_a == [5_000] # only latest kept @@ -951,340 +866,53 @@ def test_prune_keeps_latest_active_event_per_hotkey(self, tmp_path: Path): store.close() def test_event_kind_ordering_at_same_block(self, tmp_path: Path): - """CONFIG < ACTIVE < BUSY < COLLATERAL < RATE. At a shared block - the credit_interval *ending* at that block is evaluated before any - of those transitions applies. Ordering matters: a halt at block N - must disqualify block N+1 regardless of any other same-block - transition.""" + """ACTIVE < BUSY < RATE. At a shared block the credit_interval + *ending* at that block is evaluated before any of these transitions + applies. Ordering matters: active-flag flip at block N must gate + block N+1 regardless of any other same-block transition.""" from allways.validator.scoring import EventKind - assert ( - int(EventKind.CONFIG) - < int(EventKind.ACTIVE) - < int(EventKind.BUSY) - < int(EventKind.COLLATERAL) - < int(EventKind.RATE) - ) - - -class TestHistoricalConfigState: - """Replay must evaluate min_collateral, max_collateral, and halt state - as-of each block, not as-of scoring time. Admin-side config changes - don't retroactively disqualify blocks the miner legitimately earned.""" - - def test_min_collateral_raised_mid_window_excludes_post_raise_blocks(self, tmp_path: Path): - """Miner has 100M collateral. Admin raises min_collateral from 80M - to 120M at block 600. Miner qualifies (100 <= min 80) before the - raise, disqualifies (100 < min 120) after.""" - store = ValidatorStateStore(db_path=tmp_path / 'state.db') - watcher = make_watcher(store, active={'hk_a'}) - seed_config(watcher, 'min_collateral', 80_000_000, 0) - conn = store.require_connection() - conn.execute( - 'INSERT INTO rate_events (hotkey, from_chain, to_chain, rate, block) VALUES (?, ?, ?, ?, ?)', - ('hk_a', 'tao', 'btc', 0.00020, 0), - ) - conn.commit() - seed_collateral(watcher, 'hk_a', 100_000_000, 0) - watcher.apply_event(600, 'ConfigUpdated', {'key': 'min_collateral', 'value': 120_000_000}) - - crown = replay_crown_time_window( - store=store, - event_watcher=watcher, - from_chain='tao', - to_chain='btc', - window_start=100, - window_end=1100, - rewardable_hotkeys={'hk_a'}, - ) - # (100, 600] → min 80M, qualifies: 500 blocks. (600, 1100] → min 120M, disqualified: 0. - assert crown == {'hk_a': 500.0} - store.close() - - def test_min_collateral_lowered_mid_window_admits_miner(self, tmp_path: Path): - """Under-min at window_start; admin lowers min mid-window; miner - now qualifies for post-lower blocks.""" - store = ValidatorStateStore(db_path=tmp_path / 'state.db') - watcher = make_watcher(store, active={'hk_a'}) - seed_config(watcher, 'min_collateral', 200_000_000, 0) - conn = store.require_connection() - conn.execute( - 'INSERT INTO rate_events (hotkey, from_chain, to_chain, rate, block) VALUES (?, ?, ?, ?, ?)', - ('hk_a', 'tao', 'btc', 0.00020, 0), - ) - conn.commit() - seed_collateral(watcher, 'hk_a', 100_000_000, 0) - watcher.apply_event(400, 'ConfigUpdated', {'key': 'min_collateral', 'value': 50_000_000}) - - crown = replay_crown_time_window( - store=store, - event_watcher=watcher, - from_chain='tao', - to_chain='btc', - window_start=100, - window_end=1100, - rewardable_hotkeys={'hk_a'}, - ) - # (100, 400] → disqualified. (400, 1100] → qualified: 700 blocks. - assert crown == {'hk_a': 700.0} - store.close() - - def test_max_collateral_excludes_over_cap_miner(self, tmp_path: Path): - """Miner A has 500M collateral; max_collateral is 200M; A is over - cap and disqualifies despite best rate. B (100M, under cap) wins.""" - store = ValidatorStateStore(db_path=tmp_path / 'state.db') - watcher = make_watcher(store, active={'hk_a', 'hk_b'}) - seed_config(watcher, 'max_collateral', 200_000_000, 0) - conn = store.require_connection() - for hk, rate in (('hk_a', 0.00030), ('hk_b', 0.00020)): - conn.execute( - 'INSERT INTO rate_events (hotkey, from_chain, to_chain, rate, block) VALUES (?, ?, ?, ?, ?)', - (hk, 'tao', 'btc', rate, 0), - ) - conn.commit() - seed_collateral(watcher, 'hk_a', 500_000_000, 0) - seed_collateral(watcher, 'hk_b', 100_000_000, 0) - - crown = replay_crown_time_window( - store=store, - event_watcher=watcher, - from_chain='tao', - to_chain='btc', - window_start=100, - window_end=1100, - rewardable_hotkeys={'hk_a', 'hk_b'}, - ) - # A is over-cap every block → excluded. B wins the whole window. - assert crown == {'hk_b': 1000.0} - store.close() - - def test_max_collateral_lowered_mid_window_disqualifies_miner(self, tmp_path: Path): - """A has 300M collateral. max starts at 500M (A qualifies). Admin - lowers max to 100M at block 500. A is now over-cap.""" - store = ValidatorStateStore(db_path=tmp_path / 'state.db') - watcher = make_watcher(store, active={'hk_a'}) - seed_config(watcher, 'max_collateral', 500_000_000, 0) - conn = store.require_connection() - conn.execute( - 'INSERT INTO rate_events (hotkey, from_chain, to_chain, rate, block) VALUES (?, ?, ?, ?, ?)', - ('hk_a', 'tao', 'btc', 0.00020, 0), - ) - conn.commit() - seed_collateral(watcher, 'hk_a', 300_000_000, 0) - watcher.apply_event(500, 'ConfigUpdated', {'key': 'max_collateral', 'value': 100_000_000}) - - crown = replay_crown_time_window( - store=store, - event_watcher=watcher, - from_chain='tao', - to_chain='btc', - window_start=100, - window_end=1100, - rewardable_hotkeys={'hk_a'}, - ) - # (100, 500] → max 500M, 300M qualifies: 400 blocks. (500, 1100] → max 100M, over-cap: 0. - assert crown == {'hk_a': 400.0} - store.close() - - def test_max_collateral_zero_means_no_cap(self, tmp_path: Path): - """Contract semantics: max_collateral=0 disables the upper bound. - A miner with arbitrarily large collateral still qualifies.""" - store = ValidatorStateStore(db_path=tmp_path / 'state.db') - watcher = make_watcher(store, active={'hk_a'}) - # config_current default for max_collateral is 0. - assert watcher.config_current['max_collateral'] == 0 - conn = store.require_connection() - conn.execute( - 'INSERT INTO rate_events (hotkey, from_chain, to_chain, rate, block) VALUES (?, ?, ?, ?, ?)', - ('hk_a', 'tao', 'btc', 0.00020, 0), - ) - conn.commit() - # A posts 1 trillion rao — absurd but should still qualify with no cap. - seed_collateral(watcher, 'hk_a', 1_000_000_000_000, 0) - - crown = replay_crown_time_window( - store=store, - event_watcher=watcher, - from_chain='tao', - to_chain='btc', - window_start=100, - window_end=1100, - rewardable_hotkeys={'hk_a'}, - ) - assert crown == {'hk_a': 1000.0} - store.close() - - def test_halt_mid_window_recycles_halt_interval(self, tmp_path: Path): - """Contract halted at block 400, unhalted at block 800. During the - halt, no miner holds crown — that interval's pool recycles. Miner - earns only the active intervals.""" - store = ValidatorStateStore(db_path=tmp_path / 'state.db') - watcher = make_watcher(store, active={'hk_a'}) - conn = store.require_connection() - conn.execute( - 'INSERT INTO rate_events (hotkey, from_chain, to_chain, rate, block) VALUES (?, ?, ?, ?, ?)', - ('hk_a', 'tao', 'btc', 0.00020, 0), - ) - conn.commit() - seed_collateral(watcher, 'hk_a', MIN_COLLATERAL, 0) - watcher.apply_event(400, 'ConfigUpdated', {'key': 'halted', 'value': 1}) - watcher.apply_event(800, 'ConfigUpdated', {'key': 'halted', 'value': 0}) - - crown = replay_crown_time_window( - store=store, - event_watcher=watcher, - from_chain='tao', - to_chain='btc', - window_start=100, - window_end=1100, - rewardable_hotkeys={'hk_a'}, - ) - # (100, 400] = 300 + (800, 1100] = 300 → 600 total. (400, 800] recycles. - assert crown == {'hk_a': 600.0} - store.close() - - def test_halt_entire_window_recycles_full_pool(self, tmp_path: Path): - """Contract halted at scoring time and for the full window — the - pool fully recycles through RECYCLE_UID.""" - hotkeys = pad_hotkeys_to_cover_recycle(['hk_a']) - v = make_validator(tmp_path, hotkeys=hotkeys, block=10_000) - # Halt the contract starting pre-window. - seed_config(v.event_watcher, 'halted', 1, 0) - conn = v.state_store.require_connection() - conn.execute( - 'INSERT INTO rate_events (hotkey, from_chain, to_chain, rate, block) VALUES (?, ?, ?, ?, ?)', - ('hk_a', 'tao', 'btc', 0.00020, 0), - ) - conn.commit() - seed_collateral(v.event_watcher, 'hk_a', MIN_COLLATERAL, 0) - - rewards, _ = calculate_miner_rewards(v) - - assert rewards[0] == 0.0 - np.testing.assert_allclose(rewards[RECYCLE_UID], 1.0, atol=1e-6) - v.state_store.close() - - def test_halt_event_applied_after_block_halt_takes_effect_next_block(self, tmp_path: Path): - """Halt event at block 500. The credit_interval *ending* at 500 - uses pre-halt state — block 500 is credited. The interval after - (500, window_end] sees halted=True → no credit.""" - store = ValidatorStateStore(db_path=tmp_path / 'state.db') - watcher = make_watcher(store, active={'hk_a'}) - conn = store.require_connection() - conn.execute( - 'INSERT INTO rate_events (hotkey, from_chain, to_chain, rate, block) VALUES (?, ?, ?, ?, ?)', - ('hk_a', 'tao', 'btc', 0.00020, 0), - ) - conn.commit() - seed_collateral(watcher, 'hk_a', MIN_COLLATERAL, 0) - watcher.apply_event(500, 'ConfigUpdated', {'key': 'halted', 'value': 1}) - - crown = replay_crown_time_window( - store=store, - event_watcher=watcher, - from_chain='tao', - to_chain='btc', - window_start=100, - window_end=1100, - rewardable_hotkeys={'hk_a'}, - ) - # (100, 500] = 400. After block 500 the halt applies, rest recycles. - assert crown == {'hk_a': 400.0} - store.close() - - def test_unknown_config_key_is_ignored(self, tmp_path: Path): - """ConfigUpdated for a key not in CONFIG_KEYS_TRACKED is silently - dropped — we don't bloat the log with every knob.""" - store = ValidatorStateStore(db_path=tmp_path / 'state.db') - watcher = make_watcher(store, active=set()) - watcher.apply_event(500, 'ConfigUpdated', {'key': 'consensus_threshold_percent', 'value': 66}) - assert watcher.config_events_by_key.get('consensus_threshold_percent') is None - store.close() - - def test_duplicate_config_event_no_op(self, tmp_path: Path): - """Duplicate ConfigUpdated for the same value is a no-op.""" - store = ValidatorStateStore(db_path=tmp_path / 'state.db') - watcher = make_watcher(store, active=set()) - watcher.apply_event(100, 'ConfigUpdated', {'key': 'min_collateral', 'value': 500}) - watcher.apply_event(200, 'ConfigUpdated', {'key': 'min_collateral', 'value': 500}) - watcher.apply_event(300, 'ConfigUpdated', {'key': 'min_collateral', 'value': 500}) - events = watcher.config_events_by_key['min_collateral'] - assert [e.block for e in events] == [100] - store.close() - - def test_get_config_at_returns_current_when_no_events(self, tmp_path: Path): - """Fallback path: no history → return config_current value (set by - constructor default / bootstrap).""" - store = ValidatorStateStore(db_path=tmp_path / 'state.db') - watcher = make_watcher(store, active=set()) - assert watcher.get_config_at('min_collateral', 9_999) == MIN_COLLATERAL - assert watcher.get_config_at('max_collateral', 9_999) == 0 - assert watcher.get_config_at('halted', 9_999) == 0 - store.close() - - def test_config_history_is_replayed_when_events_exist(self, tmp_path: Path): - """With in-window events, get_config_at returns the latest value - at or before the requested block.""" - store = ValidatorStateStore(db_path=tmp_path / 'state.db') - watcher = make_watcher(store, active=set()) - watcher.apply_event(100, 'ConfigUpdated', {'key': 'max_collateral', 'value': 1_000_000}) - watcher.apply_event(500, 'ConfigUpdated', {'key': 'max_collateral', 'value': 2_000_000}) - watcher.apply_event(900, 'ConfigUpdated', {'key': 'max_collateral', 'value': 500_000}) - assert watcher.get_config_at('max_collateral', 50) == 0 # pre-first-event falls back - assert watcher.get_config_at('max_collateral', 100) == 1_000_000 - assert watcher.get_config_at('max_collateral', 499) == 1_000_000 - assert watcher.get_config_at('max_collateral', 500) == 2_000_000 - assert watcher.get_config_at('max_collateral', 899) == 2_000_000 - assert watcher.get_config_at('max_collateral', 999) == 500_000 - store.close() - - def test_halt_and_min_collateral_change_same_block(self, tmp_path: Path): - """Both a halt and a min_collateral change at the same block apply - in the same credit_interval boundary. Both are CONFIG kind → - ordering among themselves is insertion order. Halt is the stronger - filter so result is dominated by it.""" - store = ValidatorStateStore(db_path=tmp_path / 'state.db') - watcher = make_watcher(store, active={'hk_a'}) - conn = store.require_connection() - conn.execute( - 'INSERT INTO rate_events (hotkey, from_chain, to_chain, rate, block) VALUES (?, ?, ?, ?, ?)', - ('hk_a', 'tao', 'btc', 0.00020, 0), - ) - conn.commit() - seed_collateral(watcher, 'hk_a', MIN_COLLATERAL, 0) - watcher.apply_event(500, 'ConfigUpdated', {'key': 'halted', 'value': 1}) - watcher.apply_event(500, 'ConfigUpdated', {'key': 'min_collateral', 'value': 999_999_999}) - - crown = replay_crown_time_window( - store=store, - event_watcher=watcher, - from_chain='tao', - to_chain='btc', - window_start=100, - window_end=1100, - rewardable_hotkeys={'hk_a'}, - ) - # Pre-500: 400 blocks to A. Post-500: halted → nobody. Also over-min. - assert crown == {'hk_a': 400.0} - store.close() - - def test_crown_helper_halted_short_circuits(self): - """crown_holders_at_instant: halted=True returns [] unconditionally.""" - rates = {'a': 0.00020, 'b': 0.00015} - collaterals = {'a': MIN_COLLATERAL, 'b': MIN_COLLATERAL} - holders = crown_holders_at_instant(rates, collaterals, MIN_COLLATERAL, rewardable={'a', 'b'}, halted=True) - assert holders == [] - - def test_crown_helper_max_collateral_excludes_over_cap(self): - rates = {'a': 0.00030, 'b': 0.00020} - collaterals = {'a': 300_000_000, 'b': 100_000_000} - holders = crown_holders_at_instant( - rates, collaterals, MIN_COLLATERAL, rewardable={'a', 'b'}, max_collateral=200_000_000 - ) - assert holders == ['b'] - - def test_crown_helper_max_collateral_zero_disabled(self): - rates = {'a': 0.00020} - collaterals = {'a': 1_000_000_000_000} - holders = crown_holders_at_instant(rates, collaterals, MIN_COLLATERAL, rewardable={'a'}, max_collateral=0) - assert holders == ['a'] + assert int(EventKind.ACTIVE) < int(EventKind.BUSY) < int(EventKind.RATE) + + +class TestHaltShortCircuit: + """Halt check at the scoring entry sidesteps event replay: full pool + recycles, rewards skip the crown-time path entirely.""" + + def _make_validator_with_halt(self, tmp_path: Path, halt_return, hotkeys: list[str]) -> SimpleNamespace: + hotkeys = pad_hotkeys_to_cover_recycle(hotkeys) + v = make_validator(tmp_path, hotkeys) + contract_client = MagicMock() + if isinstance(halt_return, Exception): + contract_client.get_halted.side_effect = halt_return + else: + contract_client.get_halted.return_value = halt_return + v.contract_client = contract_client + captured = {} + + def capture(rewards, miner_uids): + captured['rewards'] = rewards + captured['miner_uids'] = miner_uids + + v.update_scores = capture + return v, captured + + def test_halted_short_circuits_to_full_recycle(self, tmp_path: Path): + v, captured = self._make_validator_with_halt(tmp_path, halt_return=True, hotkeys=['hk_a', 'hk_b']) + score_and_reward_miners(v) + rewards = captured['rewards'] + recycle_uid = RECYCLE_UID if RECYCLE_UID < len(rewards) else 0 + assert rewards[recycle_uid] == 1.0 + # every other uid must be exactly zero + assert float(rewards.sum()) == 1.0 + + def test_halted_rpc_error_still_scores(self, tmp_path: Path): + """If the halt RPC fails, scoring proceeds as normal rather than + zeroing every miner's reward.""" + v, captured = self._make_validator_with_halt(tmp_path, halt_return=RuntimeError('rpc down'), hotkeys=['hk_a']) + # No rate / collateral seeded → replay credits nothing → full pool + # still recycles, but via the normal path (not the halt short-circuit). + score_and_reward_miners(v) + rewards = captured['rewards'] + recycle_uid = RECYCLE_UID if RECYCLE_UID < len(rewards) else 0 + assert rewards[recycle_uid] > 0 # recycle got something via normal path