diff --git a/allways/validator/forward.py b/allways/validator/forward.py index 595f39b..8644778 100644 --- a/allways/validator/forward.py +++ b/allways/validator/forward.py @@ -128,6 +128,12 @@ def initialize_pending_user_reservations(self: Validator) -> None: for item in items: swap_label = f'{item.from_chain.upper()}->{item.to_chain.upper()}' + if item.miner_hotkey not in hotkey_to_uid: + self.state_store.remove(item.miner_hotkey) + bt.logging.info( + f'PendingConfirm [{swap_label} ({item.miner_hotkey[:8]})]: miner deregistered, dropping' + ) + continue uid = hotkey_to_uid.get(item.miner_hotkey, '?') miner_short = f'UID {uid} ({item.miner_hotkey[:8]})' provider = self.chain_providers.get(item.from_chain) @@ -403,10 +409,17 @@ def purge_deregistered_hotkeys(self: Validator) -> None: stale = {hk for (hk, _, _) in self.last_known_rates.keys()} - current_hotkeys if not stale: return + pending_dropped = 0 for hk in stale: + if self.state_store.has(hk): + pending_dropped += 1 self.state_store.delete_hotkey(hk) self.last_known_rates = {k: v for k, v in self.last_known_rates.items() if k[0] not in stale} bt.logging.info(f'forward: dropped state for {len(stale)} deregistered miner(s)') + if pending_dropped: + bt.logging.info( + f'forward: removed {pending_dropped} pending confirm(s) for deregistered miner(s)' + ) async def confirm_miner_fulfillments( diff --git a/allways/validator/state_store.py b/allways/validator/state_store.py index 9757094..5909a7c 100644 --- a/allways/validator/state_store.py +++ b/allways/validator/state_store.py @@ -695,6 +695,7 @@ def delete_hotkey(self, hotkey: str) -> None: conn.execute('DELETE FROM swap_outcomes WHERE miner_hotkey = ?', (hotkey,)) conn.execute('DELETE FROM reservation_pins WHERE miner_hotkey = ?', (hotkey,)) conn.execute('DELETE FROM reservation_pin_events WHERE hotkey = ?', (hotkey,)) + conn.execute('DELETE FROM pending_confirms WHERE miner_hotkey = ?', (hotkey,)) conn.commit() def prune_events_older_than(self, cutoff_block: int) -> None: diff --git a/tests/test_forward.py b/tests/test_forward.py index 1cdcb8f..51838d4 100644 --- a/tests/test_forward.py +++ b/tests/test_forward.py @@ -11,7 +11,12 @@ from unittest.mock import MagicMock from allways.classes import Swap, SwapStatus -from allways.validator.forward import extend_fulfilled_near_timeout +from allways.validator.forward import ( + extend_fulfilled_near_timeout, + initialize_pending_user_reservations, + purge_deregistered_hotkeys, +) +from allways.validator.state_store import PendingConfirm, ValidatorStateStore from allways.validator.swap_tracker import SwapTracker # Real swap 206 numbers: extension 2 was proposed at this block, and a BTC @@ -136,3 +141,59 @@ def test_skips_extension_when_dest_tx_fails_canonical_check(self): v.optimistic_extensions.maybe_propose_timeout.assert_not_called() v.optimistic_extensions.maybe_challenge_timeout.assert_not_called() + + +PENDING_CONFIRM_SAMPLE = PendingConfirm( + miner_hotkey='miner-gone', + from_tx_hash='tx-1', + from_chain='btc', + to_chain='tao', + from_address='bc1-user', + to_address='5user', + tao_amount=123, + from_amount=456, + to_amount=789, + miner_from_address='bc1-miner', + miner_to_address='5miner', + rate_str='350', + reserved_until=1000, +) + + +class TestPurgeDeregisteredHotkeys: + def test_clears_pending_confirms_for_stale_hotkeys(self, tmp_path): + store = ValidatorStateStore(db_path=tmp_path / 'state.db') + store.enqueue(PENDING_CONFIRM_SAMPLE) + assert store.has('miner-gone') + + v = SimpleNamespace( + metagraph=SimpleNamespace(hotkeys=['miner-live']), + last_known_rates={('miner-gone', 'btc', 'tao'): '350'}, + state_store=store, + ) + purge_deregistered_hotkeys(v) + + assert not store.has('miner-gone') + assert v.last_known_rates == {} + store.close() + + +class TestInitializePendingSkipsDeregistered: + def test_drops_pending_for_hotkey_not_in_metagraph(self, tmp_path): + store = ValidatorStateStore(db_path=tmp_path / 'state.db') + store.enqueue(PENDING_CONFIRM_SAMPLE) + provider = MagicMock() + + v = SimpleNamespace( + metagraph=SimpleNamespace(hotkeys=['miner-live']), + state_store=store, + block=100, + chain_providers={'btc': provider}, + event_watcher=SimpleNamespace(open_swap_count={'miner-gone': 0}), + contract_client=MagicMock(), + ) + initialize_pending_user_reservations(v) + + assert not store.has('miner-gone') + provider.verify_transaction.assert_not_called() + store.close() diff --git a/tests/test_rate_state.py b/tests/test_rate_state.py index 6a4ee3e..e8dc3b7 100644 --- a/tests/test_rate_state.py +++ b/tests/test_rate_state.py @@ -3,7 +3,7 @@ import pytest -from allways.validator.state_store import ValidatorStateStore +from allways.validator.state_store import PendingConfirm, ValidatorStateStore def make_store(tmp_path: Path) -> ValidatorStateStore: @@ -188,6 +188,32 @@ def test_removes_from_rate_and_outcome_tables(self, tmp_path: Path): assert 'hk2' in store.get_success_rates_since(0) store.close() + def test_removes_pending_confirms(self, tmp_path: Path): + store = make_store(tmp_path) + store.enqueue( + PendingConfirm( + miner_hotkey='hk1', + from_tx_hash='tx-1', + from_chain='btc', + to_chain='tao', + from_address='bc1-user', + to_address='5user', + tao_amount=123, + from_amount=456, + to_amount=789, + miner_from_address='bc1-miner', + miner_to_address='5miner', + rate_str='350', + reserved_until=1000, + ) + ) + assert store.has('hk1') + + store.delete_hotkey('hk1') + + assert not store.has('hk1') + store.close() + class TestPrune: def test_prune_leaves_swap_outcomes_intact(self, tmp_path: Path): diff --git a/tests/test_state_store.py b/tests/test_state_store.py index 20384a3..f0bdd2a 100644 --- a/tests/test_state_store.py +++ b/tests/test_state_store.py @@ -10,7 +10,7 @@ from dataclasses import replace from pathlib import Path -from allways.validator.state_store import ReservationPin, ValidatorStateStore +from allways.validator.state_store import PendingConfirm, ReservationPin, ValidatorStateStore PIN_SAMPLE1 = ReservationPin( miner_hotkey='miner-1', @@ -147,6 +147,32 @@ def test_delete_hotkey_clears_the_pin(self, tmp_path: Path): assert store.get_reservation_pin('miner-1') is None store.close() + def test_delete_hotkey_clears_pending_confirm(self, tmp_path: Path): + store = ValidatorStateStore(db_path=tmp_path / 'state.db') + store.enqueue( + PendingConfirm( + miner_hotkey='miner-1', + from_tx_hash='tx-1', + from_chain='btc', + to_chain='tao', + from_address='bc1-user', + to_address='5user', + tao_amount=123, + from_amount=456, + to_amount=789, + miner_from_address='bc1-miner', + miner_to_address='5miner', + rate_str='350', + reserved_until=1000, + ) + ) + assert store.has('miner-1') + + store.delete_hotkey('miner-1') + + assert not store.has('miner-1') + store.close() + def test_fresh_init_db_has_reservation_pins_table(self, tmp_path: Path): store = ValidatorStateStore(db_path=tmp_path / 'state.db') conn = store.require_connection()