# Security Audit: Zest CLMM Venue in `swap-router-stx-usdcx-jing-v6.clar` (Commit `9dab9bd` vs `v5-5`) - **Target Contract:** `Rapha-btc/jing-contracts-v3` -> `contracts/swap-router-stx-usdcx-jing-v6.clar` (`9dab9bd`, 1,478 lines) - **Diff Baseline:** `contracts/swap-router-stx-usdcx-jing-v5-5.clar` (1,123 lines) - **Mainnet Dependencies Verified:** - `SP2NK9P261ZN3ZG2M1DS68PCJCRDFZVFMPYA6ZEST.clmm-router-v1` - `SP2NK9P261ZN3ZG2M1DS68PCJCRDFZVFMPYA6ZEST.clmm-pool-v1` (Pool `u1`, bin step `100`, 30 bps base fee) - `SP1A27KFY4XERQCCRCARCYD1CC5N7M6688BSYADJ7.wstx` - **Auditor:** Watchful Cass (`SP3GCJHKK0634TFR07VH44S01HNDYGHCSV5ZTWK2K` / `bc1qury5vucpsexn7qp7ufpxgqvdt05ytxwe5ldwus`) --- ## Executive Verdict The `v5-5 -> v6` Zest CLMM integration is architecturally sound across both split swaps (`swap-stx-for-usdcx`, `swap-usdcx-for-stx`) and smart swaps (`smart-swap-stx-for-usdcx`, `smart-swap-usdcx-for-stx`). Price orientation inversion (`zest-bound`), directional rounding, native STX pass-through under `restrict-assets?`/`as-contract?`, and untrusted `mid` containment hold with no loss-of-funds or limit-violation vulnerabilities. We identified **1 Low/Medium edge-case DoS (unchecked `uint128` numerator overflow in `zest-bound` on permissive `limit-price` values such as `UINT-MAX`)** and **2 Low/Informational boundary invariants**. --- ## Findings ### [Finding 1 - Low/Medium] Unchecked `uint128` Numerator Overflow in `zest-bound` (`sell-stx = false`) When `limit-price` Uses Sentinel Ceiling (`UINT-MAX`), Violating "No Fill Never Reverts" - **Location:** `contracts/swap-router-stx-usdcx-jing-v6.clar`, `zest-bound` (L1236-1247), called by `zest-stage` (L1297) and `smart-swap-usdcx-for-stx` (L1423). - **Root Cause:** In `zest-bound` (L1243): ```clarity (let ((c (/ (+ (* ZEST_WAD_PRICE ZEST_FEE_ONE) (- limit u1)) limit))) (/ (+ c (- ZEST_FEE_ONE fee u1)) (- ZEST_FEE_ONE fee)) ) ``` Here `(* ZEST_WAD_PRICE ZEST_FEE_ONE)` is `10^26 * 10^8 = 10^34`. When a caller invokes `smart-swap-usdcx-for-stx` with an unbounded/market ceiling `limit-price > MAX_U128 - 10^34 + 1` (for example `UINT-MAX = u340282366920938463463374607431768211455`, which is the exact sentinel documented for `zest-route.price` at L34), evaluating `(+ (* ZEST_WAD_PRICE ZEST_FEE_ONE) (- limit u1))` overflows `uint128` and triggers a Clarity VM runtime abort before `zest-swap` or `dlmm-stage` can run. Note that on `sell-stx = true`, `limit-min` (L591: `(/ (* base limit) PRICE_SCALE)`) and `dlmm-threshold` (L749) also overflow if `limit-price` exceeds `MAX_U128 / amount`. - **Impact:** Reverts the entire `smart-swap-usdcx-for-stx` transaction instead of degrading gracefully to `ZEST_NONE` (`in: u0, out: u0`) as specified by the v6 invariant at L22 ("No fill never reverts"). - **Remediation:** For any `limit >= (* ZEST_WAD_PRICE ZEST_FEE_ONE)` (`10^34`), `ceil(10^34 / limit)` is identically `u1`. Guard `c` in `zest-bound` (L1243): ```clarity (let ((num (* ZEST_WAD_PRICE ZEST_FEE_ONE)) (c (if (>= limit num) u1 (/ (+ num (- limit u1)) limit)))) (/ (+ c (- ZEST_FEE_ONE fee u1)) (- ZEST_FEE_ONE fee)) ) ``` --- ### [Finding 2 - Low] 16-Bit Mantissa Squaring Truncation in `zest-log2` & Discrete `TABLE-100` Powering Cause 1-Tick Downward Bias at Exact Bin Boundaries (Safely Absorbed by `ZEST_TICK_MARGIN = 2`) - **Location:** `zest-log2-bit` (L1127-1146), `zest-log2` (L1148-1165), `zest-tick` (L1167-1184), `zest-ticks` (L1210-1234) vs `clmm-pool-v1.clar` `TABLE-100` (L55). - **Root Cause:** 1. `zest-log2` normalizes `x` to 60 bits and extracts 16 fractional bits (`ZEST_FRAC_BITS` from `2^59` down to `2^44 = u17592186044416`), truncating up to `2^44 - 1` below exact `log2(x) * 2^60`. 2. In `clmm-pool-v1`, `tick-to-price` uses binary powering over `TABLE-100` (`1.01 * 2^60 = u1158671772221436761`). At exact bin boundaries `wad = tick-to-price(t)` (including `wad = 10^18 = tick-to-price(0)`, where `zest-log2(10^18) = 68938602072350896128 < ZEST_LOG2_WAD = 68938602072359301496`), `num = zest-log2(wad) - ZEST_LOG2_WAD` is slightly negative (`-8405368`), so Euclidean floor division in `zest-tick` (L1171-1174) yields `t - 1` (`-1` instead of `0`). - **Verification Across Domain `[-4458, +4458]`:** Across all reachable `mid-wad = floor(10^26 / mid)` values (`[-4166, +1861]`), `zest-tick` differs from exact `clmm-pool-v1` ticks by at most `-1` tick (`0` or `-1`). Because `zest-ticks` starts at `tm - 2` (`up = true`) / `tm + 2` (`up = false`) and ends at `tb + 1` (`up = true`) / `tb - 1` (`up = false`), this 1-tick downward bias is completely absorbed in both directions. --- ### [Finding 3 - Informational] Split Swap `unsold` Accounting Implicitly Depends on `allow-partial-fill = false` in `leg-zest` - **Location:** `leg-zest` (L251-283), `swap-stx-for-usdcx` (L396, L450), `swap-usdcx-for-stx` (L507, L562). - **Analysis:** In `swap-stx-for-usdcx` (L450) and `swap-usdcx-for-stx` (L562), `unsold` is computed as `(+ (- jing-amount routed) (- dlmm-in (get in dlmm)))`, omitting `(- (get zest amm-amounts) (get in zest))`. In `clmm-pool-v1` (`swap-x-for-y` L450-463, `swap-y-for-x` L476-489), the pool only checks `(asserts! (or allow-partial-fill (is-eq left u0)) ERR-NOT-FILLED)` inside the `(if (> out u0) ...)` branch. However, because `leg-zest` passes `(amm-floor min-received) >= u1` as `min-dy`/`min-dx` and `allow-partial-fill = false`, any `out == u0` reverts with `ERR-SLIPPAGE (u5008)` and any `0 < out` with `left > u0` reverts with `ERR-NOT-FILLED (u5001)`. Thus `(get in zest)` is strictly equal to `(get zest amm-amounts)` whenever `leg-zest` succeeds, making `unsold` exact. --- ## Verification of the 4 Audit Scope Questions 1. **Split Swaps (`swap-stx-for-usdcx` L347, `swap-usdcx-for-stx` L461, `leg-zest` L251):** - `ERR_SPLIT_MISMATCH (u3004)` verifies `amount == jing-amount + dlmm + xyk + velar + zest` (L368, L482). - `valid-fallback` (L340) only accepts `VENUE_DLMM (u1)`, preventing any unrouted Jing residual from landing on the fixed `zest-route` tick list. - When `amm-amounts.zest == u0`, `leg-zest` returns `(ok { in: u0, out: u0 })` without calling `ZEST_ROUTER` (`zest-route` is ignored). 2. **Smart Swaps (`zest-stage` L1281, `zest-bound` L1236, `zest-ticks` L1210, `zest-swap` L1249):** - **Rounding Proof (`sell-stx = true`, `x -> y`):** `zest-bound` computes `floor(floor(10^26 * (10^8 - fee) / limit) / 10^8) <= 10^18 * (1 - fee/10^8) / limit`. In `clmm-pool-v1` `step-x-for-y` (L592-605), `frame-price-limit(fr, max-price, false)` rounds down, `accept` rounds protocol/LP fee up (`mul-div-up`), and `out` applies `haircut`. Thus every unit filled on Zest satisfies `dy / dx >= limit-price / 10^8` (up to the 1-raw-unit haircut). - **Rounding Proof (`sell-stx = false`, `y -> x`):** `zest-bound` computes `ceil(ceil(10^26 * 10^8 / limit) / (10^8 - fee)) >= 10^18 / ((limit / 10^8) * (1 - fee/10^8))`. In `clmm-pool-v1` `step-y-for-x` (L620-633), `frame-price-limit(fr, min-price, true)` rounds up. Thus every unit filled satisfies `dy / dx <= limit-price / 10^8` (up to the 1-raw-unit haircut). - **No-Fill Safety:** `zest-swap` passes `allow-partial-fill = true` and `min-dy/min-dx = u0`, matching any `(err e)` from `ZEST_ROUTER` into `(merge ZEST_NONE { ticks: (len ticks) })`. Since Clarity rolls back inner contract writes when a `contract-call?` returns `err`, any Zest revert leaves state clean and forwards the full remainder to `dlmm-stage`. 3. **Native STX & Post-Conditions:** - Verified against `clmm-pool-v1` (L556-580): `transfer-in` executes `(restrict-assets? owner ((with-stx amount)) (try! (pull token amount owner)))` with `owner = tx-sender`, and `transfer-out` executes `(as-contract? ((with-stx amount)) (try! (pay token amount recipient)))` with `recipient = tx-sender`. Because `swap-router-stx-usdcx-jing-v6` never calls `as-contract`, `tx-sender` is the end user throughout, zero funds remain in the router, and `out` is measured directly via `(gain usdcx-before (usdcx-balance user))` / `(gain stx-before (stx-balance user))`. 4. **Untrusted `mid` Proof:** - `mid` only enters `mid-wad = (/ ZEST_WAD_PRICE mid)` -> `tm = zest-tick(mid-wad)` (clamped to `[-4458, +4458]`), which selects the starting tick `tm -/+ 2` of `zest-ticks`. Because `zest-tick-step` checks `(> tick end)` / `(< tick end)` before appending (where `end = tb +/- 1` and `tb = zest-tick(bound)`), no tick beyond `tb +/- 1` can be emitted, and `clmm-pool-v1` enforces `bound` inside `step-x-for-y` / `step-y-for-x`. An adversarial `mid` can at worst yield `0` Zest fill (falling through to `dlmm-stage`), never a fill worse than `limit-price`.