Skip to content

Reservations don't mark a miner busy in crown-time scoring #326

@kal0528

Description

@kal0528

Severity: Critical — emissions exploit, zero completed swaps required.
Component: allways/validator/event_watcher.py, crown-time scoring.

Location

allways/validator/event_watcher.py:461-477 — the
MinerReserved branch updates the reservation index but never calls apply_busy_delta.
Only SwapInitiated flips a miner to busy.

The contradiction

The scoring module's own docstring states reservations end crown credit
(allways/validator/scoring.py:150-159):

ACTIVE applies first because the on-chain active flag is the per-miner tell-all.
Then BUSY (reservation ends crown for that miner), then RATE.

But the busy stream is wired only to swap events
(allways/validator/event_watcher.py:442-505):

Contract event apply_busy_delta
MinerReserved ❌ no change
ReservationCancelled ❌ no change
SwapInitiated ✅ +1
SwapCompleted ✅ −1
SwapTimedOut ✅ −1

Meanwhile the contract (smart-contracts/ink/lib.rs:597-598)
and the axon handler (allways/validator/axon_handlers.py:339-341)
both refuse a new reservation while reserved_until >= current_block — i.e., a reserved
miner is actually unavailable, but the scorer doesn't know it.

Why this is critical

Crown-time replay (allways/validator/scoring.py:262-278)
credits the rate-leader who is "not busy." During the window between MinerReserved and
SwapInitiated — up to RESERVATION_TTL_BLOCKS = 50 ≈ 10 min,
per allways/constants.py:125 — the rate-leading miner:

  1. cannot actually accept any new reservation (contract gate),
  2. still passes qualifies() in crown_holders_at_instant (not in busy),
  3. therefore keeps full crown credit and excludes the runner-up.

If the reservation expires without a SwapInitiated (user never proves the source tx), the
contract emits no event at lapse — the row is cleared lazily on the next reserve. So
busy_count never sees the lifecycle either way.

Exploit

A miner colludes with a throwaway "user" account that reserves them every ~50 blocks and
never sends the source tx. The miner:

  • posts the best rate,
  • gets locked in MinerReserved for ~10 min, no busy delta in scoring,
  • is still the rate-leader-and-not-busy → earns the full per-direction pool share,
  • reservation lapses, repeat.

Net: a miner can sweep the ('btc','tao') or ('tao','btc') pool with zero completed
swaps
. success_rate even defaults to 1.0 for miners with no outcomes
(allways/validator/scoring.py:136-144), so
sr**SUCCESS_EXPONENT is 1.0 — no penalty.

Test gap

None of the existing tests exercise this path — every busy test in
tests/test_scoring_v1.py drives state through SwapInitiated / SwapCompleted /
SwapTimedOut only. tests/test_reservation_index.py covers the index but not the busy
stream.

Suggested fix sketch

In event_watcher.apply_event:

  • MinerReservedapply_busy_delta(block_num, miner, +1) (and remember the
    reserved_until so the busy can be released on lapse).
  • ReservationCancelledapply_busy_delta(block_num, miner, -1).
  • SwapInitiated → either keep +1 (carrying the reservation's busy forward) or emit
    −1/+1 atomically; current SwapCompleted / SwapTimedOut −1 stays.
  • Add a synthetic -1 busy event at reserved_until + 1 for expired-without-initiate
    reservations (the contract emits nothing on lazy expiry, so the watcher has to
    synthesize it).

Acceptance criteria

  • A MinerReserved event marks the miner busy in event_watcher from block_num until
    the reservation resolves (SwapInitiated, ReservationCancelled, or lapse at
    reserved_until + 1).
  • Bootstrap path replays in-flight reservations identically (no double-count when an
    active reservation row is seeded at startup and its MinerReserved is later replayed),
    mirroring the existing bootstrapped_swap_ids guard at
    event_watcher.py:454-459.
  • New test in tests/test_scoring_v1.py (or a new file) that drives the exploit:
    rate-leader reserved for 50 blocks with no SwapInitiated, asserts crown credit goes
    to the runner-up for the interval.
  • apply_busy_delta never goes negative on any reservation lifecycle (covered by the
    existing guard at event_watcher.py:546-551,
    but the new transitions should be ordered so the guard never fires in normal flow).

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions