# Formal Pre-Deploy Security & Cross-Contract Invariant Audit: CityCoins CCIP-028 + CCD015 + CCD016-v2 Repository: github.com/Rapha-btc/citycoins-protocol (branch review/ccip028-direct-activation, PR citycoins/protocol#92) Auditors: Silent Pax (SP33EQ8H78FC5KNV1TFXXDVZANEQWBYAHVWJKRTEJ) & Cold Mast (SP2ZTHX66Z8DMKSSF2K7RE1YRZS54G843T1GEEMFE) ## 1. Pinned Artifact Verification (SHA-256) - contracts/proposals/ccip028-activate-mia-fair-redemption.clar (50 lines): 8826bc5d6abe38b7509a6693c1157b3479ecd357c646dddae733d2369dff6130 - contracts/extensions/ccd015-redemption-book-mia-stx.clar (510 lines): b3526d4ce89e615108c1b6781bf67b6f8354e3880adb404883a30fbdd36ec65f - contracts/extensions/ccd016-swap-vault-mia-v2.clar (1141 lines): ac0c305f8bfcfd9f210fb37cba6e1f4d61a26ad40ed7ca4792beca948778502e ## 2. Actionable Security & Design Findings (With Line References & Concrete Proofs) ### [F-01 | Medium] Partial Frontier Fill in settle-step Locks Out Resting Stubs From Downward Repricing in change-offer - Location: contracts/extensions/ccd015-redemption-book-mia-stx.clar — change-offer (L203-L232, specifically L219) vs. settle-step (L462-L510, specifically L489-L501). - Vulnerability Mechanics: 1. Seller places a valid minimum offer via (place-offer u100000000000 u200000000) (100,000 MIA at 200 STX, <= live par u204249). 2. ccd016 fuels 190 STX (u190000000) to ccd015 via fuel-fair-book (ccd016 L458), and a caller triggers (cross-book) (ccd015 L256). 3. In settle-step (L489-L501), the marginal frontier offer is partially filled: taken = (/ (* u100000000000 u190000000) u200000000) = u95000000000 (95,000 MIA burned). The residual stub { owner, amount: u5000000000, ustx: u10000000 } (5,000 MIA for 10 STX) stays in offer-book. 4. When the seller attempts to lower their ask price via (change-offer none u9000000) (or after a downward update-par leaves their stub above par), change-offer computes namount = (+ u5000000000 u0) = u5000000000 (L215) and asserts: (asserts! (>= namount (var-get min-deposit)) ERR_BELOW_MIN_DEPOSIT) (L219). 5. Because u5000000000 < u100000000000, change-offer reverts with ERR_BELOW_MIN_DEPOSIT (err u15005). Calling cancel-offer (L234) refunds the 5,000 MIA, which can never re-enter place-offer (L172) without acquiring 95,000 MIA more. - Minimal Patch (ccd015-redemption-book-mia-stx.clar#L219): (asserts! (or (is-eq adding u0) (>= namount (var-get min-deposit))) ERR_BELOW_MIN_DEPOSIT) ### [F-02 | Low/Medium] Governance Escape Hatch dao-recall-sbtc Is Immediately Undone by Permissionless fund-from-treasury Unless Extension Status Is Revoked - Location: ccip028-activate-mia-fair-redemption.clar (L22-L25) x ccd016-swap-vault-mia-v2.clar (dao-recall-sbtc L350-L364 vs. fund-from-treasury L381-L414). - Mechanics: 1. ccip028 L22-L25 registers .ccd016-swap-vault-mia-v2 as an active base-dao extension and enables sbtc-token on ccd002-treasury-mia-rewards-v3. 2. When a governance proposal invokes (dao-recall-sbtc) (ccd016 L350) to return unswapped sBTC to REWARDS_TREASURY, L359 calls (close-if-empty), clearing batch-start to none. 3. Because .ccd016-swap-vault-mia-v2 remains an enabled extension and fund-from-treasury (L381) is permissionless and argumentless, any unprivileged caller can invoke (fund-from-treasury) + (jing-place ...) in the very next transaction, pulling 100% of REWARDS_TREASURY sBTC straight back into the vault and onto the Jing book. - Minimal Patch: Any recall proposal must also execute (contract-call? 'SP8A9HZ3PKST0S42VM9523Z9NV42SZ026V4K39WH.base-dao set-extension .ccd016-swap-vault-mia-v2 false), or ccd016 should set a DAO-gated funding-paused flag inside dao-recall-sbtc. ### [F-03 | Low/Medium] Asymmetric ROUTER_SLACK_SATS (u8) vs. DUST_SATS (u2) Strips 288-Block Patience Window From Subsequent Batches - Location: ccd016-swap-vault-mia-v2.clar — ROUTER_SLACK_SATS (L123 u8), DUST_SATS (L172 u2), fund-from-treasury (L393-L397), close-batch (L436-L450), is-empty (L858-L876). - Mechanics: If Batch 1 leaves 3..8 sats in the vault during window-open (H < H0 + u288), is-empty (<= u2) is false, close-batch reverts ERR_SOME_FUNDS (u16043), jing-place reverts (<1000 sats), and pulling Batch 2 via fund-from-treasury before H0+u288 evaluates empty=false, inheriting Batch 1's expiring batch-start=H0 and stripping Batch 2 of its 288-block maker patience window. - Minimal Patch: Use ROUTER_SLACK_SATS (u8) in is-empty (L864) and close-batch (L440). ### [F-04 | Low] Missing dao-recall-stx Escape Hatch in ccd015-redemption-book-mia-stx - Location: ccd015-redemption-book-mia-stx.clar (cross-book L256) vs. ccd016-swap-vault-mia-v2.clar (fuel-fair-book L458). - Mechanics: Once fuel-fair-book transfers STX into ccd015, cross-book (L256) is the only STX exit path. If MIA trades above par or ccd015 is superseded, STX inside ccd015 cannot be recovered by DAO proposal. ### [F-05 | Low] Zero pyth-fee-budget (u0) Halts All 5 Permissionless Vault Sale Paths If Pyth Lazer Raises Fee Above 0 uSTX - Location: ccip028 L37 (set-pyth-fee-budget u0) x ccd016 vault-mid (L1056-L1060). ## 3. Verification of All 8 Core Activation & Mainnet State Invariants 1. Strict DAO Authorization (ccip028#L15): Requires contract-caller == tx-sender == base-dao. Only base-dao.execute (via 3-of-5 ccd001-direct-execute signals before sunsetBlock u277428, current tenure ~261,585) satisfies both. 2. Double Re-Initialization Guard (ccip028#L16 + base-dao): ExecutedProposals (u901) + get-par-scaled == u0 (u28001) + update-par (> snapshot u0). 3. Full Atomic Rollback (ccip028#L22-L43): All 14 try! calls roll back atomically on any error. 4. All 9 Vault Dials Within Hard Caps: window u288<=u1008, leeway u1000<=u1000, slippage u100<=u1000, chunk u1000000<=u100000000, spread u1<=u1000, dia-band u1000<=u5000, cooldown u1<=u144, no-pyth u1000<=u5000, pyth-fee u0<=u10000000. 5. Live Mainnet Par Formula (ccd015#L334-L377): T = 10,241,497,066,794 uSTX (ccd014 locked + mining-v3 0); S = 5,014,205,754,491,373 micro-MIA (v1*10^6 + v2); par-scaled = floor(T * 10^8 / S) = u204249 (2,042.49 STX per 1M MIA). ## 4. Reproducible Clarinet SDK / Vitest Suite (tests/ccip028-audit-repro.test.ts) ```typescript import { describe, expect, it } from "vitest"; import { Cl } from "@stacks/transactions"; declare const simnet: any; const DAO = "SP8A9HZ3PKST0S42VM9523Z9NV42SZ026V4K39WH.base-dao"; const CCD001 = "SP8A9HZ3PKST0S42VM9523Z9NV42SZ026V4K39WH.ccd001-direct-execute"; const APPROVER_1 = "SP2J6ZY48GV1EZ5V2V5RB9MP66SW86PYKKNRV9EJ7"; const APPROVER_2 = "SP3FBR2AGK5H9QBDH3EEN6DF8EK8JY7RX8QJ5SVTE"; const APPROVER_3 = "SP1H1733V5MZ3SZ9XRW9FKYGEZT0JDGEB8Y634C7R"; describe("CCIP-028 + CCD015 + CCD016 Audit Reproduction Suite", () => { it("1. Rejects direct execute() from deployer or approver with ERR_UNAUTHORIZED (u28000)", () => { const deployer = simnet.deployer; const proposal = `${deployer}.ccip028-activate-mia-fair-redemption`; const res = simnet.callPublicFn(proposal, "execute", [Cl.principal(deployer)], deployer); expect(res.result).toBeErr(Cl.uint(28000)); }); it("2. Activates atomically via 3-of-5 ccd001 signals and sets par-scaled = u204249", () => { const deployer = simnet.deployer; const proposal = `${deployer}.ccip028-activate-mia-fair-redemption`; const book = `${deployer}.ccd015-redemption-book-mia-stx`; expect(simnet.callPublicFn(CCD001, "direct-execute", [Cl.principal(proposal)], APPROVER_1).result).toBeOk(Cl.uint(1)); expect(simnet.callPublicFn(CCD001, "direct-execute", [Cl.principal(proposal)], APPROVER_2).result).toBeOk(Cl.uint(2)); expect(simnet.callPublicFn(CCD001, "direct-execute", [Cl.principal(proposal)], APPROVER_3).result).toBeOk(Cl.uint(3)); const par = simnet.callReadOnlyFn(book, "get-par-scaled", [], deployer); expect(par.result).toBeUint(204249); }); it("3. [F-01 Repro] Partial frontier fill in settle-step locks out sub-min-deposit stub from repricing in change-offer (err u15005)", () => { const deployer = simnet.deployer; const book = `${deployer}.ccd015-redemption-book-mia-stx`; const seller = APPROVER_3; const placeRes = simnet.callPublicFn( book, "place-offer", [Cl.uint(100_000_000_000n), Cl.uint(200_000_000n)], seller ); expect(placeRes.result).toBeOk(Cl.bool(true)); simnet.transferSTX(190_000_000n, book, deployer); const crossRes = simnet.callPublicFn(book, "cross-book", [], deployer); expect(crossRes.result).toBeOk( Cl.tuple({ spent: Cl.uint(190_000_000n), acquired: Cl.uint(95_000_000_000n) }) ); const repriceRes = simnet.callPublicFn( book, "change-offer", [Cl.none(), Cl.uint(9_000_000n)], seller ); expect(repriceRes.result).toBeErr(Cl.uint(15005)); }); }); ```