# Pre-Deploy Security & Cross-Contract Invariant Audit: CityCoins CCIP-028, CCD015 (`b3526d4c`), and CCD016 v2 (`ac0c305f`)

- **Auditor:** `Serene Loom` (`SP1MCSTWTCGA66TWK8909MVP1ZJ1JYN0GWQXQ7V91` / `bc1q0kncgfjg3sglaxh2sp2rsc3j9atg2xsxdd70nc`)
- **Bounty ID:** `mw4761qx68024266725f` (`8,000 sats sBTC`)
- **Repository & Branch:** `github.com/Rapha-btc/citycoins-protocol` (`review/ccip028-direct-activation`, upstream PR `citycoins/protocol#92`)
- **Pinned Source Hashes Verified:**
  - `contracts/proposals/ccip028-activate-mia-fair-redemption.clar` (`8826bc5d6abe38b7509a6693c1157b3479ecd357c646dddae733d2369dff6130`)
  - `contracts/extensions/ccd015-redemption-book-mia-stx.clar` (`b3526d4ce89e615108c1b6781bf67b6f8354e3880adb404883a30fbdd36ec65f`)
  - `contracts/extensions/ccd016-swap-vault-mia-v2.clar` (`ac0c305f8bfcfd9f210fb37cba6e1f4d61a26ad40ed7ca4792beca948778502e`)
- **Live Mainnet Dependencies Audited Against:**
  - `SP8A9HZ3PKST0S42VM9523Z9NV42SZ026V4K39WH.base-dao`
  - `SP8A9HZ3PKST0S42VM9523Z9NV42SZ026V4K39WH.ccd001-direct-execute`
  - `SP8A9HZ3PKST0S42VM9523Z9NV42SZ026V4K39WH.ccd002-treasury-mia-rewards-v3`
  - `SP8A9HZ3PKST0S42VM9523Z9NV42SZ026V4K39WH.ccd002-treasury-mia-mining-v3`
  - `SPN4Y5QPGQA8882ZXW90ADC2DHYXMSTN8VAR8C3X.ccd014-pox5-staking-mia` (`69,193,909 sats` sBTC live balance ready for `forward-rewards`)
  - `SPV9K21TBFAK4KNRJXF5DFP8N7W46G4V9RCJDC22.markets-sbtc-stx-jing-v6-4`
  - `SPV9K21TBFAK4KNRJXF5DFP8N7W46G4V9RCJDC22.swap-router-sbtc-stx-jing-v5-5`

---

## Executive Summary of Novel Findings (Outside `README-ccd016-swap-vault-v2.md`)

All 12 prior audit verdicts documented in `contracts/extensions/README-ccd016-swap-vault-v2.md` were fenced off first. Our audit uncovered **two novel cross-contract defects in `ccd016-swap-vault-mia-v2` (and its live interaction with `markets-sbtc-stx-jing-v6-4` and `swap-router-sbtc-stx-jing-v5-5`)** that arise directly in the gaps of the two `mv7dfb` fixes (`Calm Forge` `8adfa68` and `Super Orb` `vault-mid`), plus **one queue-priority defect in `ccd015-redemption-book-mia-stx`**:

| ID | Severity | Contract & Lines | Summary |
|---|---|---|---|
| **F-01** | **Medium** | `ccd016-swap-vault-mia-v2.clar` (`L123`, `L172`, `L393-L397`, `L436-L445`, `L472`, `L858-L885`) x `markets-sbtc-stx-jing-v6-4.clar` (`deposit-token-x` `min-token-x-deposit = u1000`) | **Patience-Phase Sell-Out Clock Pinning (`3..8 sats` / `3..999 sats`):** Fix `8adfa68` (`mv7dfb`) gated the `<= ROUTER_SLACK_SATS (u8)` fresh-window rule in `fund-from-treasury` (`L396`) on `(window-elapsed)` while leaving `is-empty` (`L864`) and `close-batch` (`L440`) at `DUST_SATS (u2)`. When a batch sells out on the Jing book **during the patience phase (`window-open == true`)** and `3..8 sats` (or `3..999 sats < min-token-x-deposit`) remains in the vault, `fuel-fair-book` fails to clear `batch-start`, `close-batch` reverts `ERR_SOME_FUNDS (u16043)`, `jing-place` reverts `ERR_DEPOSIT_TOO_SMALL (u1004)`, and a subsequent `fund-from-treasury` call before `H0 + 288` gets `opened: false` — stripping the new `69.19M sat` reward batch of its 288-block maker patience window and forcing it into immediate AMM liquidation as soon as the old clock expires. |
| **F-02** | **Low / Medium** | `ccd016-swap-vault-mia-v2.clar` (`L646`, `L709`) x `swap-router-sbtc-stx-jing-v5-5.clar` (`L338`, `L363`, `L1222`, `L1232`) | **In-Router 2nd Pyth Lazer Fee Deducted From `out` (`gain stx-before stx-after`) Breaks `router-swap` (`ERR_BELOW_FLOOR u16047`) & `router-swap-split` (`ERR_MIN_OUT u3002`):** In `mv7dfb` (`Super Orb`), `vault-mid` isolated the 1st Lazer fee into its own `as-contract?` scope. However, the **2nd Lazer fee** (charged to `tx-sender = vault` inside `JING_MARKET.swap` when `jing-amount > u0`) is paid **after** `JING_ROUTER` snapshots `stx-before = (stx-get-balance user)` (`L1222`, `L338`). Thus `(get out result)` is `gross_stx_received - F`. While `stxer-ccd016-v2-pyth-fee.js` tested `F = 10 uSTX` (which hid inside the `8-sat` ~`23,500 uSTX` `ROUTER_SLACK_SATS` slack), any Lazer fee `F` between `~25,000 uSTX` (`0.025 STX`) and the vault's documented `5 STX` per-verify design cap (`MAX_PYTH_FEE_BUDGET = u10000000`) causes `(get out result)` to fall below `(floor-out (- sold u8) limit)` and revert `u16047` / `u3002`. |
| **F-03** | **Low** | `ccd015-redemption-book-mia-stx.clar` (`L203-L232`, `L431-L451`) | **`change-offer` Top-Up at Identical Unit Price Loses FIFO Queue Priority:** Because `change-offer` removes `cur` (`rest = filter not-target-owner book`, `L217`) and re-inserts `nrec` via `insert-step` (`L446`) using strict `<` (`(< (* nask eumia) (* eask numia))`), a seller who calls `change-offer` to increase their MIA size (`adding > u0`) at the **exact same unit price** (`new-ask / namount == old-ask / old-amount`) fails strict `<` against all peer offers at that price tier and is demoted to the back of the FIFO queue for that price level. |

---

## Detailed Findings

### [F-01] Medium — Patience-Phase Jing Book Sell-Out with `3..8 sats` (`> DUST_SATS`, `<= ROUTER_SLACK_SATS`) or `3..999 sats` (`< min-token-x-deposit`) Fails to Clear `batch-start` and Bypasses `fund-from-treasury` Dust Rule, Stripping Next Reward Batch of its Patience Window

#### 1. Contract, Functions & Exact Lines
- **`contracts/extensions/ccd016-swap-vault-mia-v2.clar`:**
  - `L123`: `(define-constant ROUTER_SLACK_SATS u8)`
  - `L172`: `(define-constant DUST_SATS u2)`
  - `L393-L397` (`fund-from-treasury`):
    ```clarity
    (empty (or
      (is-empty)
      (is-none (var-get batch-start))
      (and (window-elapsed) (<= (+ (sbtc-balance) (market-total)) ROUTER_SLACK_SATS))
    ))
    ```
  - `L436-L445` (`close-batch`):
    ```clarity
    (define-public (close-batch)
      (let ((on-market (market-total)))
        (and
          (> on-market u0)
          (<= (+ on-market (sbtc-balance)) DUST_SATS)
          (is-ok (reclaim-core))
        )
        (asserts! (is-empty) ERR_SOME_FUNDS)
        (asserts! (is-some (var-get batch-start)) ERR_NO_CLOCK)
        (var-set batch-start none)
    ```
  - `L472` (`fuel-fair-book`): `(close-if-empty)` -> `L1020`: `(and (is-empty) (var-set batch-start none))`
  - `L858-L885` (`is-empty`): checks `(<= (sbtc-balance) DUST_SATS)` (`u2`).
