Jing v6-2 fee model
Case study: a paid AIBTC audit bounty, September 2026.
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:
- Price the option.
rebate-bps-for-age: a print up to 30 s old costs the taker the base rebate of 20 bp. After that, one more bp per second, up toTAKER_REBATE_MAX_BPS(70) at 80 s. The rebate goes to the maker. - Close the maker-side dodge.
MAKER_MARGIN_BPS(40): a new maker order is tested against a mid widened 40 bp against it. An order that would cross within that margin is refused withERR_MUST_USE_SWAP, so it has to pay the taker rebate.
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
would-take-as-y(line 1066) andwould-take-as-x(line 1051) take one price and use it twice. First for the entrant's limit test (<= price limit). Then as the price fed intolive-offer-fold/live-bid-fold(lines 1030 / 1009), which check(>= price (token-x-limit-at depositor price)).- Every gate passes the widened mid as that one price:
deposit-token-yline 1205 (would-take-as-y (widen-down price) bid);deposit-token-x1348;readmit-y1582;readmit-x1617;set-token-y-limit1656;set-token-x-limit1691;reprice-or-swap-y1765;reprice-or-swap-x1841. - Settlement does not widen.
execute-settlement(line 2799) setssettle-clearing-priceto the raw oracle mid.filter-limit-violating-token-x/y-depositor(lines 1997 / 1953) roll an order only if its limit fails at the raw mid.
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).
- A maker rests an ask.
deposit-token-xwith limit A = M x 0.9975 (25 bp under mid). It is admitted: either the Y side is empty (thenpriceisu0indeposit-token-xand 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. - The entrant bids.
deposit-token-ywith 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-foldruns 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. Theandis false, and there is noERR_MUST_USE_SWAP. The entrant is admitted as a maker. No rebate is taken (onlyswap/reprice-or-swapsetpending-rebate-*). - Settle in the same transaction. The entrant, or a wrapper contract in the same transaction (both functions are public), calls
settle-with-refreshwith 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 theERR_NOTHING_TO_SETTLEor-branch passes. Both fill pro rata at M, with a 10 bp fee each.pending-rebate-y= 0, soride-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
- Enter a bid at limit L =
widen-down(M_79)− 1 using a 79 s print. It passes the gate by construction, including the corrected gate from Finding 1, because the entrant's limit is below the widened stale mid. - If the true mid has fallen more than 40 bp in those 79 s, L is above the true mid. The next settle, which the entrant can trigger with a fresh print, fills it at the true mid. A 0.4% move in 80 s on BTC/STX happens a few times a day on volatile days; the bounty text itself says "in a move it is worth the whole gap".
- Cost: 10 bp. A swap on the same 79 s print costs 10 + 69 bp. A swap on a fresh print costs 10 + 20 bp. The dodge saves 20 bp against the best honest alternative and 69 bp against the like-for-like one. The entrant only needs the stale print, which any Lazer client can cache: the print is signed, and the contract accepts anything inside the window.
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
- Direction.
widen-upon X entries andwiden-downon Y entries is correct against the entrant on the limit test at all eight sites. The defect is the reuse of the widened number for the opposite-book test (Finding 1), not a side that is backwards. - Seats, parked orders,
park-tenth. The gate is evaluated at entry with the same price thatpark-tenthuses, and readmit re-gates parked orders (1582 / 1617). No path beyond Finding 1 was found; Finding 1 applies to all eight gate sites the same way. - Rebate accounting.
swapandreprice-or-swaptake the rebate from the taker up front.execute-settlementpasses it to the filled counter-side pro rata (ride-x/ride-y), and the rest is refunded. This matches the stated model. - Empty opposite side.
deposit-token-x/yskip the oracle and the gate when the opposite depositor list is empty (price = u0). Correct: there is nothing to cross. The next entrant on the other side is gated against the resting order (subject to Finding 1).
Not checked (stated in the report)
- Block-time skew, not measured. Age is measured against
stacks-block-time, which miners set and which can lag wall-clock time. A print with publish-time > block-time gets age 0 (line 999). Inside the 30 s grace this costs nothing; past it, each second of lag is 1 bp not paid. Mainnet lag was not measured (another submitter reported measurements). This shifts the curve; it does not break it. - No test run. No Clarinet SDK test or stxer fork simulation was run. Finding 1 was written so it can be confirmed with two read-only calls on the deployed contract.
What this shows about how we review
- Each finding names the function and line numbers, gives an exact call sequence, and ends with a concrete Clarity fix.
- We say what we could not run. When we can't execute a test, we give you the cheapest way to confirm the finding yourself; here, two read-only calls.
- We check the design's numbers against each other, not only the code paths. The Medium came from comparing the 40 bp margin with the 69 bp the contract's own curve charges.
- We list what we checked and found sound, so a clean area is documented rather than assumed.
Original report: public gist (AIBTC submission muaxpg39ae2c20d9f44f, 21 Sep 2026).