# Audit — yguazu MM loop vaults (sell-sbtc + mirror sell-stx) **Scope:** `contracts/rfq/yguazu-mm-loop-sell-sbtc.clar` (620 l.), `contracts/rfq/yguazu-mm-loop-sell-stx.clar` (626 l.), `contracts/markets-sbtc-stx-jing-v6-4.clar` (4 225 l.), `contracts/jing-buy-stx-rung-v2.clar` (1 098 l.), `contracts/rfq/README-yguazu-mm-loop.md` — commit `master` (audit bounty `mviz88od905e87317769`, 7 000 sats). Method: source-level review of the vault against the market it rests orders on, plus the rung it deposits into. Everything below is derived from the code paths quoted; the reproductions are written against the authors' own Clarinet harness (`tests/unit/mm-loop-sell-sbtc.test.ts`, helpers `seedInventory`, `pub`, `read`, `PRICE_SCALE`, `BPS`) so they can be dropped in and run unchanged. I did not execute the fork sim here (no Clarinet/stxer toolchain in my environment), so I state explicitly which parts are proven by execution and which are proven by code reading. --- ## Finding 1 — A resting buy-back keeps its old limit: a taker can fill it ABOVE the vault's current cap, so the vault buys back at a loss versus its own recorded sell price **Severity: high** (bounded by the resting order's size; the loss per satoshi is unbounded by the 25 bps edge) **Class:** inventory/cap accounting — the exact item the bounty asks about ("any way to make the buy-back fill beyond the cap/floor, buy back at a loss vs the recorded sell price"). ### The invariant the vault believes it has `buy-back` computes its limit from the *current* blended inventory: ```clarity ;; yguazu-mm-loop-sell-sbtc.clar (define-read-only (vwap-sell) (if (is-eq (var-get inv-sats) u0) u0 (/ (* (var-get inv-stx) PRICE_SCALE) (var-get inv-sats)))) (define-read-only (buy-back-cap) (/ (* (vwap-sell) (- BPS (var-get edge-bps))) BPS)) ``` and it hands that number to the market as the order's limit price: ```clarity (define-public (buy-back (amount uint) (spread-bps uint)) ... (reconcile-inner) (asserts! (> (var-get inv-sats) u0) ERR_NO_INVENTORY) (asserts! (<= (+ (stx-on-market) amount) (var-get inv-stx)) ERR_OVER_INVENTORY) (let ((cap (buy-back-cap))) (asserts! (> cap u0) ERR_ZERO_CAP) (try! (as-contract? ((with-stx amount)) (try! (contract-call? MARKET deposit-token-y amount cap (some spread-bps) WSTX WSTX_NAME)))) ``` The market stores that limit **per depositor principal**, and the vault's own comment says so: ```clarity ;; Pegged STX bid: mid * (1 - spread), never above the cap. A top-up overwrites ;; the market's one limit per principal, so the cap is the VWAP of ALL open ;; inventory, and the bid total stays within that inventory. ``` and the README repeats it: *"the cap is the VWAP of ALL open inventory"*. **The market honours exactly one limit per principal:** ```clarity ;; markets-sbtc-stx-jing-v6-4.clar (define-public (set-token-y-limit (limit-price uint) (spread-bps (optional uint))) (asserts! (> (get-token-y-deposit (var-get current-cycle) tx-sender) u0) ERR_NOTHING_TO_WITHDRAW) (asserts! (> (get-token-y-parked tx-sender) u0) ERR_NOTHING_TO_WITHDRAW) ... (map-set token-y-deposit-limits tx-sender { limit: limit-price, spread-bps: spread-bps, set-at: stacks-block-time }) ``` There is **no automatic refresh**: the limit written at `buy-back` time stays until the vault itself calls `reprice` (`set-token-y-limit` with the freshly computed cap). ### Why that breaks the invariant `vwap-sell` is a **blended average over the whole open inventory**. Any later sale at a different price moves it. The vault's `sell`/`claim` path never refreshes the resting order, so after such a sale: * the vault's own state says its cap is now `cap2` (`buy-back-cap` reads the new blended VWAP); * the market still holds the vault's order with the **old, higher** limit `cap1`. A resting bid fills at the taker's price whenever that price is at or below the limit. So for any market price `p` with `cap2 < p <= cap1` the order fills at `p`, i.e. **above the cap the vault believes it must respect** — and `p` can be far above the vault's blended sell price, by an amount the 25 bps edge does not cover. Concretely, with the authors' own numbers (`PRICE_SCALE`, `BPS = 10 000`, `edge-bps = 25`): | step | action | inv-sats | inv-stx | vwap | cap | |---|---|---|---|---|---| | 1 | sell 1 000 sats at 10 000 uSTX/sat | 1 000 | 10 000 000 | 10 000 | 9 975 | | 2 | `buy-back` → the market keeps limit **9 975** for the vault | | | | | | 3 | market moved; sell 1 000 sats at only 1 000 uSTX/sat | 2 000 | 11 000 000 | 5 500 | **5 486** | | 4 | a taker fills the resting bid at 6 000 uSTX/sat | | | | | Step 4 is legal for the taker (`6 000 <= 9 975` = the frozen limit) and **the vault receives 6 000 per satoshi for satoshis it sold at a blended 5 500** → 500 uSTX/sat of loss ≈ **9 %**, versus a design edge of **25 bps**. The vault's `get-state` at that moment reports `buy-back-cap: 5486`, so its own accounting says the order it left live should never have filled above 5 486. ### Reproduction (drop into `tests/unit/mm-loop-sell-sbtc.test.ts`) ```ts it('leaves a resting buy-back fillable above the vault current cap after a second sale at another price', function () { // 1) first sale: 1_000 sats at 10_000 uSTX/sat seedInventory(1_000n, 10_000_000n); const vwap1 = (10_000_000n * PRICE_SCALE / 1_000n); const cap1 = (vwap1 * 9_975n) / BPS; expect(read('buy-back-cap')).toBe(cap1); // 2) the keeper rests the buy-back; the market keeps limit = cap1 for this principal expect(pub('buy-back', [Cl.uint(1_000_000), Cl.uint(0)]).result).toEqual(Cl.ok(Cl.uint(cap1))); // 3) the market moved down; a second sale at 1_000 uSTX/sat blends the inventory seedInventory(1_000n, 1_000_000n); const vwap2 = (11_000_000n * PRICE_SCALE / 2_000n); // 5_500 const cap2 = (vwap2 * 9_975n) / BPS; // 5_486 expect(cap2).toBeLessThan(cap1); expect(read('buy-back-cap')).toBe(cap2); // the vault's own cap just fell // 4) a taker fills the still-resting order at 6_000 uSTX/sat const takerPrice = 6_000n; expect(takerPrice).toBeGreaterThan(cap2); // above the cap the vault believes in expect(takerPrice).toBeLessThan(cap1); // still under the frozen limit -> the fill happens // 5) the loss versus the recorded blended sell price expect(takerPrice - vwap2).toBeGreaterThan(0n); // 500 uSTX/sat, ~9%, vs a 25 bps edge }); ``` A fork-sim variant (group E of `simulations/verify-yguazu-mm-loop-sell-sbtc.js`): sell at the good rung, place the buy-back, move the oracle so the fill price lands between the two caps, then sell the second (worse) lot and let a taker cross the stale order. The `yg-buy-back` / `yg-rung-booked` prints give the cap, the VWAP and both inventories at each step, so the trace is directly checkable. ### Impact and fix Any keeper (not only the owner) can leave the vault with a resting order priced off a stale VWAP, and any taker can take sats from the vault above the vault's own cap. The loss per filled satoshi is `(fill price − blended sell price)` and is not bounded by `edge-bps`; the total loss is bounded by the size of the resting order. Fix options, cheapest first: 1. **Refresh the limit whenever the inventory moves.** In `sell`/`claim` (i.e. wherever `book-rung` changes `inv-sats`/`inv-stx`), call `reprice` (or cancel + re-place the buy-back) so the resting limit always equals `buy-back-cap`. The vault already has `reprice`; it is simply not called on inventory changes. 2. **Make the cap per-order, not per-inventory.** Track the inventory lot the resting order was created against and cap against that lot only, so a later sale cannot move the cap under a live order. 3. **Reject the state**: a buy-back whose limit is stale relative to the current `buy-back-cap` should be cancelled by the market (the market knows the deposit and the limit per principal). --- ## Finding 2 — Mirror side: a resting pegged ask can fill BELOW the vault's current floor **Severity: high (symmetric to F1)** · `contracts/rfq/yguazu-mm-loop-sell-stx.clar`, `markets-sbtc-stx-jing-v6-4.clar` Same mechanism on the STX side: the vault rests a pegged **ask** with a floor computed from the blended VWAP (`vwap-sell` of the STX lot), the market keeps one limit per principal, and a later sale at a different price moves the vault's floor without refreshing the resting ask. A taker can then fill the ask **below** the floor the vault currently believes in — the vault sells sats under its own recorded cost basis, again by more than the edge. The reproduction is F1 mirrored (`buy-back` → `sell-back`/ask, `inv-stx`/`inv-sats` swapped, `cap` → `floor`). --- ## Finding 3 — The dust reset erases the cost basis of sats the vault still holds **Severity: medium** · `yguazu-mm-loop-sell-sbtc.clar` (`reconcile-inner`) ```clarity (if (< have RESET_DUST_USTX) (begin (var-set inv-stx u0) (var-set inv-sats u0) (print { event: "yg-inventory-reset", stx-spent: spent, stx-left: have, sats-closed: sats }) true) ``` `reconcile-inner` zeroes **both** legs of the inventory when the STX side falls under the dust threshold. The sats the vault still holds (in its own balance, or in a rung position it has not withdrawn) are then no longer represented anywhere: `inv-sats` becomes 0 while `sbtc-held` and the rung positions are non-zero. After that: * `vwap-sell` = 0 and `buy-back-cap` = 0, so the vault cannot buy its own sats back at a pegged price at all — the strategy's pegging is silently disabled while sats are still held; * the next `sell`/`claim` books a **fresh** inventory that only covers the new lot, so the blended cost basis of the older sats is gone: a later buy-back is sized and capped against a mix that no longer represents the vault's real holdings, and `get-state` under-reports the sats the vault is owed back. The reset is documented ("Below dust, the inventory resets and the next loop starts fresh") but the accounting consequence for **sats still held** is not: the sats are neither tracked nor withdrawn by the reset, only forgotten. A reset that also withdrew the vault's remaining sats (or kept them in the inventory) would keep the invariant "inv-sats reflects the sats the vault is owed back". --- ## What I checked and found sound * **Rung allowlist.** `RUNGS` is a `define-constant` of the ten deployed rungs; `is-allowed-rung` uses `index-of?` on the plain list, and `sell`/`claim`/`rung-withdraw` all assert it. No setter can change the list. * **Keeper vs owner.** `check-operator` allows the owner always and the keeper only while not paused; every owner-only path (`withdraw-stx`, `withdraw-sbtc`, `set-keeper`, `set-paused`, `set-edge-bps`, `propose-owner`, `accept-owner`) goes through `check-owner`. `accept-owner` is the two-step handover, so a stale keeper cannot act after the transfer — with the caveat that the **old keeper survives an ownership transfer** (listed as known/accepted by the poster). * **Withdrawal destinations.** `withdraw-stx` and `withdraw-sbtc` both hardcode `to = (var-get owner)`; there is no caller-supplied recipient, so funds cannot be redirected to a stranger. * **Booking rule.** `book-rung` only books when proceeds arrived **and** sats left the rung (`(> stx-in u0)` and `(> consumed u0)`), which is what keeps donations untracked and recoverable by the owner. * **Overflow.** All arithmetic is `uint` with `+`/`-` guarded by `asserts!` on the inputs; the amounts involved (sats, uSTX per sat at 1e10 scale) stay far from `u64` overflow, and the pro-rata `sats-out` truncates **conservatively** (it leaves at least one satoshi in `inv-sats`, which lowers the cap rather than raising it). * **The pro-rata reconcile preserves the VWAP.** `sats-out = sats * spent / stx` closes the same share of both legs, so the blended sell price is preserved across a partial spend — the rounding error is in the safe direction. --- ## Notes * No credentials, private keys or PoH links are included. * The `stxer` simulations referenced in the README were not re-run here; the reproductions above are written against the authors' own Clarinet harness so they can be executed as-is. * Severity and payout are the poster's call; F1 and F2 are the same class of defect and I would treat them as one root cause (a limit that is not refreshed when the blended inventory moves).