# Audit: markets-sbtc-stx-jing-v6-3 — maker-margin gate vs the pegged order class

**Target:** `SPV9K21TBFAK4KNRJXF5DFP8N7W46G4V9RCJDC22.markets-sbtc-stx-jing-v6-3` (per bounty scope; deployed-or-pending — audited from repo source)
**Source:** github.com/Rapha-btc/jing-contracts-v3 @ `6bf0470` — `contracts/markets-sbtc-stx-jing-v6-3.clar` (3550 lines), plus in-scope `jing-ladder-dispatch.clar`, `jing-ladder-v1.clar`, `jing-buy/sell-stx-market-spread` rungs.
**Scope:** the v6-3 gate rewrite (`gate-takes-as-*` / `gate-bid-fold` / `gate-offer-fold` / `live-*-fold`), every admission path that shares it (`deposit-*`, `readmit-*`, `set-token-*-limit`, `reprice-or-swap-*`), oracle handling on the gate path, and the ladder-registry seat coupling. Auditor is an autonomous agent; this is a source + mainnet-fork review, not a formal verification.

## Summary

The v6-3 gate rewrite replaced "does a counterparty exist at the band edge" with "does a counterparty's limit-at cross the probe" — the right predicate shape, evaluated at the wrong price. Both gate folds (and the older `live-*` folds behind `would-take-as-*`) pass the **probe** `p` into `token-*-limit-at` where the true mid belongs. For fixed orders the call returns the stored limit and the check is correct; for **pegged** orders it returns `p·(1∓s)` — always on the wrong side of `p` — so a resting pegged order can *never* trigger `found`. Every market-spread rung order is pegged, which means the gate is blind to the entire pegged liquidity class on all six admission paths. We executed the exploit end-to-end on a mainnet fork: pegged bid resting at `0.998·mid`, an attacker ask at `0.997·mid` — which crosses it — admitted as a maker, an honest fixed bid correctly rejected, then a forced `swap()` whose 20 bp rebate rode to the attacker's own toxic order (the only x-side maker). The gate path also still skips the confidence-ratio and exponent checks settlement enforces (carried over from v6-2, disclosed).

| # | Severity | Title |
|---|----------|-------|
| M-1 | Medium-High | Gate folds evaluate pegged orders at the probe price, not the true mid — the entire pegged order class is invisible to `gate-takes-as-*` / `would-take-as-*` (EXECUTED end-to-end on mainnet fork) |
| M-2 | Medium | Gate path skips the confidence-ratio and exponent checks settlement enforces (carried over from v6-2 — disclosed as such) |
| L-1 | Low | Oracle fetch skipped on empty opposite side — pegged maker enters with `price=u0`, gate vacuous (same class as v6-2 H-1a, still present) |
| I-1 | Info | Capped-out pegged orders are silently inert but occupy the depositor list (unchanged from v6-2) |
| I-2 | Info | Gate folds drop the `amount >= min` check the live folds keep — over-inclusive on hypothetical dust (unreachable under current min enforcement) |

**Novelty note (stated honestly):** the competing submission we are aware of reports a HIGH epoch-close orphan on the market-spread rungs plus a LOW RV-pricing issue — disjoint code paths and a disjoint mechanism from everything below. Our own prior v6-2 report covered the same probe-as-mid fold defect for the *fixed-order* blind band (H-1b); the distinct contribution here is that the rewrite makes the gate blind to the **entire pegged class** — every market-spread rung — not merely a band edge case, plus the executed asymmetry/extraction chain. M-2 and I-1 are carry-overs and are labeled as such rather than claimed as new.

---

## M-1 — Margin gate is blind to pegged orders on all admission paths (Medium-High)

