Skip to content
Merged
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
15 changes: 15 additions & 0 deletions allways/contract_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand Down Expand Up @@ -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')],
Expand Down Expand Up @@ -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'),
}


Expand Down Expand Up @@ -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,
Expand Down
259 changes: 14 additions & 245 deletions allways/validator/event_watcher.py

Large diffs are not rendered by default.

167 changes: 67 additions & 100 deletions allways/validator/scoring.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,14 +30,39 @@

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)
except Exception as e:
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:
Expand All @@ -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)}

Expand Down Expand Up @@ -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
Expand All @@ -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(
Expand All @@ -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)
Expand All @@ -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'])))

Expand All @@ -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)
Expand All @@ -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
Expand All @@ -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)
Expand All @@ -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]] = {}
Expand Down
12 changes: 4 additions & 8 deletions neurons/validator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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,
Expand Down
6 changes: 6 additions & 0 deletions smart-contracts/ink/errors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
Loading
Loading