# Security audit — Jing v6-3 market + v5-3 router, core-spread v1 rungs, swap vaults

**Scope:** `jing-contracts-v3` at `df091b8` (post-`d4ac0c4` delta: withdrawal
overflow fix `b41dbd6`, inlined epoch close `d8b01e4`, aged-rebate capacity
`ee2edde`, three-pool DLMM selection `df091b8`), plus the external swap
vaults `juicestx` (`juice-sbtc-autoswap.clar`), `fastpool-pox-5`
(`fastpool-swap-vault.clar`) and `citycoins-protocol` (`jing-vault.clar`,
pinned `1688dec`).

**Result:** one conditional defect reported (L-1). Everything else in the
diff surface was cleared; see `WORKING-NOTES.md` for the full dead-end
record.

## Summary

| # | Severity | Title | Status |
|---|----------|-------|--------|
| L-1 | Low | Vault `as-contract?` swap allowances omit `with-stx`; enabling the Pyth Lazer fee bricks every sBTC sale path | Conditional (fee is `u0` on mainnet today); static proof of the abort path |
| I-1 | Info | Rung members pay the Lazer fee out of pocket when `withdraw` settles a pending escrow | Documented; no change requested |
| I-2 | Info | `get-taker-capacity`'s `capacity-rebate-hint` is intentionally unverified; divergence is self-inflicted | Documented design; cleared |

---

## L-1 — Low: vault swap allowances cover sBTC only; the oracle fee inside the swap path is unpayable

**Contracts / functions / lines**

- `juicestx/contracts/pox-5/juice-sbtc-autoswap.clar`:
  `router-swap` L392-397, `router-swap-split` L450-451, `jing-take` L361-363.
- `fastpool-pox-5/contracts/fastpool-swap-vault.clar`:
  `router-swap` L398-403, `router-swap-split` L456-457, `jing-take` L367
  (same bodies, mirrored).
- Pull site: `SPMV5HDZ4EMB8XY7HAYT3XW0DF7DZ4E8XEG2J1T8.pyth-lazer-oracle`
  `verify-price-feeds` → `charge-fee` →
  `(stx-transfer? fee-amount tx-sender (var-get fee-recipient))` when
  `fee > u0`.
- Reached from `markets-sbtc-stx-jing-v6-3.clar`: `swap` →
  `settle-with-refresh` → `fresh-classification-price-aged` → `lazer-feeds`
  → `contract-call? LAZER_ORACLE verify-price-feeds` (L1039).

**Description**

Every sBTC sale entry point on both vaults wraps the market/router call in

```clarity
(as-contract? ((with-ft SBTC_TOKEN ASSET_SBTC <budget>)) ...)
```

granting the contract permission to move **only sBTC**. No `with-stx`
allowance is granted in any of `router-swap`, `router-swap-split`, or
`jing-take`.

Inside that dynamic scope, `tx-sender` is the vault. When the Jing leg
executes, the market re-verifies the caller's signed Lazer update via
`verify-price-feeds`, which ends with `charge-fee`: a
`(stx-transfer? fee tx-sender fee-recipient)` whenever governance's
`pyth-lazer-oracle.fee` is nonzero. That transfer moves the **vault's**
STX, which the `with-ft`-only allowance does not permit, so the
`stx-transfer?` aborts and the entire swap reverts.

The author's own CityCoins vault (`citycoins-protocol/contracts/deployed/
jing-vault.clar`, pinned commit) demonstrates this is a real, recognised
requirement on the identical pattern: it budgets
`(with-stx PYTH_FEE_BUDGET)` — comment, L41-50:

> "the fee transfer aborts the whole call whenever the vault is the first
> to submit its VAA ... Without an explicit with-stx allowance ..."

The juice/fastpool vaults dropped that budget when they moved to the
Lazer market, where `fee` currently happens to be `u0`.

**Impact**

`pyth-lazer-oracle.fee` is a live governance switch (`set-fee`, operator
role). Today it is `u0` — verified by read-only call — so the defect is
dormant. The moment governance sets any nonzero fee:

- `jing-take` (POOL keeper path): **always aborts** — `market swap`
  unconditionally calls `verify-price-feeds` inside the `with-ft`-only
  scope. No fallback exists on this path.
- `router-swap` / `router-swap-split`: abort **exactly when the book leg
  would execute** (`jing-amount > 0` → `jing-swap` → `market swap`).
  With `jing-amount = 0` the smart path skips the oracle verify and
  stays alive, and `router-swap-split-dia` (update `none`) is unaffected.
- `refresh-mid` inside `current-mid` runs **outside** the `as-contract?`
  scope, so the keeper EOA pays its fee normally — the failure is
  specifically the *second* verify inside the swap.

No funds are lost or stranded: `emergency-recover`, `jing-reclaim`,
`cancel-token-x-deposit` and `close-batch` make no oracle call, and
`router-swap-split-dia` keeps a degraded sell path open. The impact is a
conditional denial of service on the vault's primary sell paths —
precisely the legs that capture Jing book liquidity — until governance
resets the fee or the vaults are upgraded.

**Reproduction sequence (static; not executed — see honesty note)**

1. Governance calls `pyth-lazer-oracle.set-fee u1` (any `fee > 0`).
2. Keeper calls `juice-sbtc-autoswap.router-swap update` with a valid
   Lazer update while Jing book capacity exists (`jing-amount > 0`).
3. `smart-swap-sbtc-for-stx` → `jing-swap` →
   `markets...v6-3.swap` → `lazer-feeds` → `verify-price-feeds` →
   `charge-fee` → `(stx-transfer? u1 <vault> <fee-recipient>)`.
4. The vault's `as-contract?` granted `(with-ft SBTC_TOKEN ...)` only;
   the STX transfer violates the allowance → the call aborts → the whole
   `router-swap` reverts (`jing-take` reverts identically for any
   nonzero book-or-no-book swap).

**Concrete fix**

Add an STX budget to the swap scopes, mirroring the CityCoins vault:

```clarity
(as-contract? (
    (with-ft SBTC_TOKEN ASSET_SBTC (+ amount (get min-token-x mins) u51))
    (with-stx PYTH_FEE_BUDGET)        ;; e.g. u10, as jing-vault.clar uses
  )
  ...)
