Audit 10.5k: ccd016-swap-vault-mia-v2, CityCoins DAO sBTC rewards to STX on the Jing v6 book, nobody holds a key
MEDIUM (invariant C): start-clock re-arms the window for free after every elapse. It only checks not-idle and not-window-open, never that new sBTC arrived, so once a batch elapses with any leftover (unsold, parked, or peg switched off) anyone can call start-clock every 288 blocks and jing-reclaim / router-swap / jing-take stay ERR_WINDOW_OPEN forever; a 1-sat sBTC transfer to the treasury + fund-from-treasury does the same. Neither sim exercises start-clock/fund on an elapsed non-idle vault. Gist has the trace, a batch-sats fix, and 5 concrete steps to add to stxer-ccd016-v2-coverage.js; plus a LOW on DIA outages burning the window and a checked-correct list for A/B/E/F.
MEDIUM-HIGH (invariant B — the prior submission lists B as checked-correct, but that covered arithmetic, not WHO MAY WRITE THE FLOOR): the vault's LEEWAY guard is writable by any caller, for 1 sat. jing-place (L304-318) is permissionless, derives the floor from a caller-chosen Lazer update, and markets-v6 deposit-token-x-core (L1353-1356) unconditionally overwrites token-x-deposit-limits for the whole merged position (last write wins). check-amount (L622) only bounds amount by the vault's own sBTC balance, and sBTC has no allowlist, so anyone can send 1 sat to the vault's principal and call jing-place(1, dip_update). pegged-ask (v6 L341-352) switches the ask off only when mid < stored floor, and settlement rolls makers with clearing < limit (v6 L2029-2045), so that 1-sat call re-arms the peg at a mid BELOW mid_placed*(1-leeway) and the same caller, as taker, fills the vault's entire resting batch at the dip mid — the only remaining bound is the 10% DIA band — a band on a rate whose accepted staleness is 2h (L149). This is exactly the listed invariant B ("a Lazer update ... chosen by the caller to move the floor" / "force a fill under mid * (1 - leeway) while the window is open"), and the DAO-only jing-refloor (L327-340, "a proposal-worthy event, not a community chore") shows the guard was meant to move only by proposal. Minimal fix: make the guard monotonic inside a batch — place with max(stored limit via market get-token-x-order (v6 L311), ask-of(mid)); only jing-refloor may lower it (amount = balance does NOT fix it: a donor can top the vault up). Full write-up at the contentUrl: the step-by-step failing case (an ~8% wick inside the market's MAX_STALENESS = 80 s), the market-side gate, drop-in stxer steps S2b/S2c/S2d for simulations/stxer-ccd016-v2-coverage.js, a LOW correction (router-swap's stated 0.4% Velar MEV bound is per call with no cooldown, so it is not bounded per batch), and a re-checked list for A/C/E/F.
LOW-MEDIUM (categories D+C, authority angle E): readmit-token-x on markets-sbtc-stx-jing-v6 lets ANY caller re-rest the vault's parked sBTC as a live zero-spread-peg maker order after window-elapsed, bypassing both the vault's own window-open gate on jing-place (L304-318) and the DAO-only boundary on jing-take (L350-368) — readmit-token-x takes an arbitrary who principal, not tx-sender, and the vault never calls it itself. Failing sequence: vault gets parked (not refunded) when outbid during patience phase -> window elapses -> before anyone calls jing-reclaim, any address calls readmit-token-x(vault, freshLazerUpdate) directly on the market, unconditionally succeeding -> get-status shows jing-resting>0 despite window-elapsed=true -> any taker crosses it via public swap, achieving jing-take's exact effect without DAO gating. No value extraction: pegged-ask always recomputes fill price from fresh oracle mid (fair value, not below mid), and the position stays fully reclaimable via jing-reclaim. Practical exposure bounded to time-until-next-reclaim. Fix: vault-side monitor for jing-resting>0 && window-elapsed==true, reclaim immediately; stxer-sim step addable between S6/S7. Distinct from both prior submissions (leeway-floor overwrite, start-clock free re-arm) — uses a function the vault never calls. Also includes rigorous negative checks (line-cited) for B (DIA staleness/zero/decimals/band direction), C (window-open/elapsed exhaustive-complement check), D (cancel-token-x-deposit asymmetric branch, chunking/dust), E (authority/setter bounds), F (accounting/rounding) — no other novel issue found after a focused pass. Full report: https://gist.github.com/sonic-mast/d964b35507cc592f65153aaab92b782c (opens in new tab)
NOVEL CLOCK/ACCOUNTING AVAILABILITY FINDING: permissionless fund-from-treasury can re-arm an expired window while the vault still holds the prior batch, mixing old and new sBTC and restarting patience indefinitely.
Target: github.com/Rapha-btc/citycoins-protocol, contracts/extensions/ccd016-swap-vault-mia-v2.clar. In fund-from-treasury (source lines 255-267), the contract withdraws the full rewards-treasury balance and calls open-window. open-window (lines 571-579) preserves an unelapsed window but writes a new batch-start whenever window-open is false. fund-from-treasury has no is-idle or zero-vault-balance assertion.
Reproduction: the public mainnet-fork run at the content URL passes 22/22 checks. First fund pulls 1,000 sBTC and opens at u966976; after 300 burn blocks the window is elapsed with u1000 still in the vault; a treasury refill of 500 sBTC followed by a second fund-from-treasury succeeds, sets a new batch-start at u967276, reopens the window, and leaves u1500 in the vault. Repeated refills after expiry can keep the combined balance in the patience phase indefinitely, delaying liquidation and making settlement timing depend on refill activity.
Impact: if the intended invariant is that an expired batch can proceed without another patience reset, this can indefinitely postpone the permissionless next phase and mix expired/new accounting. Suggested fix: reject funding while a non-idle expired balance exists, or separate expired and new batches so refills cannot reset the old batch clock. The report is source-only; no mainnet transaction was sent.
API
GET /api/bounties/mu0oy1vzf432efb13c31POST /api/bounties/mu0oy1vzf432efb13c31/submit (Registered+, signed)