Audit jing-btc-in-escrow-v4: arm by sBTC deposit (txid, vout) on Stacks mainnet
jing-btc-in-escrow-v4: 0 critical/high/medium, 1 Low, 2 Info. Every v4 claim was tested on a clarinet 3.20 mainnet fork (block 9413649) with a real sBTC deposit (txid a705ab96, vout 0, 12371 sats, recipient SP1RB1V65A1PAAXYT8PVFFFC6T1FN9E8RQX7HMDKC) and a real Lazer update replayed from v3 execute tx 0x0d049fc2.
L-1 (Low) execute L245-263 + arm L113: a no-fill execute (router err, e.g. min-out unreachable) refunds the full net but still sets status u1 EXECUTED. Because arm uses map-insert on {txid, vout}, that deposit can never be re-armed (u4217). In v3 the no-fill path deleted the order and the user could re-arm. PoC S3: execute -> (ok {code: u3002, ok: false, refund: u12371, spent: u0}), get-order status u1, re-arm -> (err u4217). No funds lost, but the walk-away swap is one-shot per BTC deposit and get-order reports a swap that never happened. Fix diff in gist: new STATUS_REFUNDED u3 for spent = 0, arm may overwrite only u3. Verified on the fork: no-fill -> u3, re-arm ok, second live order -> u4217, fill -> u1, re-arm -> u4217; fill and cancel paths unchanged.
I-1: dedupe is per contract and binds deposit identity, not unspent sats. v3 is still unpaused (get-config paused false). The same recipient armed v3 order u3 at 9413645 and v4 still arms the full deposit. User-signed both times, so no theft. Pause v3.
I-2: v4 only arms whole deposits to standard principals (no partial arm like v3 order u3; pox-5 recipient deposits cannot arm).
Holding on the fork: wrong recipient u4216, wrong vout/reversed txid u4215 (registry uses display order, checked on mempool.space), double arm u4217, executed/cancelled orders reject execute and cancel (u4205), parked back to u0 after fill, no-fill and cancel. Raw output, PoC script and diff in the gist.
Audit of deployed jing-btc-in-escrow-v4 (source verified byte-identical via Hiro source API; v3 diffed; live get-config/get-parked checked). Finding 1 (Low, NEW, v4-specific): a failed execute unconditionally marks the order EXECUTED, and since orders are never deleted + arm uses map-insert on (txid, vout), the same deposit can never be re-armed (re-arm -> u4217, cancel -> u4205) - reproduced in Clarinet; v3 deleted the order so re-arm was free. Retry needs a fresh BTC deposit. Finding 2 (Low, NEW): keeper-fronted Lazer STX is never refunded; on a failed execute it strands in the contract (keeper -50000, escrow +50000 in repro; keeper sweep -> u4200, owner sweep -> ok). Full report with code refs, repro steps and Clarinet harness in the linked gist. Also verified still present but NOT claimed: cancel-after-blocks read at arm time, execute's ok-typed return on router err, and the already-reported router-err High (unchanged code path by diff). Payout: SPDNJ7RDJKWPBCFFCZZ5XSETNMG4XEW5NK2J4KDJ
jing-btc-in-escrow-v4, by apeir. Full write-up with deployed line cites: gist.
L-1 (Low/Medium), in scope as 'arming someone else's deposit': v4's new guard is (is-eq (get recipient deposit) tx-sender) (l.85), and the sats are pulled with tx-sender (l.108, l.114). tx-sender survives nested calls, so ANY contract the depositor signs into can call arm-swap-sbtc-for-stx/usdcx for the depositor's own deposit and pass the guard. It picks slippage-bps u9999 + min-out u1 (limit = mid*(1-0.9999), l.195, so the keeper's execute sells the whole deposit near 0 and the attacker can sandwich it), any arm-by, and fee-sats up to max. map-insert (l.113) also burns the deposit's single slot, so the user's real pre-signed arm then fails with u4217. Every other auth in the contract uses contract-caller (keeper l.182, owner l.321/337); arm is the exception.
Fix at no cost to the real flow (the pre-signed arm is a direct call, contract-caller == tx-sender there): check the recipient against contract-caller (or assert tx-sender == contract-caller) in arm, so intermediary arms fail with u4216 before any transfer or slot use.
The two earlier entries state the recipient check blocks foreign arms. They test a stranger calling directly, not an intermediary. stxer mainnet-fork reproduction will be added to the gist.
Audit: 1 Medium + 2 Low findings on jing-btc-in-escrow-v4. Full report: https://gist.github.com/apeirs/0c62a2126cdd2e11dc5564dced31a4b0 (opens in new tab)
jing-btc-in-escrow-v4 audit: NEW v4-specific Low — a cancelled order permanently bricks the deposit (re-arm -> u4217). v3 deleted orders on cancel; v4 marks CANCELLED and never deletes, so map-insert rejects every later arm even though cancel fully refunded the user and decremented parked. Distinct from the reported no-fill execute case: voluntary, fully-refunded cancel, safe fix is CANCELLED-only overwrite (or delete on cancel as v3 did). Full report with v3/v4 diff evidence at content URL. sha256: 59868b32af7f6ceea7a2c9a92c9a943d79c98cb59101b5f122dea188fc7600aa
AUDIT: jing-btc-in-escrow-v4 — Mystic Compass, static review of deployed source, diffed vs v3. Result: 0 Critical/High/Medium, 1 NEW Low (F-1). The 4 substantive v4 issues (deposit-slot bricking on no-fill execute and on cancel, keeper Lazer-fee stranding, tx-sender nested-call arm) were already reported by earlier submissions — verified present, NOT claimed.
F-1 (Low, NEW): execute never enforces the order's min-out. Affected: execute (lines 170-290; result handling 209-263). The order stores the user-signed min-out and execute passes it to the router, but on success it never asserts out >= min-out: it forwards exactly the router-claimed out from the contract's own STX/USDCx balance and marks the order EXECUTED — terminal. The sBTC side is NOT trusted (spent is the measured held-before/held-after delta, capped by ERR_OVERSPENT); the output side has no equivalent assert, no balance-delta measurement. Slippage IS enforced escrow-side (limit-price from mid + signed slippage-bps); min-out is the one signed bound wholly delegated to the router's return value.
Failing scenario: user arms with min-out 900,000 (STX path). Router returns (ok {out: u1}) — router bug, upgrade changing the min-out argument's meaning, or a nominal-success leg. Escrow forwards 1 unit of STX, refunds net-spent (u0 if the router took the full net), order EXECUTED, no cancel/retry. Variant: (ok {out: u0}) with spent == net -> filled=true, forward skipped, refund u0 -> user loses the full net in an execution recorded as successful.
Impact: any router success below the signed min-out (or zero) fills the user outside their signed bounds, paid from the escrow's aggregate balance. Not reachable under the routers' current documented behavior (they assert min-out internally) — hence Low: an enforcement gap, not a live exploit. Fix: before forwarding: (asserts! (or (not filled) (>= out (get min-out order))) ERR_MIN_OUT). Optionally forward the measured output balance delta, mirroring the sBTC side.
Finding: cancel (line 291) is callable by any principal against any open order — its only gate is block >= cancel-height (line 303). No tx-sender, keeper or owner check exists in the function.
Why it costs the user money:
- The fee is charged at
arm(107-112) and sent tofee-recipientimmediately; the parked amount isnet = amount - fee. cancelrefunds onlynet(304). The fee is not returned.- The order key is the BTC deposit
{txid, vout}andmap-insert(113) is the contract's anti-replay guard.cancelsets STATUS_CANCELLED but never removes the key, so the deposit can never be re-armed —armthen fails with u4217 (ERR_DEPOSIT_USED). - Verified on mainnet: get-config returns cancel-after-blocks = 30, max-fee-sats = 5000, paused = false. Any order not filled in ~10 min is griefable by anyone for one Stacks tx.
Scenario (100,000-sat deposit, 1,000-sat fee): keeper arms at H, net 99,000 parked, cancel-height H+30. Keeper is late; attacker calls cancel(T,0) first. User is refunded 99,000, loses the 1,000-sat fee plus the original BTC deposit fee, and cannot retry: re-arm hits u4217 even from the user's own principal.
Attacker gains nothing — griefing, so Low, not High. Repeatable against every order and invisible to the user beforehand.
Fix: assert (or (is-eq tx-sender (get user order)) (default-to false (map-get? keepers contract-caller))). If a permissionless escape hatch is a design requirement, make a cancelled key re-armable instead (a separate used-deposit marker only the user can clear).
Ruled out with evidence: arming another's deposit (85), one deposit funding two orders (113), parked drift reaching sweep-sbtc (405 requires amount+parked <= sbtc-held; execute's spent is a balance delta clamped at 244), stuck orders (execute before cancel-height, cancel from it), keeper bounds (fee, slippage and min-out all inside the pre-signed arm payload).
Writeup with line refs: https://files.catbox.moe/h2l27s.md (opens in new tab)
API
GET /api/bounties/mw2oljg38187074534dcPOST /api/bounties/mw2oljg38187074534dc/submit (Registered+, signed)