Skip to content

Close parser-poison free-rider hole + harden sentinel-rate handling#436

Merged
anderdc merged 10 commits into
testfrom
feat/block-sentinel-rates
May 31, 2026
Merged

Close parser-poison free-rider hole + harden sentinel-rate handling#436
anderdc merged 10 commits into
testfrom
feat/block-sentinel-rates

Conversation

@LandynDev
Copy link
Copy Markdown
Collaborator

Summary

Primary: closes a new free-rider exploit not covered by v1.0.6.

A miner posts a sane rate (e.g. 340), builds credibility, then overwrites their commitment with "x" (or any parser-rejected string: wrong version, NaN, malformed, unsupported chain). The Bittensor Commitments pallet has no delete extrinsic, but set_commitment is overwrite-only — a single follow-up tx puts the miner in the "vanished from poll" state. Today refresh_miner_rates only emits a 0-terminator inside its per-direction loop over admitted pairs, so a vanished pair leaves the prior positive rate as the latest in state_store. Scoring reads the ledger, finds 340, calls is_executable_rate(340, ...)True (340 is a perfectly executable rate), credits the miner crown forever. They can never be reserved (live commitment read fails), so they free-ride emissions until deregistration.

v1.0.6's executability filter (PR #395/#420) does not catch this. That filter rejects rates whose value is unexecutable. The free-rider's stored rate is a sane, executable value — the bug is that the rate is stale and nobody emits a terminator when the commitment disappears.

Secondary: hardens sentinel-rate handling.

PR #395/#420 zeroed sentinel-rate posters' crown reward at the scoring layer. They still appear in alw view rates, get axon-pinged on reserves, burn RPC budget on every commitment poll, and have no defense-in-depth at the axon handlers. This PR drops them at the parser layer so they're invisible to the validator entirely.

Both exploits present identically to the validator — "admitted previously, no admitted pair this poll, stale positive rate in state_store" — so one sweep covers both.

What changes

  • parse_commitment_data / read_miner_commitment / read_miner_commitments gain optional min_swap_rao / max_swap_rao kwargs. Defaults are 0/0 (permissive, matching is_executable_rate). Validator passes cached bounds; CLI callers stay unchanged.
  • refresh_miner_rates gains a second sweep after its per-direction loop: any (hotkey, from, to) previously > 0 in last_known_rates but missing from this poll's admitted set gets a 0-terminator emitted. This is the free-rider fix — crown attribution stops at the next block whether the miner's commitment was parser-rejected for sentinel reasons or because they overwrote it with garbage.
  • bootstrap_miner_rates hydrates last_known_rates from persisted state before seeding admitted pairs so the same defense fires on the first poll after a restart that crossed a miner's parser-poison flip.
  • handle_swap_reserve and handle_miner_activate get defense-in-depth executability checks. handle_swap_confirm is intentionally left alone (the reservation-pin path already handles bounds-shift correctly).
  • alw view rates drops unexecutable rows with a footer count. alw view miners keeps them but decorates with . alw swap quote adds the "every miner is sentinel" summary branch. alw swap now pre-filters before showing the ranked table.
  • New read_unexecutable_commitments helper returns hotkeys whose commitment parses permissively but drops with bounds. Not wired into any caller in this PR — staged for the follow-up auto-deactivation PR.

Not in this PR

Auto-deactivation via vote_deactivate is consensus-sensitive and lands separately. Until then, a parser-poisoned or sentinel miner stays active on-chain — they just stop earning crown, stop being reservable, and stop appearing in alw view rates. Operators can vote_deactivate manually if needed.

Test plan

  • pytest tests/ — all pass (634)
  • ruff format && ruff check clean
  • Staging validator with a planted sentinel commitment: confirm view rates hides them, view miners shows , swap quote labels unexecutable, handle_swap_reserve rejects.
  • Staging validator with a planted parser-poison sequence (post sane rate, accrue events, post "x"): confirm next poll emits 0-terminator and scoring stops crediting at the terminator block.
  • No cross-validator divergence over a full scoring round.

LandynDev added 10 commits May 31, 2026 11:26
Adds optional min_swap_rao / max_swap_rao kwargs to parse_commitment_data,
read_miner_commitment, and read_miner_commitments. When supplied, any
positive rate that fails is_executable_rate drops the entire pair so the
validator never sees sentinel-rate posters. Defaults preserve CLI behavior.

