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
2 changes: 1 addition & 1 deletion allways/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
__version__ = '1.0.8'
__version__ = '1.0.9'
version_split = __version__.split('.')
__spec_version__ = (1000 * int(version_split[0])) + (10 * int(version_split[1])) + (1 * int(version_split[2]))
67 changes: 63 additions & 4 deletions allways/validator/event_watcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -480,6 +480,49 @@ def cold_bootstrap(
self._record_collateral_event(self.cursor, hotkey, collateral)
self.state_store.set_event_cursor(self.cursor)

def reconcile_collateral_from_contract(
self,
current_block: int,
metagraph_hotkeys: List[str],
contract_client: Any,
) -> int:
"""Resync each active miner's collateral against the contract.

The per-event series can drift from on-chain truth — a missed
CollateralPosted leaves a stale/absent baseline, and a fee/slash delta
on no baseline used to fabricate a 0 — and the capacity / can_fund gate
then reads that as low/zero collateral and drops the miner from crown.
Reading the contract for each active miner and recording a fresh event
when the value changed reconciles the series to truth.

Called at startup (heals the warm-restart path, which does no contract
reads) and once per scoring round. Only active miners can hold crown,
so we skip the rest — both correct and cheap. Writes at
``current_block`` only, never retroactively, preserving the #409
no-post-window-top-up property. RPC failures skip that hotkey and keep
the prior value. Returns the count of miners updated."""
if not metagraph_hotkeys or contract_client is None:
return 0
updated = 0
for hotkey in metagraph_hotkeys:
if hotkey not in self.active_miners:
continue
try:
collateral = int(contract_client.get_miner_collateral(hotkey))
except Exception as e:
bt.logging.debug(f'EventWatcher reconcile: collateral read failed for {hotkey[:8]}: {e}')
continue
if collateral < 0 or self._latest_collateral(hotkey) == collateral:
continue
self._record_collateral_event(current_block, hotkey, collateral)
updated += 1
if updated:
bt.logging.info(
f'EventWatcher: reconciled collateral for {updated} active miner(s) '
f'from contract @ block {current_block}'
)
return updated

def hydrate_from_db(self) -> None:
"""Rebuild every in-memory mirror from state.db. Called on warm restart
when the persisted cursor is within one scoring window of head — the
Expand Down Expand Up @@ -974,11 +1017,14 @@ def apply_busy_delta(self, block_num: int, hotkey: str, delta: int, swap_id: Opt
self.busy_events.append(BusyEvent(hotkey=hotkey, delta=delta, block=block_num))
self.state_store.insert_busy_event(block_num, hotkey, delta, swap_id)

def _latest_collateral(self, hotkey: str) -> int:
"""Last known collateral for ``hotkey`` (0 if no event has fired)."""
def _latest_collateral(self, hotkey: str) -> Optional[int]:
"""Last known collateral for ``hotkey``, or ``None`` if no event has
fired. ``None`` means *unknown* (we never observed a baseline), which
callers must not conflate with a known zero — applying a fee/slash
delta against an unknown baseline would fabricate a spurious 0."""
history = self.collateral_events_by_hotkey.get(hotkey)
if not history:
return 0
return None
return history[-1].collateral_rao

def _record_collateral_event(self, block_num: int, hotkey: str, collateral_rao: int) -> None:
Expand All @@ -999,8 +1045,21 @@ def _apply_collateral_delta(self, block_num: int, hotkey: str, delta_rao: int) -
"""Apply a signed delta against the last-known collateral. Used for
fee (``confirm_swap``) and slash (``timeout_swap``) deductions that
``apply_collateral_penalty`` silently makes without emitting a
``CollateralWithdrawn``. Clipped at zero."""
``CollateralWithdrawn``. Clipped at zero.

With no known baseline the delta is meaningless — ``prior`` would be a
fabricated 0, so ``0 + (-fee)`` clips to 0 and permanently pins the
miner at zero collateral, dropping them from crown via the capacity /
can_fund gate. Skip instead; ``reconcile_collateral_from_contract``
and the next genuine CollateralPosted/Withdrawn establish the baseline,
and the scoring gate fails open while collateral is unknown."""
prior = self._latest_collateral(hotkey)
if prior is None:
bt.logging.debug(
f'EventWatcher: skipping collateral delta {delta_rao} for {self._label(hotkey)} '
f'@ block {block_num} — no known baseline (would fabricate 0)'
)
return
self._record_collateral_event(block_num, hotkey, prior + delta_rao)

def prune_old_events(self, current_block: int) -> None:
Expand Down
10 changes: 10 additions & 0 deletions allways/validator/forward.py
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,16 @@ async def forward(self: Validator) -> None:
enforce_swap_timeouts(self, tracker, uncertain_swaps)

if due_for_scoring(self.block, self.last_scored_block, self.initial_scoring_done):
# Resync active miners' collateral to on-chain truth before scoring so
# a drifted/absent baseline can't drop an active miner from crown via
# the capacity / can_fund gate. Writes at the current block only, so it
# never retroactively rescales capacity already earned in this window.
try:
self.event_watcher.reconcile_collateral_from_contract(
self.block, list(self.metagraph.hotkeys), self.contract_client
)
except Exception as e:
bt.logging.warning(f'forward: collateral reconcile failed: {e}')
score_and_reward_miners(self)
self.initial_scoring_done = True
bt.logging.info('forward: scoring done')
Expand Down
21 changes: 17 additions & 4 deletions allways/validator/scoring.py
Original file line number Diff line number Diff line change
Expand Up @@ -294,6 +294,8 @@ def calculate_miner_rewards(self: Validator) -> Tuple[np.ndarray, Set[int]]:
distributed=distributed,
recycled=recycled,
weighting_traces=weighting_traces,
min_swap_rao=min_swap_amount,
max_swap_rao=max_swap_amount,
)

