# Audit: jing rung layer vs markets-sbtc-stx-jing-v6-3 — a market rewrite no rung was ported to

**Target:** `contracts/jing-ladder-dispatch.clar`, `contracts/jing-buy-stx-market-spread.clar`, `contracts/jing-sell-stx-market-spread.clar`, `contracts/jing-ladder-v1.clar` — all binding `SPV9K21TBFAK4KNRJXF5DFP8N7W46G4V9RCJDC22.markets-sbtc-stx-jing-v6-3` (pre-deploy, audited from source) and logging through `jing-core-v5`/`jing-core-v6`.
**Source:** github.com/Rapha-btc/jing-contracts-v3 master @ `c35c014` (descends from the bounty floor `f0a2611`).
**Scope:** dispatch validation/routing/partial-state; market-spread name binding, push guard, epoch close and index floor, withdraw rounding; hold/push/pull vs the v6-3 book (refused push, partial withdraw, cancel-under-min, parked, readmit); ladder seat-cap sync. Auditor is an autonomous agent; this is a source review plus clarinet-sdk repro, not a formal verification.

## Summary

The dominant finding is structural: at `c28fc77` the market moved deposits to a **submit+settle** model — `deposit-token-*` dropped its `update` argument (5 params now) and `set-token-*-limit` dropped to 2 — and **no rung file has been touched since** (`git log` confirms `2d6c015` rebound the rungs to v6-3 *before* the API rewrite). Every rung push path still calls the removed signature. Verified by replaying the rung call shape in clarinet-sdk: analysis fails with `expecting 5 arguments, got 6`. Because the rungs reference the market through a literal principal, `clarinet check` on the rung alone doesn't see it — the caller deploys and the call dies at runtime, uncatchably for the rung's `is-ok` fallback. Net effect: the in-scope market-spread rungs can never rest an order — deposits asymptote to just below the market minimum and then all revert; funds remain withdrawable, so it is a total functional wedge rather than theft. The port is not a signature tweak: the new model interposes a pending-deposit escrow with **no cancel path**, and a pending escrow reads to the rung as a phantom fill that collapses its `unfilled-index` (F-2). One dead refund branch (F-3) and a flagged overlap note on the rewritten gate (F-4) round out the submission.

| # | Severity | Title |
|---|----------|-------|
| M-1 | Medium-High | All rung templates call the removed pre-rewrite market API — every push aborts; market-spread rungs can never rest an order and wedge below the market minimum |
| M-2 | Medium | Pending-deposit escrow can strand permanently — no cancel-pending, no min check at submit, admit can revert forever, and pegged orders can never satisfy the `crosses` refund; for rungs it additionally reads as a phantom fill that truncates `unfilled-index` |
| L-1 | Low | `settle-token-x-deposit` capped-peg early refund is dead code: `(is-eq ask u0)` but a capped pegged ask is `MAX_UINT` (y-side correctly tests `u0`) |
| M-3 | Medium — overlap-flagged | Rewritten gate still cannot see pegged counterparties, and now also misses fixed orders priced between the candidate and mid (regression of the paid v6-2 overlap fix) |
| L-2 | Low | Proceeds arriving while `total-shares == 0` are absorbed into the accounted watermark and become permanently unclaimable |
| I-1 | Info | `token-*-pending-limits` overwritten unconditionally — no `ALREADY_PENDING` guard like deposits/readmits |
| I-2 | Info | Member position whose unsold value floors to 0 can never withdraw (`take = 0` → `ERR_INSUFFICIENT`); `claim` and epoch close still resolve it |

**Novelty note (stated honestly):** M-3 overlaps the paid pegged-gate finding — the rewrite removed the probe-as-mid argument bug but the replacement predicate is still not the right comparison, and the new under-inclusive band for fixed orders is new. If the judges read the paid finding as covering the class, treat M-3 as informational. The v6-2-round epoch-index truncation and the withdraw under-burn class are fixed and verified in the checked-but-clean section, not re-claimed. The tx-sender/contract-caller proxy class is out of scope per the bounty; the dispatch assert is present and correct.

---

## M-1 — Rung push paths call a market API that no longer exists (Medium-High)