Adds read_unexecutable_commitments helper returning hotkeys whose permissive
parse succeeds but bounded parse drops — staged for the follow-up
auto-deactivation PR; no live caller yet.
A miner who builds credibility on a sane rate and then overwrites their
commitment with parser-rejected garbage (wrong version, NaN, malformed,
unsupported chain) leaves a stale positive rate in state_store. Scoring
keeps crediting the dead rate until deregistration — they free-ride
emissions while being unreachable.

refresh_miner_rates now runs a second sweep after the per-direction loop:
any (hotkey, from, to) that was previously > 0 in last_known_rates but is
absent from this poll's admitted set gets a 0-terminator emitted to
state_store. Covers both parser-poisoned commitments and rates that just
dropped below executability bounds.

bootstrap_miner_rates hydrates last_known_rates from persisted state
before seeding admitted pairs so the same defense fires on the first
poll after a restart that crossed a miner's parser-poison flip.

Validator bounds_cache values are also threaded into read_miner_commitments
so the parser drops sentinel-rate pairs before they ever reach the loop.
handle_swap_reserve now checks is_executable_rate against the miner's
quoted rate using cached swap bounds before voting reserve. handle_miner_activate
threads the same bounds into its commitment read so a sentinel-rate poster
can't get re-activated.

handle_swap_confirm is intentionally untouched — the reservation pins the
rate at reserve block, and re-gating against shifted bounds at confirm time
would re-introduce the failure mode pinning was designed to prevent.
view rates filters rows whose neither direction is executable under
cached bounds and surfaces a count in the footer. view miners keeps
all rows (operator view) but decorates per-direction rate cells with
[red]N ✗[/red] when the rate is unexecutable.

swap quote adds an "every miner is sentinel" summary branch so the
copy matches the actual failure mode instead of suggesting the user
retry with a smaller amount.

swap now hoists the bounds read above the miner table and drops
miners whose rates fail is_executable_rate before sorting, so the
ranked picker never offers a sentinel as the default choice.
Codifies that scoring is per-window: bounds-tightening between scoring
rounds does not retroactively zero credit earned in the previous round.
The replay function is idempotent and only reads bounds for the window
it's invoked on, so prior rounds' results are preserved.
Per-row "unexecutable rate" status label already covers this case;
expecting every miner on the subnet to post a sentinel simultaneously
is not a realistic scenario worth a dedicated message.
check_swap_viability further down already rejects sentinel-rate miners
when their derived TAO leg fails bounds. The pre-filter was duplicate
defense for an unlikely scenario.
Validator-side fix (parser drop + terminator + scoring gate) removes
the incentive to post unexecutable rates: no crown, no reservations.
Surfacing them in view rates/miners adds code for a state miners are
no longer motivated to be in.
read_miner_commitments swallows transient RPC errors (ConnectionError /
TimeoutError) and returns []. Without a guard, a single websocket flake
would terminate every previously-positive miner.

If pairs is empty for any reason — RPC dead or genuinely no commitments —
skip the sweep. The next successful poll catches whatever genuinely
vanished. Stale positives persisting one extra poll is acceptable;
nuking every miner on a flake is not.
A miner can post a live, executable rate whose smallest in-band TAO leg
exceeds their collateral — winning crown but funding zero swaps.
Survives the stale-rate terminator (commitment is real) and the
executability filter (rate is technically routable).

Per-block gate inside replay_crown_time_window: a holder whose
collateral_at_block can't fund the smallest in-band TAO leg at their
rate is dropped, cascading to the next-best funded rate via
crown_holders_at_instant. Uses per-block collateral from the watcher's
CollateralEvent series, matching #409's no-snapshot semantics.

min_executable_tao_leg exposes the band math shared with is_executable_rate.
@LandynDev LandynDev force-pushed the feat/block-sentinel-rates branch from 0f13832 to dc31404 Compare May 31, 2026 16:33
@anderdc anderdc merged commit 14ac8b4 into test May 31, 2026
3 checks passed
@anderdc anderdc deleted the feat/block-sentinel-rates branch May 31, 2026 16:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants