Skip to content

Preserve the synchronous reserve-time pin (fix quote↔settlement rate divergence)#451

Open
anderdc wants to merge 1 commit into
testfrom
fix/initiate-quote-vs-pin-rate-invariant
Open

Preserve the synchronous reserve-time pin (fix quote↔settlement rate divergence)#451
anderdc wants to merge 1 commit into
testfrom
fix/initiate-quote-vs-pin-rate-invariant

Conversation

@anderdc
Copy link
Copy Markdown
Collaborator

@anderdc anderdc commented Jun 4, 2026

Problem

A user reserved a swap at one rate but settled at a worse one — silently, well beyond the protocol fee. Concretely, swap 2405 (btc→tao): reserved/quoted at rate 370 (to_amount = 315,610,000), but the swap settled at rate 280 (deliveredAmount = 236,451,600) — the user received ~24% less than the swap's own destAmount, with no slippage check in between.

Root cause

The reservation pin is written twice:

  1. handle_swap_reserve writes a synchronous pin at the instant it reads the miner's commitment to validate the user's quote. This is the rate the on-chain to_amount was reserved against — i.e. correct.
  2. event_watcher.record_reservation_pin then re-reads the commitment at the reservation's on-chain inclusion block and overwrites the pin (INSERT OR REPLACE, keyed by miner).

When the miner moves its rate between the handler's read and the inclusion of vote_reserve, those two reads see different rates. For 2405 the miner was oscillating 370↔280 every ~2 blocks, and the inclusion block landed on a 280 tick:

8329917  370
8329918  370
8329919  280   ← reservation inclusion block → watcher pinned 280
8329920  370
8329922  280

At handle_swap_confirm, the divergence is baked in: to_amount (370-based) comes from the on-chain reservation, while rate comes from the (overwritten) pin (280). expected_swap_amounts — the single source of truth for both miner delivery and validator verification — keys off rate, so the miner delivered the 280 amount and the validator confirmed it. Nothing anywhere compares the 370 amount to the 280 rate.

Fix

The synchronous pin is the authoritative reserve-time rate. Make the watcher's read a pure backfill: only write the settlement pin when none already exists. This stops the overwrite that introduced the stale rate, so confirm settles at the rate the user reserved against.

  • Scoring-overlay pin events are intentionally left reading the canonical block — that's the separate "pinned-rate-during-reservation" scoring workstream, not settlement.
  • A NOTE / TODO(contract-v2, multi-validator) documents the scope: this relies on the single-validator invariant (exactly one authoritative synchronous pin). With multiple validators, each synchronous pin is read at its own instant and isn't deterministic across the set; the correct fix then is to bind (reserve_block, rate) into the reservation at quorum and verify rate == CommitmentOf(reserve_block) within the user's slippage band — which needs the reserve hash + Reservation struct to carry the rate, i.e. a smart-contract iteration. Until v2 lands, we back off to the synchronous pin.

Why this is safe now

There is currently a single whitelisted validator, so the synchronous pin is always present and authoritative — preferring it fully closes the divergence. The change degrades gracefully: if the synchronous write ever failed, the watcher still backfills from the inclusion block (current behavior).

Tests

  • New test_existing_synchronous_pin_is_not_overwritten — asserts a pre-existing 370 pin survives a MinerReserved re-read that sees 280.
  • Existing test_miner_reserved_writes_expected_pin still passes (fresh state → backfill writes the pin).
  • Full suite: 663 passed.

Not in scope

  • The multi-validator/contract-v2 fix (documented in the TODO).
  • The miner's rate-oscillation behavior itself (a scoring/farming concern — the oscillation is what makes the two reads disagree; this PR removes the payoff for forward swaps but doesn't penalize the behavior).
  • Retroactive relief for swap 2405's user.

The event watcher re-read the miner's commitment at the reservation's
on-chain inclusion block and overwrote the pin the reserve handler had
already written. When a miner moves its rate between the handler's
quote-validation read and the inclusion of vote_reserve, the re-read
captures a different rate than the one the on-chain to_amount was reserved
against. That settlement rate then diverges from the reserved amount and
the user is short-changed at confirm (swap 2405: reserved at 370, pinned to
280, settled ~24% low).

The synchronous pin is written at the instant the user's quote was
validated, so it is the authoritative reserve-time rate. Make the watcher's
read a pure backfill: only write the settlement pin when none exists.

A NOTE/TODO documents that this relies on the single-validator invariant
(one authoritative synchronous pin) and that the multi-validator fix is to
bind (reserve_block, rate) into the reservation at quorum and verify it
against CommitmentOf(block) within slippage, which requires a contract
iteration.

Scoring-overlay pin events are intentionally left reading the canonical
block (separate scoring workstream).
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.

1 participant