if storage_enabled:
Expand Down Expand Up @@ -577,8 +579,14 @@ def can_fund(hotkey: str, rate: float) -> bool:
# Boundary-squat per-block gate: a miner whose own rate forces a TAO
# leg larger than their collateral_at_block earns no crown for that
# block. Cascades to the next-best rate via crown_holders_at_instant.
# Fail open when collateral is *unknown* (no event ever recorded):
# absent != zero. The contract auto-deactivates anyone below
# min_collateral, so an active miner always holds enough; treating a
# missing baseline as 0 would silently drop them from crown.
if hotkey not in collaterals:
return True
min_leg = min_executable_tao_leg(rate, from_chain, to_chain, min_swap_rao, max_swap_rao)
return min_leg == 0 or collaterals.get(hotkey, 0) >= min_leg
return min_leg == 0 or collaterals[hotkey] >= min_leg

def credit_interval(interval_start: int, interval_end: int) -> None:
duration = interval_end - interval_start
Expand Down Expand Up @@ -607,7 +615,9 @@ def credit_interval(interval_start: int, interval_end: int) -> None:
split = duration / len(holders)
for hk in holders:
crown_blocks[hk] = crown_blocks.get(hk, 0.0) + split
cap = capacity_factor(collaterals.get(hk, 0), max_swap_rao)
# Unknown collateral (no event recorded) → capacity 1.0, matching
# can_fund's fail-open. Only a known value scales capacity down.
cap = capacity_factor(collaterals[hk], max_swap_rao) if hk in collaterals else 1.0
cap_weighted_blocks[hk] = cap_weighted_blocks.get(hk, 0.0) + split * cap

def apply_event(event: ReplayEvent) -> None:
Expand Down Expand Up @@ -704,9 +714,12 @@ def can_fund(
) -> bool:
# Mirror the scoring path's boundary-squat gate so the live table
# never credits a holder whose collateral can't fund their own
# smallest legal leg, which the ledger drops.
# smallest legal leg, which the ledger drops. Fail open on unknown
# collateral (absent != zero) to match the scoring path exactly.
if hotkey not in collaterals:
return True
min_leg = min_executable_tao_leg(rate, from_chain, to_chain, min_swap_amount, max_swap_amount)
return min_leg == 0 or collaterals.get(hotkey, 0) >= min_leg
return min_leg == 0 or collaterals[hotkey] >= min_leg

