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:
- cannot actually accept any new reservation (contract gate),
- still passes
qualifies() in crown_holders_at_instant (not in busy),
- 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:
MinerReserved → apply_busy_delta(block_num, miner, +1) (and remember the
reserved_until so the busy can be released on lapse).
ReservationCancelled → apply_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).
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
MinerReservedbranch updates the reservation index but never callsapply_busy_delta.Only
SwapInitiatedflips a miner to busy.The contradiction
The scoring module's own docstring states reservations end crown credit
(allways/validator/scoring.py:150-159):
But the busy stream is wired only to swap events
(allways/validator/event_watcher.py:442-505):
apply_busy_deltaMinerReservedReservationCancelledSwapInitiatedSwapCompletedSwapTimedOutMeanwhile 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 reservedminer 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
MinerReservedandSwapInitiated— up toRESERVATION_TTL_BLOCKS = 50≈ 10 min,per allways/constants.py:125 — the rate-leading miner:
qualifies()incrown_holders_at_instant(not inbusy),If the reservation expires without a
SwapInitiated(user never proves the source tx), thecontract emits no event at lapse — the row is cleared lazily on the next reserve. So
busy_countnever 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:
MinerReservedfor ~10 min, no busy delta in scoring,Net: a miner can sweep the
('btc','tao')or('tao','btc')pool with zero completedswaps.
success_rateeven defaults to1.0for miners with no outcomes(allways/validator/scoring.py:136-144), so
sr**SUCCESS_EXPONENTis1.0— no penalty.Test gap
None of the existing tests exercise this path — every busy test in
tests/test_scoring_v1.pydrives state throughSwapInitiated/SwapCompleted/SwapTimedOutonly.tests/test_reservation_index.pycovers the index but not the busystream.
Suggested fix sketch
In
event_watcher.apply_event:MinerReserved→apply_busy_delta(block_num, miner, +1)(and remember thereserved_untilso the busy can be released on lapse).ReservationCancelled→apply_busy_delta(block_num, miner, -1).SwapInitiated→ either keep+1(carrying the reservation's busy forward) or emit−1/+1atomically; currentSwapCompleted/SwapTimedOut−1stays.-1busy event atreserved_until + 1for expired-without-initiatereservations (the contract emits nothing on lazy expiry, so the watcher has to
synthesize it).
Acceptance criteria
MinerReservedevent marks the miner busy inevent_watcherfromblock_numuntilthe reservation resolves (
SwapInitiated,ReservationCancelled, or lapse atreserved_until + 1).active reservation row is seeded at startup and its
MinerReservedis later replayed),mirroring the existing
bootstrapped_swap_idsguard atevent_watcher.py:454-459.
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 goesto the runner-up for the interval.
apply_busy_deltanever goes negative on any reservation lifecycle (covered by theexisting guard at event_watcher.py:546-551,
but the new transitions should be ordered so the guard never fires in normal flow).