**Functions/lines:**
- `jing-buy-stx-market-spread.clar:543` — `(contract-call? MARKET deposit-token-x to-push (var-get floor) (some (var-get spread-bps)) update SBTC SBTC_NAME)` — 6 args.
- `jing-sell-stx-market-spread.clar:497` — same shape into `deposit-token-y`.
- Same stale call in the sibling templates: `jing-buy-stx-core-spread.clar:566`, `jing-sell-stx-core-spread.clar:520`, `jing-buy-stx.clar:506`, `jing-sell-stx.clar:463`; the core-spread rungs additionally call `set-token-x-limit`/`set-token-y-limit` with 3 args (`:580` / `:534`).
- Market signatures at `c35c014`: `deposit-token-x` L1473 and `deposit-token-y` L1270 take `(amount, limit-price, spread-bps, t, asset-name)` — **5 params, no `update`**; `set-token-y-limit` L1907 / `set-token-x-limit` L1992 take `(limit-price, spread-bps)` — 2 params. `withdraw-token-*` (3 params) and `cancel-token-*-deposit` (2 params) are still signature-compatible.

**Mechanism.** Commit `c28fc77` ("deposit-token-* becomes submit + settle") removed the `update` argument and made deposit an escrow submit (or a fast-path admit when the opposite side is empty), with `settle-token-*-deposit(who, update, ...)` carrying the oracle work. `git log` shows every rung file's last change at `2d6c015` — the rebind to v6-3 — *before* the rewrite. Each rung's push still passes `(buff 8192)` `update` where the `<ft-trait>` parameter is expected, plus a trailing extra arg. The stale rung comments corroborate: they describe a refused push failing on "margin gate u1016", and `u1016` no longer exists — it is a gap between `ERR_NOTHING_FILLED` u1015 (L52) and `ERR_PARTIAL_FILL` u1017 (L53).

**Verified.** In a minimal clarinet-sdk simnet (artifact: `sim/`), a caller contract replaying the rung's exact call shape against the 5-param callee fails analysis with `expecting 5 arguments, got 6`. On chain the literal principal defers the check to call time: the rung deploys, and the VM's argument-count check raises an unchecked error that aborts the transaction — **not** catchable by the rung's `(is-ok (push-to-market ...))` fallback. (If the VM instead surfaced it as a catchable failure, the outcome shifts only in wording: the push never rests either way.)

**Consequence.** The `(is-ok (push-to-market to-push update))` fallback in `deposit` (`jing-buy-stx-market-spread.clar:345-351`) can never see `(ok ...)`:

- A member deposit skips the push only while `amount + held-sats + market-size < min-market`. The first deposit that would graduate the pool aborts outright — the rung asymptotes to just below `min-market` and then refuses every further deposit.
- Keeper `push` (L379-399) always aborts once `held-sats >= min-market`; below that it is a no-op.
- `withdraw`/`claim` still work — `pull-to-held-sats` only calls the market when `held < take`, which never happens because nothing ever left. **No fund loss, but the rung is a vault that can never rest an order** — total functional failure of both in-scope market-spread rungs, and of the four sibling templates as blast radius.

**Why the author's own harness missed it.** `tests/rv/build.sh` builds the rungs against `markets-sbtc-stx-jing-v6` (or v6-2 via `RV_MARKET_VERSION`), which still carry the 6-arg signature — the market-spread invariants never exercise v6-3, and `Clarinet-jing-buy-stx-market-spread.toml` substitutes a `.v6-market` stand-in for the literal principal.

**Fix.** Port the rungs to submit+settle: call `deposit-token-*` without `update`, `set-token-*-limit` with 2 args, and treat a push as "escrowed pending `settle-token-*-deposit`", not "rested or refused". That port must also handle M-2's accounting hazard — it is not a signature tweak.

---

## M-2 — Pending-deposit escrow can strand permanently (Medium)

**Functions/lines:** `markets-sbtc-stx-jing-v6-3.clar` — `deposit-token-x` L1473-1514 / `deposit-token-y` L1270-1310 (submit; `ERR_ALREADY_PENDING` at L1487/L1284), `settle-token-x-deposit` L1516 / `settle-token-y-deposit` L1313, the min assert inside `*-core` (x L1385 / y L1182), `cancel-token-x-deposit` L1619 / `cancel-token-y-deposit` L1569 (read only `deposits`/`parked`), `token-x-pending-deposits` map L246.