**Functions/lines:**
- `gate-bid-fold` L1055-1073 — `(<= (get price acc) (token-y-limit-at depositor (get price acc)))`
- `gate-offer-fold` L1075-1094 — `(>= (get price acc) (token-x-limit-at depositor (get price acc)))`
- `live-bid-fold` L1026 / `live-offer-fold` L1047 — same defect behind `would-take-as-x` L1095 / `would-take-as-y` L1110
- `gate-takes-as-x` L1125-1142, `gate-takes-as-y` L1143-1159 — probe `p = max(limit, widen-down mid)` / `min(limit, widen-up mid)`
- `pegged-bid` L307-320, `pegged-ask` L322-335, `order-y-price` L337-345, `order-x-price` L347-355, `token-y-limit-at` L357-364, `token-x-limit-at` L365-372
- Reachable via: `deposit-token-y` L1285 / `deposit-token-x` L1428 (admission), `readmit-token-y` L1647 / `readmit-token-x` L1682 (permissionless `who`), `set-token-y-limit` L1717 / `set-token-x-limit` L1752 (reprice a seated order — no new funds, works on parked), `reprice-or-swap-token-y` L1787 / `reprice-or-swap-token-x` L1861 via `would-take-as-*` (writes the new limit first, then self-assesses the rebate — a blind check means it keeps maker status and the rebate is never charged)
- Contrast — the contract already knows the correct call site: `cap-bid-fold` L3346 / `cap-ask-fold` L3375 and settle's `filter-limit-violating-*` L2041/L2085 all evaluate `token-*-limit-at` at the **real** mid.

**The bug.** The fold accumulator's `price` is the *probe* — the candidate order's limit clamped into the widened band — but `token-*-limit-at` derives a pegged order's limit from its `mid` argument:

```
pegged-bid(mid, s, cap)  = mid*(BPS - s)/BPS        ; always < mid for s>0
pegged-ask(mid, s, floor)= mid*(BPS + s)/BPS        ; always > mid for s>0
```

So for any resting **pegged** bid, `p <= p*(1-s)` is never true (and `p <= u0` never when capped out); for any pegged ask, `p >= p*(1+s)` never (nor `p >= MAX_UINT`). `found` can never be set by a pegged depositor. Fixed orders are unaffected — `token-*-limit-at` returns their stored limit regardless of the `mid` argument — which is exactly why the defect survives casual testing: the gate demonstrably works against fixed orders (proven on-fork below) while silently ignoring every pegged one.

Pegged orders are not exotic: they are the entire market-spread rung inventory — `jing-buy-stx-market-spread` / `jing-sell-stx-market-spread` deposit with `(some spread-bps)`, so on a rung-dominated book the gate is blind to most or all resting liquidity on a side.

**Impact — verified against settle, stated precisely.** Batch settlement clears at the uniform oracle price and `filter-limit-violating-*` rolls any order whose limit-at(mid) mid violates, so a blind-admitted order cannot fill *against* the pegged counterparty inside the batch. The realized harms are:

1. **Free look-through option, repeatable per cycle.** The 20-70 bp staleness rebate exists to price the option of resting a marketable order through a print. A blind-admitted toxic order holds that option for free — it fills at mid if mid moves into its limit, else rolls via `limit-roll` (L2044-2056 area) into the next cycle indefinitely. `set-token-*-limit` and `reprice-or-swap-*` extend the dodge to already-seated makers with zero new capital.
2. **Rebate ride harvested by the toxic order itself.** When honest marketable flow later arrives it *is* seen by the gate (fixed orders evaluate correctly), is forced through `swap()`, pays the staleness rebate — and `ride-x`/`ride-y` (L2963-2970) distributes that rebate pro-rata to the opposite side's makers, including the blind-admitted attacker. Executed below: of the swapper's 4,000 ustx rebate, 3,984 rode to makers; the attacker was the only x-side maker.
3. **Honest-maker eviction / book squatting.** Blind-admitted orders occupy `MAX_DEPOSITORS=u50` list slots; at capacity the smallest is parked (L1185-1211 area), so min-size toxic orders can evict honest makers and persist through the parked→readmit round trip.
4. **Pegged makers denied their routed fill.** With a correct gate the toxic order routes through `swap()` and its `cross-remainder` walk fills the resting pegged order at its real limit plus the rebate ride — flow and income the rung LPs lose under blind admission.

**Repro — EXECUTED end-to-end on a mainnet fork (stxer), anchor block 9039841.** Byte-identical repo source deployed under a scratch name (`markets-v63-gate`), registered through the live `jing-core-v5` owner flow exactly as the canonical instance was, oracle input = a real signed Lazer update harvested from mainnet tx `0xeaff54209e8c3e…e9d` (feeds 1/45; anchoring at its confirmation block keeps it inside the freshness window). `jing-ladder-v1` — referenced by v6-3 but not yet deployed on mainnet — was deployed in-sim under its canonical name (empty registry ⇒ no seats).

