Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions allways/validator/forward.py
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down Expand Up @@ -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
Expand Down
16 changes: 16 additions & 0 deletions allways/validator/voting.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
113 changes: 113 additions & 0 deletions tests/test_under_min_collateral_deactivation.py
Original file line number Diff line number Diff line change
@@ -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()