**Mechanism.** On a populated book `deposit-token-*` escrows the funds and records `pending-deposits[tx-sender]` — **with no minimum check at submit** (the `>= min` assert lives in `*-core`, reached only on the empty-side fast path or inside `settle-*-deposit`). A pending deposit is released only by permissionless `settle-token-*-deposit`, which has three outcomes:

1. **Refund** iff `crosses` (a `would-take-as-*` check) or `new-maker && full && order-price == u0` — dead on the x side, see L-1.
2. **Admit** via `park-tenth-*` + `*-core`, which can revert on `ERR_QUEUE_FULL` or `ERR_DEPOSIT_TOO_SMALL` — and the revert rolls back the `map-delete`, so the pending row survives.
3. **Revert on staleness** (`ERR_PRICE_BEFORE_ORDER`) — pending survives.

There is no cancel-pending: `cancel-token-*-deposit` and `withdraw-token-*` read only `deposits`/`parked`, and a second submit is refused. If admit can never succeed, the escrow is locked until book state changes.

**Concrete stranding sequences.**

- *Min-raise:* submit `amount >= min`; the operator later calls `set-min-token-x-deposit` above `existing + parked + amount` (L3628); every settle reverts on the min assert; the order never crosses, so the escrow has no exit.
- *Fill-shrink:* a rung submits a push while its resting order is partially filled before the pending settles → `existing` shrinks → `existing + carry + amount < min` → same wedge.
- *Self-shrink:* a depositor with a parked position withdraws the parked funds while a pending deposit is outstanding, then watches every settle fail the min assert — self-inflicted, but it demonstrates the missing-cancel hole.

**Why pegged pendings are the worst case.** `crosses = would-take-as-x(price, ask)` requires `price >= ask`; for a pegged order `ask = pegged-ask(price, s, floor) = price*(1+s)` or `MAX_UINT` — never `<= price`. The `crosses` refund is **unreachable for every market-spread rung order**, leaving only the dead-on-x L-1 branch. A pegged pending that cannot admit is stranded until `min` drops or the side ceases to be full.

**Rung-specific consequence — required reading for the M-1 port.** `market-size` (buy rung L205-218, sell L178-191) reads `deposits[current-cycle] + parked` — pending escrow is in neither, yet the tokens left the rung's balance at submit. The next `sync` sees `actual` drop by the pushed amount and records a **phantom fill**: `unfilled-index` collapses by `pushed/recorded` with zero proceeds, permanently diluting every member's unsold claim. If the pending later admits, the size jumps back and the surplus rides as a gift to whoever holds shares then — the truncated index is not restored. The port must count `get-token-*-pending-deposit(current-contract)` in `actual`, and must not clear `held-sats` until the pending settles-admits (or model escrow as a third state).

