# jing-btc-in-escrow-v4 — anyone can cancel any pending order

Target: `SPV9K21TBFAK4KNRJXF5DFP8N7W46G4V9RCJDC22.jing-btc-in-escrow-v4` (Stacks mainnet)
Review date: 2026-11-05
Scope: the v4 changes (arm keyed by `{txid, vout}`, `orders` status map, `parked` accounting)
Severity: Low (denial of service with a real, quantifiable loss of the user's fee)

## Live state I verified before writing

- `get-config` (call-read, block ~now): `owner = fee-recipient = SPV9K21…CJDC22`,
  `max-fee-sats = 5000`, `cancel-after-blocks = 30`, `lazer-fee-ustx = 0`, `paused = false`.
- `get-parked` = `u0` — no order is open at the time of writing, so this is a design defect
  reachable on the first real order, not a live theft in progress.

## The defect

`cancel` (line 291) is `define-public` with **no caller check at all**. Its only guard is the
clock:

```clarity
(define-public (cancel (txid (buff 32)) (vout uint))
  ...
  (asserts! (is-eq (get status order) STATUS_OPEN) ERR_NO_ORDER)
  (asserts! (>= stacks-block-height (get cancel-height order)) ERR_TOO_EARLY)
  (try! (pay-sbtc (get net order) (get user order)))
  (map-set orders key (merge order { status: STATUS_CANCELLED }))
  (var-set parked (- (var-get parked) (get net order)))
```

Line 303 (`>= cancel-height`) is the only gate. Every other state-changing entry point in the
contract is either `is-owner`, `is-keeper`, or self-authorizing (`arm` requires
`recipient == tx-sender`); `cancel` is the single public function that any principal can call
against any other principal's order.

## Why it costs the user money

1. `arm` charges the fee **at arm time**, before any swap is attempted (line 107-112), and it is
   sent to `fee-recipient` immediately. `net = amount - fee-sats` is what gets parked.
2. `cancel` refunds exactly `net` (line 304). The fee is not returned — it is already at
   `fee-recipient`.
3. The order key is `{txid, vout}` of the **BTC deposit**, and `map-insert` at line 113 is the
   anti-replay guard for the whole contract ("Orders are never deleted … `map-insert` blocks
   arming the same deposit twice"). `cancel` sets `STATUS_CANCELLED` but the key stays. A
   re-arm of the same deposit hits `ERR_DEPOSIT_USED` (u4217).
4. So a third party who cancels an order destroys the only key that deposit can ever be armed
   with. The user must make a **new Bitcoin deposit** (new BTC network fee) to try again, and
   the protocol fee they already paid is unrecoverable.

`cancel-after-blocks` is 30 (≈10 minutes at 3s blocks). Any order that is not executed inside
that window is cancellable by anyone, for the price of one Stacks transaction.

## Concrete scenario (numbers)

- User U deposits 0.001 BTC (100,000 sats) with recipient U; the registry records
  `{amount: 100000, recipient: U}` for `{txid T, vout 0}`.
- U pre-signs `arm-swap-sbtc-for-stx(T, 0, fee-sats=1000, slippage=100, min-out=…, arm-by=H+50)`.
- Keeper arms at height H: `net = 99,000`, `fee 1,000 → fee-recipient`, `parked += 99,000`,
  `cancel-height = H + 30`.
- At height H+30 the Pyth update the keeper needs is 40 seconds late (or the keeper is simply
  slow — it is a single operator-set principal, `set-keeper`, line 384).
- Attacker A calls `cancel(T, 0)` first. It succeeds: U is refunded 99,000 sats, the order is
  `CANCELLED`, `parked -= 99,000`.
- U's loss: the 1,000-sat fee (paid for a swap that never happened) plus the Bitcoin network fee
  of the original deposit, plus the retry. U cannot re-arm `{T,0}`: `arm` now fails at line 113
  with `ERR_DEPOSIT_USED` (u4217), regardless of who calls it.
- Attacker's cost: one Stacks tx. Attacker's gain: none — this is pure griefing, which is why it
  is Low rather than High, but it is repeatable against every order and it is undetectable in
  advance by the user.

## Fix

Bind cancellation to the principal that owns the funds, and let the owner still be rescued:

```clarity
(asserts! (or (is-eq tx-sender (get user order))
              (default-to false (map-get? keepers contract-caller)))
          ERR_NOT_OWNER)
```

If a permissionless escape hatch is a product requirement (so a user can always recover), then
make the cancelled key re-armable instead: key the anti-replay map on `{txid, vout, arm-count}`
or delete the order key on cancel after burning a separate `used-deposits` entry that only the
user can clear. Either fix removes the griefing without removing the user's exit.

## What I checked and ruled out

- **Arming someone else's deposit** — blocked: line 85 compares `(get recipient deposit)` against
  `tx-sender`, and the registry's `completed-deposits` map stores `{amount, recipient, …}`
  (verified against the live sbtc-registry source).
- **One deposit funding two orders** — blocked by `map-insert` on `{txid, vout}` (line 113).
  `v4`'s switch from `last-id` to the deposit key closes the v3 hole where `amount` was
  caller-supplied.
- **`parked` drift letting `sweep-sbtc` take user funds** — `arm` (+net) and `execute`/`cancel`
  (−net) are the only writers, each inside the transaction that moves the tokens, and
  `sweep-sbtc` (line 405) requires `amount + parked <= sbtc-held`. No path decrements `parked`
  without a matching outflow, and no path lets `sweep-sbtc` reach parked funds. `execute`'s
  `spent` is measured as a balance delta and bounded by `(asserts! (<= spent net) ERR_OVERSPENT)`,
  so a router that over-pulls reverts the whole call rather than draining a neighbour's order.
- **Stuck order** — `execute` is open while `block < cancel-height` and `cancel` from
  `block >= cancel-height`, so there is no height at which neither is callable.
- **Keeper abuse beyond the signed bounds** — `fee-sats`, `slippage-bps` and `min-out` are all
  inside the pre-signed `arm` payload and enforced at lines 88-90 / 195 / 209. The keeper's only
  free choice is *when* inside the window, which `min-out` bounds.
- **Status transitions** — `arm` inserts `OPEN`; `execute` and `cancel` both assert `OPEN` first,
  so `CANCELLED → EXECUTED` and `EXECUTED → CANCELLED` are unreachable.

## Reopen condition for this target

Any commit that adds an authorization check to `cancel`, changes the anti-replay key, or lets a
cancelled deposit be re-armed.
