Skip to content

fix(validator): close crown pin when a reservation expires without a swap#467

Merged
entrius merged 2 commits into
testfrom
fix/reservation-pin-expiry-crown
Jun 9, 2026
Merged

fix(validator): close crown pin when a reservation expires without a swap#467
entrius merged 2 commits into
testfrom
fix/reservation-pin-expiry-crown

Conversation

@anderdc

@anderdc anderdc commented Jun 9, 2026

Copy link
Copy Markdown
Collaborator

Problem

Crown eligibility uses a reservation-pin overlay: when a miner is reserved, its rate is pinned (reservation_pin_events start) 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-end event, which fires on SwapInitiated/SwapCompleted/SwapTimedOut or when the miner is reserved again.

When a reservation simply expires — its reserved_until TTL lapses with no swap — nothing closes the pin:

  • the contract emits no event on natural expiry;
  • purge_expired_reservation_pins() deletes the reservation_pins row but never touches reservation_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 dangling start persists 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 EXPIRED reservations (reserved, never swapped), all from the same operator cluster. Dangling-pin windows where the miner had no live reservation but stayed pinned:

miner stale-pin window
92 ~88 min, and separately ~74 min
141 ~41 min
143 ~12 min
54 ~3 min

Fix

  • ValidatorStateStore.get_expired_reservation_pins() — lists pins whose reserved_until < current_block (read before purge).
  • ContractEventWatcher.expire_stale_reservation_pins() — for each expired pin, emits a pin-end at reserved_until + 1 (crediting crown through the reservation's last live block, then stopping), reusing the existing, tested RESERVED_END replay path, then purges the row.
  • forward.py calls it in place of the bare purge_expired_reservation_pins(), before scoring in the same forward pass.

Idempotent: _emit_reservation_pin_ends only closes directions whose latest event is a start, and the row is purged afterward, so a re-run emits nothing further. Activates exactly where the old purge did (both gate on current_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_outcomes records no counterparty, so they're unfilterable from the event stream without a contract change. Separate follow-up.

Tests

  • test_state_store.pyget_expired_reservation_pins returns only expired rows; no-op without current_block_fn.
  • test_event_watcher.py — expired reservation emits a pin-end per direction at reserved_until + 1 and 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.

anderdc added 2 commits June 9, 2026 14:48
…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 entrius merged commit b458acd into test Jun 9, 2026
3 checks passed
@entrius entrius deleted the fix/reservation-pin-expiry-crown branch June 9, 2026 20:06
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