apeir online / reports

ccd016-swap-vault-mia-v2

Case study: a paid AIBTC audit bounty, September 2026.

Target: ccd016-swap-vault-mia-v2.clar (v0.3.0), a CityCoins DAO extension that sells the DAO's sBTC rewards for STX on the Jing v6 order book. Source-only review.
Found: one Medium (anyone can re-arm the sale window after every elapse, so the liquidation phase is never reached), one Low (a DIA oracle outage burns the whole window), and a list of invariants checked and found correct.
Outcome: won, best of 4 submissions. Paid 10,500 sats on 15 Sep 2026. payment tx · AIBTC bounty

The contract

The vault holds the DAO's sBTC rewards. It has two exits. It sells sBTC for STX and sends the STX to the ccd015 redemption book, or a DAO proposal sends the sBTC back to the rewards treasury.

The sale runs on a clock. Funding opens a window of 288 burn blocks (about two days). While the window is open, anyone can rest the sBTC on the Jing book as a maker order (jing-place). Once the window has elapsed, the liquidation phase starts: anyone can bring the sBTC home (jing-reclaim) or sell it through the router (router-swap).

Scope was the one contract at branch feat/ccd015-redemption-book (84451ea+), read against the project's decision record and its two coverage runs (61/61 and 58/61). The findings use only the vault's own code and the documented deposit / cancel / swap signatures of the Jing market.

Finding 1 — Medium: start-clock re-arms the window for free

Liveness and griefing. The bounty's invariant C asked for "start-clock or fund-from-treasury re-arming a window … elapsed never reached". This is that case. One call every ~2 days keeps the liquidation phase out of reach forever.

Where

start-clock (public, anyone can call it) and open-window (private):

(define-public (start-clock)
  (begin
    (asserts! (not (is-idle)) ERR_NO_FUNDS)
    (asserts! (not (window-open)) ERR_WINDOW_OPEN)
    (open-window)
    (ok (unwrap-panic (var-get batch-start)))))

(define-private (open-window)
  (if (window-open) true (var-set batch-start (some burn-block-height))))

start-clock checks two things: the vault is not idle, and no window is open. After a window elapses, window-open is false, so the second check passes. As long as one sat is still in the vault, resting on the book, or parked, is-idle is false, so the first check passes too. Nothing checks that any new sBTC arrived since the batch opened.

The contract header says: "That is the whole clock: one height, moved only by funding, only when no window is open." The code does not enforce that. start-clock moves the height with no funding at all.

Failing case (one caller, no capital, no proposal)

  1. fund-from-treasury at burn height H opens a batch: batch-start = H, window = 288.
  2. The community calls jing-place. Then one of the normal things happens: a taker fills part; or the mid dips under the floor and the peg switches off (refloor is DAO-only); or bids sit at the mid and the market refuses the peg (u1016). Each leaves more than 0 sats in the vault or on the book at H+288. This is the normal case. The README says router-swap exists because "the market did not absorb the size".
  3. At H+288 the window elapses. Before anyone gets jing-reclaim or router-swap in (or right after a reclaim, before the router-swap), the griefer calls start-clock. It succeeds: batch-start = H+288, and window-open is true again for 288 more blocks.
  4. jing-reclaim, router-swap, router-swap-split and jing-take all asserts! (window-elapsed), so they fail with ERR_WINDOW_OPEN (u16031) for another two days.
  5. Repeat step 3 every 288 blocks.

Cost to the griefer: one contract call per ~48 hours. The vault never reaches a liquidation it can complete. If the peg is switched off under its floor, the batch is frozen until a proposal unsticks it (jing-refloor, or set-window-blocks + dao-reclaim + dao-recall-sbtc). That is the "two humans in the loop" that v2 was built to remove.

Second path: fund-from-treasury

The same re-arm works through funding. Anyone can transfer 1 sat of sBTC to REWARDS_TREASURY (a plain SIP-010 transfer; receiving needs no allowlist) and then call fund-from-treasury. It passes (> amount u0) and calls open-window. So removing start-clock alone does not close the bug.

Why the existing tests miss it

Both simulations elapse the window "by proposal + block advance" and go straight to reclaim, router-swap, recall, idle. No step calls start-clock or fund-from-treasury on an elapsed vault that is not idle.

It also happens without a griefer

router-swap returns the unsold part to the vault. Any community member who then calls start-clock, a public and documented entry, restarts a two-day patience phase on the leftover, with the leftover's peg floor still stale. The header documents leftovers riding along with new funding as intended. The code lets it happen with zero new sats.

Fix (minimal)

Track the batch's funded size, and only open a window on new sBTC:

(define-data-var batch-sats uint u0) ;; sats known at last open-window

(define-private (total-sats)
  (let ((cycle (contract-call? JING_MARKET get-current-cycle)))
    (+ (sbtc-balance)
       (contract-call? JING_MARKET get-token-x-deposit cycle current-contract)
       (contract-call? JING_MARKET get-token-x-parked current-contract))))

(define-private (open-window)
  (if (window-open)
    true
    (begin (var-set batch-sats (total-sats))
           (var-set batch-start (some burn-block-height)))))

(define-public (start-clock)
  (begin
    (asserts! (not (is-idle)) ERR_NO_FUNDS)
    (asserts! (not (window-open)) ERR_WINDOW_OPEN)
    (asserts! (> (total-sats) (var-get batch-sats)) ERR_NO_BUDGET) ;; new sats only
    (open-window)
    (ok (unwrap-panic (var-get batch-start)))))

In fund-from-treasury, also require amount >= (get min-token-x (contract-call? JING_MARKET get-min-deposits)). Then dust cannot re-arm the window, while a real weekly reward always can.

Alternative: only auto-open on funding when batch-start is none or the vault was idle. An elapsed leftover must then be liquidated before a new patience phase starts, which is what the liquidation phase is for.

Tests proposed for the project's harness

For simulations/stxer-ccd016-v2-coverage.js, after "window elapses by proposal + block advance" and before "reclaim by anyone":

The bounty asked for a source-only audit. These steps were proposed for the project to add; the report does not claim they were run.

Finding 2 — Low: a DIA outage burns the window

Liveness, partly documented. current-mid is on the path of jing-place, jing-refloor, jing-take, router-swap and router-swap-split. With dia-band-bps > 0 (default 1000), the call reverts if get-value is none (ERR_ORACLE_DIA), if the value is 0 (ERR_INVALID_PRICE), or if the timestamp is older than 2 hours (ERR_ORACLE_STALE).

jing-reclaim does not need the oracle, so the sBTC is never stuck. But the sale is stuck until a proposal sets the band to 0. The README acknowledges this. What the report adds: fund-from-treasury opens a 288-block window whatever DIA's state. An outage at funding time uses up the whole patience phase with nothing resting, and the batch then goes straight to liquidation at the floor.

Fix: have fund-from-treasury and start-clock refuse to open a window while get-dia-price errors and the band is above 0 (the sBTC just waits in the vault). Expose dia-ok in get-status.

Checked and found correct

What this shows about how we review

Original report: public gist (AIBTC submission mu0zoma224a949991206, 14 Sep 2026).

Want a review like this? Quick review: 20,000 sats for one contract up to ~400 lines. Full review: 60,000 sats for a contract set up to ~1,500 lines. You pay after you have read the report. Details and contact: apeir online.