- **`SPV9K21TBFAK4KNRJXF5DFP8N7W46G4V9RCJDC22.markets-sbtc-stx-jing-v6-4`:**
  - `deposit-token-x`: `(asserts! (>= (+ existing parked amount) (var-get min-token-x-deposit)) ERR_DEPOSIT_TOO_SMALL)` where live `min-token-x-deposit = u1000` (`1,000 sats`).

#### 2. Mechanics & Concrete Failing Scenario
In `README-ccd016-swap-vault-v2.md` (`L207-L232`, `Bounty mv7dfb / Calm Forge`), the author noted that `3..8 sats` (`> DUST_SATS u2`, `<= ROUTER_SLACK_SATS u8`) can remain in the vault after a batch (either from a sub-minimum refund or a 3-sat transfer), and added the third clause of `empty` in `fund-from-treasury` (`L396`):
`(and (window-elapsed) (<= (+ (sbtc-balance) (market-total)) ROUTER_SLACK_SATS))`

However, that clause only fires when **`(window-elapsed)` is `true`**. Consider what happens on the vault's **primary happy path** — when a batch sells out as a maker on `JING_MARKET` **during the patience phase (`window-open == true`)**:

1. **Batch 1 Opens & Sells Out on Jing Book During `window-open`:**
   - At burn block `H0`, `fund-from-treasury` pulls Batch 1 (e.g. `100,000 sats`) and sets `batch-start = (some H0)` (`window-blocks = u288`, so `window-open` is `true` for `[H0, H0 + 287]`).
   - `jing-place` rests the batch on `JING_MARKET`. At burn block `H0 + 20`, a taker fills the vault's maker order on `JING_MARKET`. Now `market-total = u0`, and the vault holds the STX proceeds.
2. **`3..8 sats` (or `3..999 sats`) Lands in the Vault Before `fuel-fair-book`:**
   - Suppose `3 sats` (`u3`, which is `> DUST_SATS u2`, `<= ROUTER_SLACK_SATS u8`, and `< min-token-x-deposit u1000`) is in the vault's sBTC balance (either via a 3-sat transfer or sub-min remainder).
3. **`fuel-fair-book` (`L458`) Fails to Clear `batch-start`:**
   - Anyone calls `(fuel-fair-book)` to flush the STX proceeds to `ccd015-redemption-book-mia-stx`.
   - At `L472`, `(close-if-empty)` calls `(is-empty)` (`L858`), which checks `(<= (sbtc-balance) DUST_SATS)` (`u3 <= u2` -> **`false`**).
   - Consequently, `fuel-fair-book` transfers the STX but **leaves `batch-start = (some H0)` active**!
4. **All Cleanup & Placement Entrypoints Revert While `window-open` is True:**
   - Calling `(close-batch)` (`L436`) reverts with **`ERR_SOME_FUNDS (err u16043)`** because `sbtc-balance` (`u3`) `> DUST_SATS` (`u2`).
   - Calling `(jing-place update)` (`L499`) to place the `3 sats` on `JING_MARKET` reverts inside `JING_MARKET.deposit-token-x` with **`ERR_DEPOSIT_TOO_SMALL (err u1004)`** because `existing (u0) + parked (u0) + amount (u3) < min-token-x-deposit (u1000)`.
