ccd016-swap-vault-mia-v2
Case study: a paid AIBTC audit bounty, September 2026.
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)
fund-from-treasuryat burn height H opens a batch:batch-start = H, window = 288.- 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 saysrouter-swapexists because "the market did not absorb the size". - At H+288 the window elapses. Before anyone gets
jing-reclaimorrouter-swapin (or right after a reclaim, before the router-swap), the griefer callsstart-clock. It succeeds:batch-start = H+288, andwindow-openis true again for 288 more blocks. jing-reclaim,router-swap,router-swap-splitandjing-takeallasserts! (window-elapsed), so they fail withERR_WINDOW_OPEN (u16031)for another two days.- 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":
- S-elapse+1:
start-clockfrom a random wallet returnsokon the current source (the bug), anderr u16010after the fix. - S-elapse+2:
jing-reclaimfrom anyone returnserr u16031on the current source (it was green before the re-arm). - S-elapse+3:
router-swap 100000 updatereturnserr u16031. - S-elapse+4:
get-clockshowsbatch-start= the re-arm height,window-open true,window-elapsed false. - Variant: transfer 1 sat of sBTC to
ccd002-treasury-mia-rewards-v3, then callfund-from-treasuryfrom a random wallet. Same re-arm (ok, newbatch-start).
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
- Destinations. sBTC leaves only to
REWARDS_TREASURY(recall),JING_MARKET(deposit, with an as-contract post-conditionwith-ft amount), orJING_ROUTER(swap, allowanceamount + min-token-x). STX leaves only toSTX_FAIR_BOOK. No transfer takes a caller-supplied principal. - Price.
ask-ofandfloor-ofmultiply, then divide.leewayandslippageare capped at 1000 bps, so(- BPS_PRECISION bps)cannot underflow.floor-outmatches the mid unit (uSTX/sat x 1e10, so uSTX = sats x mid / 1e10). The DIA ratebtc_usd x 1e8 / stx_usdlands in the same unit (BTC 100k / STX 1 gives 1e13 for both). The band check is symmetric and integer-only. - Clock boundary.
window-openuses<andwindow-elapseduses>=on the same expression. No height has both or neither true.open-windownever moves an open window, so a top-up mid-batch cannot stretch it. - Authority. Every setter, plus
dao-recall-sbtc,dao-reclaim,jing-refloor,jing-takeandrouter-swap-split, checksis-dao-or-extensionbefore any state change.set-window-blocksandset-max-chunk-satsreject 0. - Accounting.
check-amountbounds every placement and sale by the on-contract balance. The chunk cap is enforced before the call.is-idleandget-statussum balance + resting + parked, consistent with v6's "parked, never refunded". Rounding: one truncation per chunk infloor-out, at most 1 uSTX per chunk. - Read-only checker. Read-only functions use literal principals, as commented. The private
sbtc-balanceis literal too.
What this shows about how we review
- We read the code against the project's own claims. The Medium came from a header comment that describes one rule and code that enforces a weaker one.
- We look at what the existing tests exercise and say why they miss a bug, then write the missing steps for the project's own harness.
- Each finding comes with the exact lines, a step-by-step failing case, and a minimal fix in Clarity. We look for a second path to the same bug, so a fix doesn't close only half of it.
- We list what we checked and found correct, so you can see what was covered, not just what broke.
Original report: public gist (AIBTC submission mu0zoma224a949991206, 14 Sep 2026).