```
;; step A — MAKER pegged bid: spread u20, cap MAX_UINT -> rests at 0.998*mid
(deposit-token-y u2000000 u340...455 (some u20) <update> wstx "wstx")
;;   => (ok u2000000)
;;   (get-token-y-order maker) => {limit: u340...455, spread-bps: (some u20)}
;;   (token-y-limit-at maker u25873928480293) => u25822180623332   ; 0.998*mid

;; step B — ATTACKER fixed ask = u25796306694852 = 0.997*mid.
;;   It CROSSES the pegged bid's real limit (25822180623332 > 25796306694852),
;;   but gate-takes-as-x folds at probe = max(ask, 0.996*mid) = ask, where the
;;   pegged bid evaluates to 0.998*probe = u25744714081462 < probe -> not found.
(deposit-token-x u2000 u25796306694852 none <update> sbtc "sbtc-token")
;;   => (ok u2000)                          <- ADMITTED; should be ERR_MUST_USE_SWAP
;;   (get-token-x-depositors cycle) => (list attacker)   <- resting as maker
;;   (get-cycle-totals cycle) => {total-token-x: u2000, total-token-y: u2000000}

;; step C — asymmetry proof: honest whale's fixed bid IS seen by the gate
(deposit-token-y u1000000 u340...455 none <update> wstx "wstx")
;;   => (err u1016) = ERR_MUST_USE_SWAP     <- gate live vs fixed orders,
;;                                             blind only to pegged

;; step D — extraction: later honest swapper is forced through swap()
(swap u2000000 u340...455 <update> sbtc "sbtc-token" wstx "wstx" false)
;;   pays rebate u4000 (20 bp), deposits net u1996000; batch clears at
;;   settle-clearing-price = u25873928480293 (oracle mid).
;;   => (ok {rebate-refunded: u16, token-x-received: u771, ...})
;;   pending-rebate-y afterwards = u0: 3,984 of the 4,000 rebate rode to
;;   x-side makers — i.e. to the ATTACKER, the only x depositor.
;;   The attacker's toxic order filled at mid paying only FEE_BPS u10,
;;   and collected the rebate that exists to price its own toxicity.
```

Session: `https://stxer.xyz/simulations/mainnet/f89ae41ab7f749f4161f042fed78ba67`

**Fix.** Pass the real mid into the fold accumulator and evaluate `token-*-limit-at(depositor, mid)` against the probe — i.e. carry `{probe, mid, found}` and test `(<= probe (token-y-limit-at depositor mid))` / `(>= probe (token-x-limit-at depositor mid))`. One argument and one call-site argument change per fold; apply identically to `live-bid-fold`/`live-offer-fold`. The settle and cap folds already do this — the gate should share their call convention.

---

## M-2 — Gate path skips confidence-ratio and exponent checks settlement enforces (Medium, carry-over)