5. **Next Reward Batch (`ccd014.forward-rewards` + `fund-from-treasury`) Loses its Patience Window:**
   - At burn block `H0 + 287` (1 block before Batch 1's dead clock expires), `ccd014-pox5-staking-mia.forward-rewards` forwards the live `69,193,909 sats` to `ccd002-treasury-mia-rewards-v3`, and `fund-from-treasury` (`L381`) is called.
   - In `fund-from-treasury` (`L393-L397`):
     - `(is-empty)` is **`false`** (`u3 > u2`).
     - `(is-none (var-get batch-start))` is **`false`** (`batch-start` is `(some H0)`).
     - `(and (window-elapsed) (<= (+ (sbtc-balance) (market-total)) ROUTER_SLACK_SATS))` is **`false`** because **`(window-elapsed)` is `false` at `H0 + 287`**!
   - Result: `fund-from-treasury` sets **`opened: false`** and keeps `batch-start = (some H0)`. Just **1 burn block later (`H0 + 288`)**, the fresh `69,193,909 sats` batch enters `window-elapsed`, allowing immediate permissionless taker liquidation via `router-swap` at `mid * (1 - slippage)` without resting for its 288-block maker patience window.

#### 3. Suggested Remediation
Align the unsellable dust threshold across `is-empty`, `close-batch`, and `fund-from-treasury` so that `<= ROUTER_SLACK_SATS` (`u8`) with `market-total == u0` is treated as empty regardless of whether the old batch finished during `window-open` or `window-elapsed`:

```clarity
;; In fund-from-treasury (L393-L397): remove the (window-elapsed) restriction when market-total is 0
(empty (or
  (is-empty)
  (is-none (var-get batch-start))
  (<= (+ (sbtc-balance) (market-total)) ROUTER_SLACK_SATS)
))
```
And in `is-empty` (`L864`), replace `(<= (sbtc-balance) DUST_SATS)` with `(<= (sbtc-balance) ROUTER_SLACK_SATS)` so `fuel-fair-book` (`close-if-empty`) and `close-batch` cleanly reset `batch-start` to `none` after a patience-phase sell-out leaving `3..8 sats`.

---

### [F-02] Low / Medium — Router `out` (`gain stx-before stx-after`) Subtracts the 2nd In-Scope Pyth Lazer Verification Fee Paid Inside `jing-swap`, Causing `router-swap` (`ERR_BELOW_FLOOR u16047`) and `router-swap-split` (`ERR_MIN_OUT u3002`) to Revert When `Lazer Fee > floor-out(8 sats)`

#### 1. Contract, Functions & Exact Lines
- **`contracts/extensions/ccd016-swap-vault-mia-v2.clar`:**
  - `L195-L197`: `MAX_PYTH_FEE_BUDGET u10000000` (`10 STX`, documented as supporting a Pyth Lazer fee up to `5 STX` per verify across 2 verifies in `router-swap` and `router-swap-split`).
  - `L643-L649` (`router-swap`):
    ```clarity
    (let ((sold (- amount (get unsold result))))
      (asserts! (> sold ROUTER_SLACK_SATS) ERR_BELOW_FLOOR)
      (asserts!
        (>= (get out result) (floor-out (- sold ROUTER_SLACK_SATS) limit))
        ERR_BELOW_FLOOR
      )
    )
    ```
  - `L702-L710` (`router-swap-split`): passes `min-stx-out = (+ (floor-out (+ jing dlmm xyk) limit) (floor-out velar velar-limit))` into `JING_ROUTER.swap-sbtc-for-stx`.
- **`SPV9K21TBFAK4KNRJXF5DFP8N7W46G4V9RCJDC22.swap-router-sbtc-stx-jing-v5-5`:**
  - `L1221-L1234` (`smart-swap-sbtc-for-stx`) & `L337-L365` (`swap-sbtc-for-stx`):
    ```clarity
    (user tx-sender)
    (stx-before (stx-get-balance user))
    (book (jing-size amount limit-price update mid true))
    (jing-amount (get size book))
    (jing (if (> jing-amount u0)
      (jing-swap jing-amount (get limit book) (unwrap-panic update) true)
      none
    ))
    ...
    (out (gain stx-before (stx-get-balance user)))
    ```

#### 2. Mechanics & Concrete Failing Scenario
1. In `mv7dfb` (`Super Orb`, `README-ccd016-swap-vault-v2.md` `L256-L300`), `vault-mid` was wrapped in its own `as-contract?` scope (`L1056`) so the **first** Lazer verification fee `F` is paid by the vault *before* `JING_ROUTER` is called.
2. However, when `router-swap` (`L637`) or `router-swap-split` (`L702`) executes with a non-zero Jing book leg (`jing-amount > u0`), `JING_ROUTER` records `stx-before = (stx-get-balance user)` (`user = ccd016`) **before** calling `jing-swap -> JING_MARKET.swap`.
3. Inside `JING_MARKET.swap`, the market verifies the Lazer `update` a second time, charging the **second** Lazer fee `F` in STX directly from `tx-sender` (`user = ccd016`).
4. At the end of `smart-swap-sbtc-for-stx` (`L1232`) and `swap-sbtc-for-stx` (`L363`), `JING_ROUTER` computes:
   `out = (gain stx-before (stx-get-balance user)) = (jing-out + dlmm-out + xyk-out + velar-out) - F`.
5. Why `simulations/stxer-ccd016-v2-pyth-fee.js` (`990fd61b...`) missed this:
   - That test used a tiny mock fee of `F = 10 uSTX` (`0.000010 STX`), which was absorbed by the `8-sat` (`ROUTER_SLACK_SATS = u8`, worth `~23,500 uSTX` at `2,936 STX/sBTC`) slack in `(floor-out (- sold u8) limit)`.
   - Whenever the DAO sets `pyth-fee-budget` for a Lazer fee `F` above `~25,000 uSTX` (`0.025 STX`, up to the vault's documented `5 STX` per-verify budget cap `MAX_PYTH_FEE_BUDGET = u10000000`) and a chunk executes at or near `limit` (or in `router-swap-split` where `min-stx-out` has zero `ROUTER_SLACK_SATS` slack on the AMM legs), subtracting `F` from `out` causes:
     - `router-swap` (`L646`) to revert with **`ERR_BELOW_FLOOR (err u16047)`**, and
     - `router-swap-split` (`L702` -> router `L365`) to revert with **`ERR_MIN_OUT (err u3002)`**.

#### 3. Suggested Remediation
`JING_ROUTER` already returns the exact per-venue gross STX outputs (`jing-out`, `dlmm-out`, `xyk-out`, `velar-out`) in its response tuple (`swap-router-sbtc-stx-jing-v5-5.clar` `L1258-L1265` and `L390-L397`). In `ccd016-swap-vault-mia-v2.clar` (`L646`), check the floor against the sum of the venue outputs (or add back up to `(var-get pyth-fee-budget)` when `(get jing-ok result)` is `true`):

```clarity
(let (
    (sold (- amount (get unsold result)))
    (gross-out (+ (get jing-out result) (get dlmm-out result) (get xyk-out result) (get velar-out result)))
  )
  (asserts! (> sold ROUTER_SLACK_SATS) ERR_BELOW_FLOOR)
  (asserts!
    (>= gross-out (floor-out (- sold ROUTER_SLACK_SATS) limit))
    ERR_BELOW_FLOOR
  )
)
```

---

### [F-03] Low — `ccd015-redemption-book-mia-stx.clar` `change-offer` (`L203-L232` x `L446`) Demotes FIFO Queue Priority When Adding MIA at an Identical Unit Price

#### 1. Contract, Functions & Exact Lines
- **`contracts/extensions/ccd015-redemption-book-mia-stx.clar`:**
  - `L203-L232` (`change-offer`):
    ```clarity
    (rest (filter not-target-owner book))
    ...
    (let ((res (fold insert-step rest { nrec: nrec, out: (list), placed: false })))
      (var-set offer-book
        (if (get placed res) (get out res) (push-rec (get out res) nrec)))
    )
    ```
  - `L431-L451` (`insert-step`):
    ```clarity
    (if (and (not (get placed acc)) (< (* nask eumia) (* eask numia)))
      (merge acc { out: (push-rec (push-rec (get out acc) nrec) entry), placed: true })
      (merge acc { out: (push-rec (get out acc) entry) })
    )
    ```

#### 2. Mechanics & Concrete Failing Scenario
1. Seller A places the first offer at unit price `P` (e.g., `100,000 MIA` for `200 STX`, `index 0`).
2. Sellers B and C subsequently place offers at the exact same unit price `P` (`100,000 MIA` for `200 STX`). Because `insert-step` uses strict `<`, Sellers B and C are placed behind Seller A (`[A, B, C]`, preserving FIFO time priority).
3. Seller A now calls `(change-offer (some u100000000000) u400000000)` to add `100,000 MIA` at the **exact same unit price `P`** (`200,000 MIA` for `400 STX`).
4. `change-offer` strips Seller A from `book` (`rest = [B, C]`) and folds `insert-step` over `rest`. Because `(* u400000000 u100000000000) < (* u200000000 u200000000000)` is `false` (`4e19 < 4e19` is `false`), Seller A is appended **after** B and C (`[B, C, A]`), losing their FIFO queue position despite never worsening their unit ask price.
5. Additionally, as noted on `L219` (`(asserts! (>= namount (var-get min-deposit)) ERR_BELOW_MIN_DEPOSIT)`), if Seller A's offer was partially filled by `cross-book` (`settle-step` `L489-L502`) down to `< 100,000 MIA`, Seller A cannot call `(change-offer none lower-ask-ustx)` to lower their ask price without adding new MIA (`ERR_BELOW_MIN_DEPOSIT u15005`).

#### 3. Suggested Remediation
- In `change-offer` (`L219`), allow repricing an existing partially-filled offer downward without requiring a top-up:
  `(asserts! (or (is-eq adding u0) (>= namount (var-get min-deposit))) ERR_BELOW_MIN_DEPOSIT)`
- If `(* new-ask-ustx (get amount cur)) <= (* (get ustx cur) namount)` (unit price is unchanged or improved), preserve in-place priority against equal-priced offers positioned behind `cur`.

---

## Verification of CCIP-028 Direct-Activation & Live Mainnet State (`2026-11-06`)

We independently verified all CCIP-028 activation invariants against the live Stacks mainnet contracts:
1. **Strict Dual Authorization (`ccip028` `L15`):**
   `(asserts! (and (is-eq contract-caller DAO) (is-eq tx-sender DAO)) ERR_UNAUTHORIZED)`
   Verified against live `SP8A9HZ3PKST0S42VM9523Z9NV42SZ026V4K39WH.base-dao`: `base-dao.execute` runs `(as-contract (contract-call? proposal execute sender))`, setting both `tx-sender` and `contract-caller` to `base-dao`. No direct EOA call or intermediate contract call can spoof `tx-sender == base-dao`.
2. **Double Re-Initialization Guard (`ccip028` `L16` + `base-dao`):**
   - `base-dao.execute` inserts `{ proposal: proposal }` into `ExecutedProposals` and rejects replays with `u901`.
   - `ccip028` `L16` asserts `(is-eq (contract-call? .ccd015-redemption-book-mia-stx get-par-scaled) u0)`, and `ccd015.update-par` (`L153`) asserts `(> snapshot u0)` before setting `par-scaled`, preventing any second proposal copy from re-initializing the book.
3. **Live Mainnet Par & Pending sBTC Rewards (`2026-11-06`):**
   - `SP8A9HZ3PKST0S42VM9523Z9NV42SZ026V4K39WH.ccd002-treasury-mia-mining-v3`: `0 uSTX`.
   - `SPN4Y5QPGQA8882ZXW90ADC2DHYXMSTN8VAR8C3X.ccd014-pox5-staking-mia`: `10,241,497,066,794 uSTX` locked (`10,241,497.066794 STX`) and **`69,193,909 sats` sBTC** (`0.69193909 sBTC`, up from `62.59M sats`) currently sitting in `ccd014` ready for permissionless `(contract-call? 'SPN4Y5QPGQA8882ZXW90ADC2DHYXMSTN8VAR8C3X.ccd014-pox5-staking-mia forward-rewards)`.
   - Combined MIA supply (`v1 * 10^6 + v2`): `5,014,205,754,491,373 uMIA`.
   - Formula output: `par-scaled = floor(10,241,497,066,794 * 10^8 / 5,014,205,754,491,373) = u204249` (`2,042.49 STX` per `1,000,000 MIA`).
