fix(validator): close crown pin when a reservation expires without a swap#467
Merged
Conversation
…swap A reservation pin freezes a miner's crown rate so it keeps earning crown at its committed rate while reserved, even if it bumps its live quote (the bump-after-pin loophole closure). The pin is closed only by a pin-end event, emitted on SwapInitiated/SwapCompleted/SwapTimedOut or a fresh MinerReserved. When a reservation simply expires (its reserved_until TTL lapses with no swap) no event fires: the contract emits nothing on natural expiry, and purge_expired_reservation_pins() deletes the reservation_pins row but never touches reservation_pin_events, the table the crown replay overlays. Pruning deliberately preserves the latest pin event per (hotkey, direction) as an anchor, so a dangling 'start' persists indefinitely. The miner keeps earning crown at the pinned rate with no live reservation until it next reserves or swaps. Observed in production: reservations expiring without a swap left miners pinned for up to ~88 minutes with no live reservation. Add ValidatorStateStore.get_expired_reservation_pins() and ContractEventWatcher.expire_stale_reservation_pins(), called from the forward loop in place of the bare purge. For each expired pin it emits a pin-end at reserved_until + 1 (crediting crown through the reservation's last live block, then stopping) before purging the row, reusing the existing RESERVED_END replay path. Idempotent: _emit_reservation_pin_ends only closes directions whose latest event is a 'start', and the row is purged afterward.
entrius
approved these changes
Jun 9, 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.
Problem
Crown eligibility uses a reservation-pin overlay: when a miner is reserved, its rate is pinned (
reservation_pin_eventsstart) so it keeps earning crown at its committed rate even if it bumps its live quote to junk (the bump-after-pin loophole closure). A pin is closed only by a pin-endevent, which fires onSwapInitiated/SwapCompleted/SwapTimedOutor when the miner is reserved again.When a reservation simply expires — its
reserved_untilTTL lapses with no swap — nothing closes the pin:purge_expired_reservation_pins()deletes thereservation_pinsrow but never touchesreservation_pin_events, the table the crown replay overlays;prune_reservation_pin_events()deliberately preserves the latest event per(hotkey, direction)as an anchor, so a danglingstartpersists indefinitely.Result: the miner keeps earning crown at the pinned rate with no live reservation until it next reserves or swaps, while its live quote can show junk no real taker would touch.
Production evidence
In a ~26h window there were 7
EXPIREDreservations (reserved, never swapped), all from the same operator cluster. Dangling-pin windows where the miner had no live reservation but stayed pinned:Fix
ValidatorStateStore.get_expired_reservation_pins()— lists pins whosereserved_until < current_block(read before purge).ContractEventWatcher.expire_stale_reservation_pins()— for each expired pin, emits a pin-endatreserved_until + 1(crediting crown through the reservation's last live block, then stopping), reusing the existing, testedRESERVED_ENDreplay path, then purges the row.forward.pycalls it in place of the barepurge_expired_reservation_pins(), before scoring in the same forward pass.Idempotent:
_emit_reservation_pin_endsonly closes directions whose latest event is astart, and the row is purged afterward, so a re-run emits nothing further. Activates exactly where the old purge did (both gate oncurrent_block_fn), so no behavior change when bounds aren't wired.Out of scope
The wash-trade / self-deal volume + credibility findings from the same investigation are not addressed here —
swap_outcomesrecords no counterparty, so they're unfilterable from the event stream without a contract change. Separate follow-up.Tests
test_state_store.py—get_expired_reservation_pinsreturns only expired rows; no-op withoutcurrent_block_fn.test_event_watcher.py— expired reservation emits a pin-endper direction atreserved_until + 1and purges the row; a live reservation is left untouched; the sweep is idempotent.test_scoring_v1.py— end-to-end: after expiry the pinned rate stops earning crown and the live rate takes over.Full suite green (
uv run pytest), ruff lint + format clean.