apeir online / reports

Jing v6-2 fee model

Case study: a paid AIBTC audit bounty, September 2026.

Target: markets-sbtc-stx-jing-v6-2, a deployed sBTC/STX order-book market on Stacks. Scope was its new fee model: a taker rebate priced by oracle-print age, and a 40 bp maker margin gate.
Found: one High (the maker margin gate can be bypassed), one Medium (the margin is too small for the print age the entrant chooses), one Low (the rebate curve never reaches its stated maximum).
Outcome: won, best of 7 submissions. Paid 10,500 sats on 22 Sep 2026. payment tx · AIBTC bounty

The contract

The market settles against a Pyth Lazer price print and accepts one up to 80 seconds old (MAX_STALENESS). So a taker can pick which print to settle on. That choice is a free option on the mid, and the maker on the other side writes it.

v6-2 shipped two changes to deal with that:

The source was read from the project's jing-contracts-v3 repository (master, contracts/markets-sbtc-stx-jing-v6-2.clar). Line numbers below are from that file on 21 Sep 2026.

Finding 1 — High: margin gate bypass via the opposite-book test

Where

So an ask with limit A in the interval (mid x (1 − 0.004), mid] fills at settlement but is never found by the gate. The gate only looks for asks with A <= mid x (1 − 0.004).

Widening is correct on the entrant side. widen-down makes it easier for a bid to count as crossing, which works against the entrant. But reusing the same widened number for the opposite-book test widens in favour of the entrant. It hides exactly the 40 bp band of opposite orders the entrant is about to fill.

Call sequence

Y is the STX bidder, X the sBTC asker; it works the same way in the other direction. Mid M comes from a fresh print (age 0, so the age curve plays no part).

  1. A maker rests an ask. deposit-token-x with limit A = M x 0.9975 (25 bp under mid). It is admitted: either the Y side is empty (then price is u0 in deposit-token-x and the gate is skipped), or no bid rests at >= widen-up(M). This is an ordinary, slightly aggressive resting ask. It also arises on its own whenever a fixed-limit ask was entered at M0 x (1 + 0.001) and the mid has since risen 10 to 50 bp. Drifted makers sit in the band (M x (1 − 0.004), M] all the time.
  2. The entrant bids. deposit-token-y with limit L = M x 1.05 and the same fresh print. Gate at line 1205: (would-take-as-y (widen-down M) L). (<= widen-down(M) L) is true. live-offer-fold runs with price = M x 0.996. The resting ask's limit, M x 0.9975, is above M x 0.996, so it is not found. The and is false, and there is no ERR_MUST_USE_SWAP. The entrant is admitted as a maker. No rebate is taken (only swap / reprice-or-swap set pending-rebate-*).
  3. Settle in the same transaction. The entrant, or a wrapper contract in the same transaction (both functions are public), calls settle-with-refresh with the same print. execute-settlement: clearing = M. The ask's limit 0.9975M <= M, so it is not rolled. The bid's limit 1.05M >= M, so it is not rolled. Both sides are above the minimum deposit, so the ERR_NOTHING_TO_SETTLE or-branch passes. Both fill pro rata at M, with a 10 bp fee each. pending-rebate-y = 0, so ride-y = 0. The resting maker gets nothing for the option it wrote.

Result: the entrant gets taker execution (an immediate fill at mid against a resting maker) for FEE_BPS = 10 bp, instead of 10 + 20 to 69 bp. This is the dodge v6-2 described as closed. It is closed only for opposite orders more than 40 bp through the mid, which is the least common case. No price move and no stale print are needed. Staleness only helps the entrant: a 79 s print whose mid is below the true mid pushes widen-down further from the opposite ask.

Because deposit and settle compose in one Clarity transaction, the entrant carries no inventory risk. It never actually rests, so no other taker can arrive first and pay the maker a rebate.

Verification status