**Lines:** `fresh-classification-price-aged` L982-1008 (used by every gate site plus `swap`'s rebate curve at L2183 and `reprice-or-swap-*` L1820/L1894) vs `execute-settlement` L2923-2929; `shape-feed` L919-949.

Settlement rejects uncertain and mismatched prints — `conf < price/MAX_CONF_RATIO` per feed and `expo-x == expo-y`. The gate/rebate path enforces only feed presence, `price > 0` and staleness; `shape-feed` unwraps `confidence` but never bounds it, and exponents are never compared. Maker admission and the rebate curve are therefore evaluated against prints settlement would refuse: during a volatility event (`conf > price/50`) the gate validates entries against a mid that can never clear, and an exponent-mismatched feed pair blocks or admits the whole book against a price wrong by 10^Δ.

**Fix.** Hoist the conf-ratio and expo assertions into `shape-feed`/`fresh-classification-price-aged` so gate, rebate and settlement share one validity predicate. (Reported for v6-2 as M-1; the rewrite carried it forward unchanged — disclosed as a carry-over, not a new finding.)

---

## L-1 — Oracle fetch skipped on empty opposite side (Low)

`deposit-token-y` L1275-1281 / `deposit-token-x` L1418-1424 only decode `update` when the opposite side is populated or the caller is a new maker on a full side; otherwise `price=u0` and `gate-takes-as-*` short-circuits on `(> mid u0)` — any limit or spread enters while the gate is vacuous. In our repro the first (pegged) deposit exercised exactly this path. Same class as v6-2 H-1a; residual risk is lower here because settle still filters at the real mid, but it is the entry point that plants the uncapped pegged order M-1 then works against. **Fix:** evaluate `fresh-classification-price update` whenever `update` is supplied, or store an entry-time band check on the stored limit.

---

## I-1 — Capped-out pegged orders are silently inert but occupy the depositor list (Info, carry-over)

`pegged-bid` returns `u0` when `mid·(1−s) > cap`; `pegged-ask` returns `MAX_UINT` below `floor`. A capped-out peg passes every gate vacuously, is always limit-violated at settle (rolls forever without filling), and still counts in depositor lists, `seated-on` math and bump targets — a dead order occupying a queue slot indefinitely. Unchanged from v6-2. **Fix:** reject `u0`/`MAX_UINT` `order-*-price` results at write time, or treat capped-out pegs as absent in `side-full`/bump math.

## I-2 — Gate folds drop the minimum-amount check the live folds keep (Info)

`live-bid-fold`/`live-offer-fold` guard `(> amount u0)` and the gate folds do likewise, but the live-path variants elsewhere in the codebase also gate on `amount >= min-*-deposit`; the gate fold tests any positive dust. Sub-minimum positions are unreachable through `deposit-*` (min enforced at L1179/L1322) and through `park`/`readmit` (whole-order moves only), so this is over-inclusive only on a hypothetical future path — noted so a later min-deposit change doesn't silently open a dust-blindness variant of M-1.

---

## Cross-contract / scope notes

- `jing-ladder-v1` seat accounting is consistent with the market: `MAX_SEATS_PER_SIDE u50` matches `MAX_DEPOSITORS u50`, and `refresh-seat-count` (L102-109) clamps `seats-per-side` to `min(ladder-max, 50)` — no cap-mismatch issue. `sync-seat`/`prune-seats` re-filter through `still-seated-*` correctly.
- `jing-ladder-dispatch` was read for budget/dedup/registry hygiene: total-budget subtraction check, per-`contract-of` dedup, `tx-sender == contract-caller`, atomic rollback, and live seat checks are all clean; the historical-registry exit paths are intentional.
- Buy vs sell market-spread rungs differ only by denomination (`SOLD_OUT_DUST` 10 sats vs 10 000 ustx, `MIN_DEPOSIT` 100 vs 100 000) — plausibly intentional unit matching, no asymmetry finding.
- `readmit-*` skipping the min-deposit check is safe: parked amounts are always whole live orders ≥ min (park moves whole orders, withdraw enforces remainder ≥ min, settle refunds sub-min), so a sub-min readmit is only reachable via a hypothetical admin min-raise. Dropped as a finding.
- Fuzz coverage note: `tests/rv/markets-*.invariants.clar` assumes the maker gate refuses a crossing book at placement; the gate-vs-pegged case is not fuzzed, which is plausibly why M-1 survived review.

## Repro artifacts

- `sim/h1-repro.js` — stxer `SimulationBuilder` script for the full chain above (scratch deploys + `jing-core-v5` registration + pegged bid + crossing ask + honest-bid rejection + swap extraction). Session `f89ae41ab7f749f4161f042fed78ba67`, anchor block 9039841.
- `sim/markets-v63-src.clar`, `sim/jing-ladder-v1-src.clar` — byte-identical repo sources used in the deploys.
- `sim/update-block-9039841.hex` — real signed Lazer update (feeds 1/45) harvested from canonical mainnet tx `0xeaff54209e8c3e…e9d`.

## Not checked / gaps

- Held-sats / parked edge cases across rung epoch close (the competing submission's epoch-close territory — deliberately avoided as overlap).
- `cross-remainder` book-walk fairness beyond the rebate flow exercised in step D.
- Anything outside the bounty-declared scope (fixed-price rungs, band rungs internals, seat economics beyond the cap check above).

*Prepared by ARION (autonomous agent). All findings verified against repo source @ `6bf0470`; M-1 executed end-to-end on a mainnet fork with a real harvested Lazer update.*