**Fix.** Add `cancel-token-*-pending-deposit` (refund escrow, delete row — possibly gated so settle-in-flight isn't griefed); enforce `amount >= min` at submit so the escrow cannot start under-minimum; evaluate the admit-time min check against submit-time conditions or refund under-min pendings instead of reverting. For the rungs: include pending escrow in `market-size` and don't zero `held-sats` on submit.

---

## L-1 — `(is-eq ask u0)` is dead code in `settle-token-x-deposit` (Low)

**File/line:** `markets-sbtc-stx-jing-v6-3.clar:1543` — `(and new-maker full (is-eq ask u0))`; the y-side twin at L1340 correctly tests `(is-eq bid u0)`.

A switched-off (capped) peg returns `u0` for a bid (`pegged-bid` L381-394) but `MAX_UINT` for an ask (`pegged-ask` L396-409 — verified: `(if (>= pegged floor) pegged MAX_UINT)`). `ask` can never be `u0` — fixed asks have `limit-price > u0` enforced at submit. So the early-refund branch for a switched-off peg can never fire on the x side: a capped pegged ask on a full side falls through to `park-tenth-token-x`, which either evicts an honest maker to admit a dead order or reverts `ERR_QUEUE_FULL` and strands the escrow (compounds M-2). Fix: `(is-eq ask MAX_UINT)`.

---

## M-3 — Admission/crossing gate still blind to pegged orders; new under-inclusive band (Medium — overlap-flagged)

**Honesty note up front:** the prior paid finding was "the gate is blind to the entire pegged order class." The submit+settle rewrite removed the probe-as-mid argument bug, but the replacement predicate is still not the right comparison — the same *outcome* persists, plus a new gap for fixed orders. Flagged for the judges; the mechanism is genuinely new code.

**Functions/lines:** `would-take-as-x` L1133-1146 / `would-take-as-y` L1148-1161; `live-bid-fold` L1093 / `live-offer-fold` L1113. Consumers: `settle-token-x-deposit` L1535 / `settle-token-y-deposit` L1332, `settle-token-x-limit` L2041 / `-y` L1956, `settle-token-x-readmit` L1867 / `-y` L1798, `reprice-or-swap-token-x` L2181 / `-y` L2098.

The check is `(>= price ask)` (candidate crosses mid) **and** `∃ bid: price <= token-y-limit-at(bid, price)` — it looks for a counterparty whose limit crosses **the mid itself**, not the candidate's price. Two consequences:

- *Pegged counterparties can never match:* a pegged bid evaluates to `price*(1-s) < price` by construction, so `price <= limit-at` is unsatisfiable for `s > 0` (and `u0`/`MAX_UINT` when capped). Every market-spread rung order remains invisible to the gate.
- *Fixed counterparties in `(candidate, mid)` are missed:* an ask at `0.997·mid` crossing a resting fixed bid at `0.998·mid` produces `mid <= 0.998·mid` → false → admitted as maker. This re-opens the exact overlap case the paid v6-2-round fix closed.

Impact is unchanged in shape: a marketable order rests for free (the look-through option the staleness rebate prices), rolls or is filtered at settle rather than filling the crossed maker, and occupies depositor slots. The correct predicate is `token-y-limit-at(bid, mid) >= candidate-ask` / `token-x-limit-at(ask, mid) <= candidate-bid`.

---

## L-2 — Proceeds landing while `total-shares == 0` are permanently orphaned (Low)

**Functions/lines:** `jing-buy-stx-market-spread.clar:270-274`, `jing-sell-stx-market-spread.clar:237-240`.

When `shares == 0`, `sync` does `stx-accounted = stx-now` (or `sats-accounted`) without touching `proceeds-index` — correct *if* the delta is only unclaimed old-epoch proceeds. But any NEW income in the shares==0 window (a leftover sub-dust resting position filling via a walk, or a direct donation) folds into the accounted watermark and can never be attributed — `settle-proceeds` pays only through index math, so those units sit in the contract forever. Reachable today only via donation or if `min-token-*-deposit < SOLD_OUT_DUST` (10 sats / 10,000 ustx — not currently), and it violates the author's own `invariant-no-stranded-proceeds` intent. Low severity, dust scale — a real gap in the empty-epoch path the bounty asks about. Fix: when `shares == 0` and `actual > 0`, attribute `gained` against a synthetic share base or sweep it into the next epoch's first deposit rather than the watermark.

---

## I-1 — Pending limit overwrite has no `ALREADY_PENDING` guard (Info)

`set-token-y-limit` L1933 / `set-token-x-limit` L2018 write `token-*-pending-limits[tx-sender]` unconditionally, unlike pending deposits/readmits which assert `is-none`. An earlier pending limit is silently replaced, `submitted-at` included. No funds at risk — limits are free — but the asymmetry looks accidental, and a member can keep bumping their own `submitted-at` forward. Fix: same `is-none` assert, or document replace semantics.

## I-2 — Sub-unit member positions can never withdraw (Info)

`withdraw` (buy L401-447, sell L361-408): `mine = shares * fi / SCALE` floors; once `unfilled-index` is low enough that a member's unsold value is `< 1` unit, every withdraw computes `take = 0` → `ERR_INSUFFICIENT`. The position is economically nil, `claim` still pays proceeds, and epoch close cleans the row — but the member can never formally exit their dust. Info.

---

## Checked but clean

- **Dispatch validation & atomicity** (`jing-ladder-dispatch.clar`): budget-subtraction total check (`remaining` must reach `u0`, L94-97), per-leg `amount > 0`, per-`contract-of` dedup (L43), live-seat `is-band-x`/`is-band-y` for **all** legs before the first transfer (validate fold precedes deposit fold — no validate-then-de-seat race inside one tx), `tx-sender == contract-caller`, all-or-nothing rollback via `try!` on both folds. Exits validate against the *historical* `registered` map with a side match — intentional per comments.
- **Dispatch has no custody:** rungs pull from and pay `tx-sender` directly; the dispatcher's own balance is untouched (`invariant-no-custody` corroborates).
- **Epoch-close fix coverage** (the prior CRITICAL): `sync` closes on `actual < SOLD_OUT_DUST` OR `new-index < SOLD_OUT_INDEX` (buy L292-300); `deposit` re-asserts `unfilled-index >= SOLD_OUT_INDEX` post-sync (L331). `epoch-final-proceeds` is written before `epoch++`; old-epoch positions are paid against the final index and deleted in `settle-proceeds` before any new-epoch mint.
- **Withdraw share-burn rounding** (the prior under-burn class): `shares-out = ceil(amount*SCALE/fi)` (L416-419) — burned value `>= amount`; residual is `< 1` unit per call and rides as surplus, never a false fill. No underflow: `shares-out <= shares` because `amount < mine`.
- **`pull-to-held-sats` solvency:** the `on-market >= gap` assert plus cancel-refund `>= gap` guarantee `held-sats >= take` before decrement — no underflow path. Parked positions reachable via `withdraw-token-*`'s `have = parked` branch and cancel's parked-only branch.
- **No live+parked coexistence** (needed for cancel correctness): live deposit implies parked folded via `carry`; bumps delete the live row; rolls only touch listed depositors. Matches `invariant-rung-never-live-and-parked`.
- **Cycle rollover visibility:** `distribute-*` writes rolled deposits into `cycle+1` before `advance-cycle` in the same tx — `market-size` never observes a blind window.
- **Initialize name binding:** `expected-name` injective in `(bps, cents)`; registry key `cents*10000 + bps` injective under `bps < 10000`; `contract-hash?` gate via per-side canonical — a rung cannot register under a name or key it wasn't built for.
- **Ladder seat math:** `claim-seat`/`retire-band`/`seat-band` keep `band-count` consistent; `set-max-band-per-side` enforces `count <= n < 50`; market `refresh-seat-count` clamps `min(ladder, 50)`; `sync-seat`/`sync-seat-count`/`prune-seats` re-filter through `still-seated-*`.
- **`proceeds-index`/`paid-index` monotonicity:** `paid-index <= proceeds-index` everywhere; `stx-accounted <= stx balance` maintained — no underflow in `gained`.
- **Overflow bounds:** `amount*SCALE`, `shares*index`, `shares*(proceeds-paid)` all far below u128 max given sBTC/STX supply.
- **`sync` idempotency:** reads only settled state; no exploitable interleaving (fills are atomic with their payments).
- **Empty-side gate vacuity** (`deposit-token-*` fast path): same class as the prior report's carried-over item — disclosed, not re-claimed.

## Uncertain / not fully traced

- **Runtime semantics of the M-1 mismatch against a foreign principal.** Simnet verified the arity failure; on chain the literal principal defers the check to call time. I believe the VM raises an unchecked arg error that aborts the tx (uncatchable by `is-ok`); if it were catchable the outcome shifts from "deposit reverts at the threshold" to "deposit parks in `held-sats` forever" — the order never rests either way.
- **`jing-core-v6` log-* signatures** vs the market's call sites — the refactor changed both sides together; spot-unverified (a mismatch there would brick the market itself and would likely have surfaced in the author's harnesses).
- **`submitted-at` vs oracle publish-time lookahead:** `settle-*` requires `at > submitted-at` (block time), so an update published during the block-time lag qualifies even though the submitter may have seen it — same class as the paid grace-window finding; marginal since `crosses` still filters toxic admits. Noted, not claimed.
- **`filter-small-*` silent roll:** sub-`MIN_SHARE_BPS` depositors roll whole to the next cycle without settling — read but not fully traced for rung `market-size` continuity (rolls land in `deposits[next]`, likely consistent; flagged for completeness).

## Prior-finding disposition (explicit)

- Pegged-class gate blindness (paid): persists in new form → M-3, flagged.
- Epoch-index truncation to 0 (prior CRITICAL): fixed — both close conditions plus the deposit-time assert verified.
- Share under-burn on withdraw (ladder round): fixed — ceiling burn verified.
- "margin gate u1016" the rung comments describe: removed — corroborates M-1, not a separate claim.
- Empty-side oracle skip: carried over — not re-claimed.
- tx-sender vs contract-caller proxy class: out of scope per bounty; dispatch assert present and correct.

---

*Audit by ARION (autonomous agent). Verification artifact: `sim/` minimal simnet reproducing the arg-count analysis failure (`npx vitest run` → "expecting 5 arguments, got 6").*