```

and keep `PYTH_FEE_BUDGET` STX free in the vault (the CityCoins comment
makes the same point). Applies to `router-swap`, `router-swap-split`,
and `jing-take` in both vaults. `router-swap-split-dia` and the
`as-contract? ()` cancel/finish paths need nothing.

**Overlap / novelty**

Distinct from the rejected vault-allowance submissions (Celestial Shark
F1, Pure Leo, Photon Warden): those claimed the **sBTC** budget
(`+ min-x + u51`) can be exceeded by market rebate/remainder pulls; this
is a different asset (STX), a different pull site (the oracle's
`charge-fee`, not the market's rebate logic), and a different trigger
(governance `set-fee`, not capacity over-estimation). The CityCoins
vault's own `with-stx PYTH_FEE_BUDGET` is prior art proving the
allowance is required; the two newer vaults lack it.

---

## I-1 — Informational: rung members pay the Lazer fee when `withdraw` settles a pending escrow

`jing-buy/sell-stx-core-spread-v1.withdraw` → `escrow-for` →
`settle-escrow` → `contract-call? MARKET settle-token-x-deposit` is a
plain call (no `as-contract?`), so `tx-sender` stays the member and the
oracle fee is pulled from the member's wallet. Correct allowance-wise,
but a surprising cost: a member exiting a rung with a young pending
escrow pays the update fee even though the escrow was created by the
rung. With `fee = u0` today this is cosmetic; noted for completeness.

## I-2 — Informational: unverified rebate hint (documented, cleared)

`get-taker-capacity`'s `capacity-rebate-hint` decodes the update without
signature verification (by design: "a fee-free, unverified sizing hint").
A crafted or malformed hint can only mis-size the caller's own book leg:
the real swap verifies the same payload and either trades at the true
rebate rate or is refused and rolled back. Self-inflicted only; matches
the documented intent.

---

## Cleared diff surface (summary; details in WORKING-NOTES.md)

- `b41dbd6` withdraw full-exit guard: equivalent plus the overflow fix.
- `d8b01e4` inlined epoch close: the dropped `current-proceeds` flush is
  vacuous — `epoch-payout`'s `members == u1` branch already pays the
  sole member the entire remainder, predating the refactor in both rungs.
- `ee2edde` capacity rebate hint + `gross-up(net, bps)`: rounding is the
  exact maximal gross; hint/swap rebate paths are symmetric.
- `df091b8` `dlmm-pick` + 30-bin walk: pick is consistent between
  capacity and swap within one tx; the per-bin `raw`/`grossed` formulas
  match Bitflow `dlmm-core-v-1-1` exactly (variable fees are manager-set,
  static within a swap); the limit threshold's reciprocal conversion and
  fee adjustment are correct. The balance-heuristic weakness is already
  filed upstream and not re-claimed.
- Router pull accounting is airtight: `jing-spent` uses post-walk `rem`,
  so `residual` never exceeds the wallet refund; DLMM/XYK/Velar legs
  pull at most `left`; `scale-min`/`cp-split`/`ROUND_SLACK`/`CP_SAFETY`
  cover the boundary roundings.
- Ladder v1 seat/replace/retire and dispatch v1 validate/deposit/exit
  folds: consistent; the earlier paid classes (index floor, zero
  increment, overflow, aged sizing, tx-sender proxy) are not re-reported.

## Honesty note

All findings above are **static source tracing plus on-chain reads**
(fetched production sources for `dlmm-core-v-1-1`,
`dlmm-swap-router-v-1-2`, `dlmm-pool-stx-sbtc-v-1-bps-15`, and
`pyth-lazer-oracle`; `get-fee` queried on mainnet: `u0`). No Clarinet or
mainnet-fork execution was performed in this pass: reproducing L-1 end
to end requires governance's `set-fee` authority on a fork, which I do
not have. The abort mechanism itself is deterministic from the sources
quoted.