holders = crown_holders_at_instant(
rates,
Expand Down
72 changes: 64 additions & 8 deletions allways/validator/scoring_trace.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,14 @@
import bittensor as bt
import numpy as np

from allways.chains import canonical_pair
from allways.constants import (
CREDIBILITY_MAX_TIMEOUTS,
CREDIBILITY_RAMP_OBSERVATIONS,
RECYCLE_UID,
TAO_TO_RAO,
)
from allways.utils.rate import min_executable_tao_leg

if TYPE_CHECKING:
from allways.validator.scoring import DirectionTrace
Expand Down Expand Up @@ -72,6 +74,8 @@ def log_scoring_trace(
distributed: float,
recycled: float,
weighting_traces: Optional[Dict[str, 'WeightingTrace']] = None,
min_swap_rao: int = 0,
max_swap_rao: int = 0,
) -> None:
hotkeys = self.metagraph.hotkeys
recycle_uid = RECYCLE_UID if RECYCLE_UID < len(rewards) else 0
Expand Down Expand Up @@ -112,8 +116,24 @@ def log_scoring_trace(
f' uid={uid} hotkey={hk[:8]}.. crown_blk={crown_blk:.0f} sr={sr:.3f}{extras} reward={crown_reward:.3f}'
)

# Collateral as-of window_start mirrors the scoring replay's starting
# state, so the non-earner diagnosis can tell "excluded by collateral"
# from "genuinely outbid". Absent hotkey == unknown (fail-open), per the
# gate in scoring.py.
collaterals = dict(self.event_watcher.get_miner_collaterals_at(window_start))
lines.extend(
non_earner_lines(self, window_start, window_end, rewards, success_rates, direction_traces, recycle_uid)
non_earner_lines(
self,
window_start,
window_end,
rewards,
success_rates,
direction_traces,
recycle_uid,
collaterals,
min_swap_rao,
max_swap_rao,
)
)

if recycled > 0:
Expand All @@ -136,7 +156,11 @@ def non_earner_lines(
success_rates: Dict[str, float],
direction_traces: Dict[Tuple[str, str], DirectionTrace],
recycle_uid: int,
collaterals: Optional[Dict[str, int]] = None,
min_swap_rao: int = 0,
max_swap_rao: int = 0,
) -> List[str]:
collaterals = collaterals or {}
ever_active = set(self.event_watcher.get_active_miners_at(window_start))
for e in self.event_watcher.get_active_events_in_range(window_start, window_end):
if e['active']:
Expand All @@ -155,7 +179,9 @@ def non_earner_lines(
if not latest_rates and hk not in ever_active:
continue
sr = success_rates.get(hk, 1.0)
reason = diagnose_non_earner(hk, latest_rates, sr, ever_active, direction_traces)
reason = diagnose_non_earner(
hk, latest_rates, sr, ever_active, direction_traces, collaterals, min_swap_rao, max_swap_rao
)
out.append(f' uid={uid} hotkey={hk[:8]}.. crown_blk=0 reason="{reason}" sr={sr:.3f}')
if len(out) >= NON_EARNER_LINE_CAP:
break
Expand All @@ -168,16 +194,46 @@ def diagnose_non_earner(
sr: float,
ever_active: Set[str],
direction_traces: Dict[Tuple[str, str], DirectionTrace],
collaterals: Optional[Dict[str, int]] = None,
min_swap_rao: int = 0,
max_swap_rao: int = 0,
) -> str:
"""Best-effort reason a miner earned no crown. Direction-aware: tao→btc is
lower-rate-wins, btc→tao higher-wins, so "outbid" only fires when the
miner's own rate is genuinely worse than the winner's. A rate that is at
least as good as the winner's but still earned nothing was excluded by the
capacity / can_fund collateral gate — report that, not "outbid"."""
collaterals = collaterals or {}
if not latest_rates:
return 'no_rate_posted'
if hotkey not in ever_active:
return 'not_active_during_window'
if sr <= 0:
return 'credibility_zero' # zero observations OR all-timeout history
parts = [
f'{direction[0]}→{direction[1]}: own={own:g} vs best={direction_traces[direction].best_rate:g}'
for direction, own in latest_rates.items()
if direction in direction_traces and direction_traces[direction].best_rate > 0
]
return 'outbid (' + '; '.join(parts) + ')' if parts else 'no_competing_winner'

outbid_parts: List[str] = []
for (from_c, to_c), own in latest_rates.items():
trace = direction_traces.get((from_c, to_c))
if trace is None or trace.best_rate <= 0:
continue
best = trace.best_rate
canon_from, _ = canonical_pair(from_c, to_c)
lower_wins = from_c != canon_from
competitive = own <= best if lower_wins else own >= best
if not competitive:
outbid_parts.append(f'{from_c}→{to_c}: own={own:g} vs best={best:g}')
continue
# Rate is at least as good as the winner's, yet earned nothing — a
# qualification gate dropped this miner. Collateral is the usual cause.
if hotkey not in collaterals:
return f'unknown_collateral ({from_c}→{to_c}: own={own:g} beats/ties best={best:g}, no baseline)'
min_leg = min_executable_tao_leg(own, from_c, to_c, min_swap_rao, max_swap_rao)
have = collaterals[hotkey]
if min_leg > 0 and have < min_leg:
return (
f'insufficient_collateral ({from_c}→{to_c}: have={have / TAO_TO_RAO:g}t need={min_leg / TAO_TO_RAO:g}t)'
)
# Competitive and funded — lost to a tie split, busy, or active-flag timing.
return f'competitive_but_unfilled ({from_c}→{to_c}: own={own:g} vs best={best:g})'

return 'outbid (' + '; '.join(outbid_parts) + ')' if outbid_parts else 'no_competing_winner'
10 changes: 10 additions & 0 deletions neurons/validator.py
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,16 @@ def __init__(self, config=None):
metagraph_hotkeys=list(self.metagraph.hotkeys),
contract_client=self.contract_client,
)
# Heal collateral on startup: a warm restart hydrates from state.db and
# does no contract reads, so any baseline that drifted/zeroed (e.g. a
# fee/slash delta applied without a baseline) would persist and keep the
# miner out of crown. Reconcile active miners against the contract now.
try:
self.event_watcher.reconcile_collateral_from_contract(
self.block, list(self.metagraph.hotkeys), self.contract_client
)
except Exception as e:
bt.logging.warning(f'startup collateral reconcile failed: {e}')

# Separate subtensor/contract/providers for axon handlers (thread safety).
# axon_lock serialises every call on axon_subtensor's websocket so two
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "allways"
version = "1.0.8"
version = "1.0.9"
description = "Allways - Universal Transaction Layer: Trustless cross-chain swaps on Bittensor Subnet 7"
license = "MIT"
requires-python = ">=3.10,<3.15"
Expand Down
Loading