Close parser-poison free-rider hole + harden sentinel-rate handling#436
Merged
Conversation
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.
0f13832 to
dc31404
Compare
anderdc
approved these changes
May 31, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 BittensorCommitmentspallet has no delete extrinsic, butset_commitmentis overwrite-only — a single follow-up tx puts the miner in the "vanished from poll" state. Todayrefresh_miner_ratesonly emits a 0-terminator inside its per-direction loop over admitted pairs, so a vanished pair leaves the prior positive rate as the latest instate_store. Scoring reads the ledger, finds340, callsis_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_commitmentsgain optionalmin_swap_rao/max_swap_raokwargs. Defaults are 0/0 (permissive, matchingis_executable_rate). Validator passes cached bounds; CLI callers stay unchanged.refresh_miner_ratesgains a second sweep after its per-direction loop: any(hotkey, from, to)previously > 0 inlast_known_ratesbut 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_rateshydrateslast_known_ratesfrom 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_reserveandhandle_miner_activateget defense-in-depth executability checks.handle_swap_confirmis intentionally left alone (the reservation-pin path already handles bounds-shift correctly).alw view ratesdrops unexecutable rows with a footer count.alw view minerskeeps them but decorates with✗.alw swap quoteadds the "every miner is sentinel" summary branch.alw swap nowpre-filters before showing the ranked table.read_unexecutable_commitmentshelper 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_deactivateis consensus-sensitive and lands separately. Until then, a parser-poisoned or sentinel miner staysactiveon-chain — they just stop earning crown, stop being reservable, and stop appearing inalw view rates. Operators canvote_deactivatemanually if needed.Test plan
pytest tests/— all pass (634)ruff format && ruff checkcleanview rateshides them,view minersshows✗,swap quotelabels unexecutable,handle_swap_reserverejects."x"): confirm next poll emits 0-terminator and scoring stops crediting at the terminator block.