From bc68456ef47f802e85d8416af6bc21688c305f7b Mon Sep 17 00:00:00 2001 From: kiannidev <156195510+kiannidev@users.noreply.github.com> Date: Thu, 28 May 2026 18:11:30 +0200 Subject: [PATCH] fix: vote deactivate miners below min_collateral floor Admin min_collateral raises leave under-min miners active on-chain. Add a forward pass that submits vote_deactivate when collateral falls below the cached floor. Fixes #387 --- allways/validator/forward.py | 46 +++++++ allways/validator/voting.py | 16 +++ .../test_under_min_collateral_deactivation.py | 113 ++++++++++++++++++ 3 files changed, 175 insertions(+) create mode 100644 tests/test_under_min_collateral_deactivation.py diff --git a/allways/validator/forward.py b/allways/validator/forward.py index e4d839b..e9c37ca 100644 --- a/allways/validator/forward.py +++ b/allways/validator/forward.py @@ -82,6 +82,8 @@ async def forward(self: Validator) -> None: poll_commitments(self) + enforce_under_min_collateral_deactivation(self) + # Pull newly-initiated and resolved swaps off the contract. await tracker.poll() bt.logging.info('forward: tracker polled') @@ -397,6 +399,50 @@ def refresh_miner_rates(self: Validator) -> None: self.last_known_rates[key] = r +def enforce_under_min_collateral_deactivation(self: Validator) -> None: + """Vote to deactivate active miners below the current min_collateral floor. + + Admin ``set_min_collateral`` raises do not retroactively clear ``active`` + on-chain — only slash/fee paths auto-deactivate. Validators remediate via + ``vote_deactivate`` quorum (see contract docs and scoring.py comments). + """ + try: + min_collateral = self.bounds_cache.min_collateral() + except Exception as e: + bt.logging.warning(f'forward: min_collateral read failed, skipping deactivation pass: {e}') + return + + if min_collateral <= 0: + return + + active = self.event_watcher.active_miners + if not active: + return + + hotkey_to_uid = {hk: uid for uid, hk in enumerate(self.metagraph.hotkeys)} + + for hotkey in active: + try: + if not self.contract_client.get_miner_active_flag(hotkey): + continue + collateral = int(self.contract_client.get_miner_collateral(hotkey)) + except Exception as e: + bt.logging.warning(f'forward: under-min check failed for {hotkey[:8]}: {e}') + continue + + if collateral >= min_collateral: + continue + + uid = hotkey_to_uid.get(hotkey, '?') + label = f'UID {uid} ({hotkey[:8]})' + bt.logging.info( + f'{label}: collateral {collateral} < min_collateral {min_collateral} ' + f'while active → voting deactivate' + ) + if voting.vote_deactivate(self.contract_client, self.wallet, hotkey, label=label): + bt.logging.success(f'{label}: vote_deactivate submitted') + + def purge_deregistered_hotkeys(self: Validator) -> None: current_hotkeys = set(self.metagraph.hotkeys) stale = {hk for (hk, _, _) in self.last_known_rates.keys()} - current_hotkeys diff --git a/allways/validator/voting.py b/allways/validator/voting.py index 9de7195..3e853dd 100644 --- a/allways/validator/voting.py +++ b/allways/validator/voting.py @@ -38,3 +38,19 @@ def timeout_swap( except Exception as e: bt.logging.error(f'{tag}: timeout_swap vote failed: {e}') return False + + +def vote_deactivate( + client: AllwaysContractClient, + wallet: bt.Wallet, + miner_hotkey: str, + label: Optional[str] = None, +) -> bool: + """Vote to deactivate an active miner. Caller logs the outcome.""" + tag = label or f'Miner {miner_hotkey[:8]}' + try: + client.vote_deactivate(wallet=wallet, miner_hotkey=miner_hotkey) + return True + except Exception as e: + bt.logging.error(f'{tag}: vote_deactivate failed: {e}') + return False diff --git a/tests/test_under_min_collateral_deactivation.py b/tests/test_under_min_collateral_deactivation.py new file mode 100644 index 0000000..9d11e47 --- /dev/null +++ b/tests/test_under_min_collateral_deactivation.py @@ -0,0 +1,113 @@ +"""Validator forward — vote_deactivate when active miners fall below min_collateral.""" + +from types import SimpleNamespace +from unittest.mock import MagicMock + +from allways.validator.forward import enforce_under_min_collateral_deactivation + + +def make_validator( + *, + active_miners=frozenset(), + min_collateral=10_000_000_000, + collaterals=None, + active_flags=None, +): + collaterals = collaterals or {} + active_flags = active_flags if active_flags is not None else {hk: True for hk in active_miners} + + contract_client = MagicMock() + contract_client.get_miner_collateral.side_effect = lambda hk: collaterals.get(hk, 0) + contract_client.get_miner_active_flag.side_effect = lambda hk: active_flags.get(hk, False) + contract_client.vote_deactivate.return_value = '0xdead' + + bounds_cache = MagicMock() + bounds_cache.min_collateral.return_value = min_collateral + + event_watcher = MagicMock() + event_watcher.active_miners = set(active_miners) + + return SimpleNamespace( + bounds_cache=bounds_cache, + contract_client=contract_client, + event_watcher=event_watcher, + metagraph=SimpleNamespace(hotkeys=['miner-a', 'miner-b']), + wallet=MagicMock(), + ) + + +class TestEnforceUnderMinCollateralDeactivation: + def test_votes_deactivate_for_active_miner_below_floor(self): + v = make_validator( + active_miners={'miner-a'}, + min_collateral=10_000_000_000, + collaterals={'miner-a': 5_000_000_000}, + ) + + enforce_under_min_collateral_deactivation(v) + + v.contract_client.vote_deactivate.assert_called_once_with( + wallet=v.wallet, + miner_hotkey='miner-a', + ) + + def test_skips_miner_at_or_above_floor(self): + v = make_validator( + active_miners={'miner-a'}, + min_collateral=10_000_000_000, + collaterals={'miner-a': 10_000_000_000}, + ) + + enforce_under_min_collateral_deactivation(v) + + v.contract_client.vote_deactivate.assert_not_called() + + def test_skips_when_min_collateral_unset(self): + v = make_validator( + active_miners={'miner-a'}, + min_collateral=0, + collaterals={'miner-a': 1}, + ) + + enforce_under_min_collateral_deactivation(v) + + v.bounds_cache.min_collateral.assert_called_once() + v.contract_client.vote_deactivate.assert_not_called() + + def test_skips_inactive_on_contract_despite_local_active_set(self): + v = make_validator( + active_miners={'miner-a'}, + min_collateral=10_000_000_000, + collaterals={'miner-a': 1}, + active_flags={'miner-a': False}, + ) + + enforce_under_min_collateral_deactivation(v) + + v.contract_client.vote_deactivate.assert_not_called() + + def test_checks_each_active_miner(self): + v = make_validator( + active_miners={'miner-a', 'miner-b'}, + min_collateral=10_000_000_000, + collaterals={'miner-a': 5_000_000_000, 'miner-b': 20_000_000_000}, + ) + + enforce_under_min_collateral_deactivation(v) + + v.contract_client.vote_deactivate.assert_called_once_with( + wallet=v.wallet, + miner_hotkey='miner-a', + ) + assert {c.args[0] for c in v.contract_client.get_miner_collateral.call_args_list} == { + 'miner-a', + 'miner-b', + } + + def test_min_collateral_read_failure_is_non_fatal(self): + v = make_validator(active_miners={'miner-a'}) + v.bounds_cache.min_collateral.side_effect = RuntimeError('rpc down') + + enforce_under_min_collateral_deactivation(v) + + v.contract_client.vote_deactivate.assert_not_called()