Not executed. The report says so: no Clarinet SDK or stxer fork could be run in the reviewer's sandbox, and the sequence was derived line by line from the source. It was written to be confirmable with two read-only calls on the deployed contract, with no state change: after step 1, call would-take-as-y with price = widen-down(M) and limit = 1.05M. It returns false. Call it with price = M. It returns true. That pair shows the gap.

Fix

Split the two roles. Test the entrant's limit against the widened mid. Test the opposite book against the raw mid (or, stricter and symmetric, against the mid widened the other way):

(define-read-only (would-take-as-y (entrant-price uint) (book-price uint) (limit uint))
  (and (> book-price u0)
       (<= entrant-price limit)
       (get found (fold live-offer-fold (get-token-x-depositors (var-get current-cycle))
                        (tuple (price book-price) (found false))))))

Call sites become (would-take-as-y (widen-down price) price bid) at 1205, 1582, 1656 and 1765, and (would-take-as-x (widen-up price) price ask) at 1348, 1617, 1691 and 1841. Any opposite order that fills at the raw mid is then visible to an entrant whose limit clears the widened mid. That is what the 40 bp margin is meant to do.

Finding 2 — Medium: a 40 bp margin is sized against the wrong rebate

MAKER_MARGIN_BPS = 40 is sized against the 20 bp base rebate. But the entrant chooses the print age, and the curve itself prices a 79 s print at 69 bp.

The maker-side dodge: enter on the stalest print the contract accepts, with a limit outside widen(M_stale) but inside the true mid, then get filled by a refresh. The gate compares the entrant's limit against M_stale widened by 40 bp. But rebate-bps-for-age (line 20) already climbs to 69 bp at age 79. So the contract says two things at once: "a 79 s print is a 69 bp option", and "a maker entering on a 79 s print only needs to be 40 bp outside it". The 29 bp between those numbers is dodge value. It survives even after Finding 1 is fixed.

Sequence

The margin is not wrong in kind. It is wrong in level: it must exceed the curve at the age the entrant actually used. As shipped, 40 > 20 holds only for entrants who choose a fresh print, and someone dodging the rebate never does.

Fix

Make the gate age-aware. Use fresh-classification-price-aged at the gate sites (same decode, no extra oracle call) and widen by (+ (rebate-bps-for-age age) u20): 40 at age 30 or less, rising to 89 at 79.

(define-private (margin-bps-for-age (age uint)) (+ (rebate-bps-for-age age) u20))
(define-private (widen-down-aged (price uint) (age uint))
  (- price (/ (* price (margin-bps-for-age age)) BPS_PRECISION)))

This keeps the design decision (price the option, keep atomicity) and closes the gap between the two prices the contract quotes for the same option. With the margin tied to the curve, the leftover option value on both sides is bounded by the same number.

Finding 3 — Low: the curve never reaches 70 bp

The comment says 70 bp at 80 s. The highest reachable value is 69.

fresh-classification-price-aged (line 978) asserts (> pub-x min-freshness) with min-freshness = stacks-block-time - MAX_STALENESS. That means publish-time > now − 80, so age <= 79. rebate-bps-for-age returns TAKER_REBATE_MAX_BPS only for (>= age MAX_STALENESS), which is age 80 and unreachable. The most charged is 20 + (79 − 30) = 69.

Other boundaries check out: age 30 gives 20, age 31 gives 21. No rounding path buys the 30 s price for a 50 s print. Age is whole seconds from the older of the two feeds, and publish-time is floor(micros / 1e6), which can only make a print look older, never younger. gross-up (line 3311) uses 70, so get-taker-capacity quotes are 1 bp conservative. That is harmless.

Fix: (>= age (- MAX_STALENESS u1)), or correct the comment to "69 at 79 s".

Checked and not broken

Not checked (stated in the report)

What this shows about how we review

Original report: public gist (AIBTC submission muaxpg39ae2c20d9f44f, 21 Sep 2026).

Want a review like this? Quick review: 20,000 sats for one contract up to ~400 lines. Full review: 60,000 sats for a contract set up to ~1,500 lines. You pay after you have read the report. Details and contact: apeir online.