diff --git a/.claude/rules/move.md b/.claude/rules/move.md index 8a5a8bf1a..a80528825 100644 --- a/.claude/rules/move.md +++ b/.claude/rules/move.md @@ -252,6 +252,34 @@ Then call as `self.id.exists_(key)`, `self.id.add(key, value)`, `self.id.borrow( - `create_and_share` constructors should accept tunable per-instance config as constructor parameters rather than seeding defaults that the admin must immediately overwrite. After `share_object` the only way to reconfigure is a separate setter tx, so a default-only constructor forces a two-tx admin flow whenever an instance needs non-default values. Take the params directly, validate them with the same `assert_*` helpers the setter uses (so creation and update share one validation path), and use defaults only when the config is genuinely the same for every instance. +## DeepBook Margin: risk ratio, solvency, and slippage + +- `risk_ratio` (`margin_manager.move:risk_ratio_int`) is `assets / debt` with **assets valued at the Pyth oracle price, not the orderbook execution price**. Debt only changes on `borrow`/`repay`/`liquidate`, never on a trade. Consequence: any close/trade that *executes worse than oracle* (real slippage) hands oracle-measured value to the counterparty while debt is fixed, so it **strictly lowers the measured risk ratio**. + +- Two distinct post-trade invariants in `pool_proxy.move`: opening trades use `assert_post_trade_solvent` (floor = `min_open_risk_ratio`, between liquidation and the borrow floor); reduce-only / close trades use a monotonic check (`ratio_after >= ratio_before`). The monotonic check fires on the **swap-only intermediate state, before any `repay`**, where slippage is pure value leak — so a reduce-only *market* close that pays the spread aborts on `ERiskRatioMustNotWorsen`. (This constant was renamed from `EReduceOnlyMustImproveRiskRatio`, abort code `7` unchanged, once the gate became shared with the non-reduce-only `place_market_order_and_repay_loan` — see below.) This is intentional and is asserted by `pool_proxy_tests.move::test_place_reduce_only_market_order_ok`, which is an `expected_failure`. Deleveraging only improves the ratio when the `repay` is included; check the invariant on the net (swap+repay) state, not the swap alone. + +- Slippage tolerance and solvency are orthogonal knobs. Allowable slippage belongs in the per-pool `price_tolerance` band enforced by `margin_registry.move::assert_price` (bids capped above oracle, asks capped below; 1%–50%, default 5%) — not in the risk-ratio check. Do not relax the risk-ratio floor to make room for slippage. + +- The reduce-only order check is a **direction guard, not a size cap** — the debt-relative size cap was **removed** (see the design journal; superseded the earlier net-debt / round-up-to-lot bid cap and its `reduce_only_bid_cap` helper). It enforces reduce-only *semantics* ("only trade the position-reducing direction") and is symmetric across all four reduce-only entries: a **bid** requires base (short-side) debt (`base_debt > 0`); an **ask** requires quote (long-side) debt and sells up to gross base held (`quote_debt > 0 && quantity <= base_asset`). Neither side carries a debt-relative quantity cap — size is bounded by funding (the balance manager) plus the monotonic / post-repay solvency gate. **Why dropping the bid size cap is safe:** over-buying past the debt on the bid converts quote into base, and for a *base-denominated* debt that is **price-invariant** — 28 SUI held against 25 SUI debt is `ratio = 28/25` at *every* price, so it drives price exposure toward **zero** rather than increasing it. The direction guard is the real boundary: it blocks a *long* (`base_debt = 0`) from bidding, which is the only genuinely exposure-*increasing* misuse. (The old cap was also bypassable anyway — stacking reduce-only orders in one PTB exceeds the net debt — so it was friction, not enforcement.) Solvency is still the monotonic check while debt remains (a full close → `risk_ratio` MAX → check skipped). Guarded in `pool_proxy.move`'s four reduce-only entries. + +- **Sui-std CI gotcha (general).** `u64.div_ceil` is new in recent Sui std — CI's older toolchain fails to build with `No known method 'div_ceil' on type 'u64'`. For an integer ceiling use modulo (`x - x % m + m`); do **not** reach for `deepbook::math::div_round_up`, which is `FLOAT_SCALING`-scaled (a fixed-point divide), not a plain integer ceiling. + +- For **closing** (rather than strict reduce-only), prefer the non-reduce-only `pool_proxy::place_market_order_and_repay_loan`: it places a market order, repays the debt side *before* the gate, then applies the **net-state monotonic check** (post-repay `risk_ratio >=` the pre-trade ratio; skipped once a full repay clears the debt → `risk_ratio` MAX). **Gate history (this branch):** it first used `assert_post_trade_solvent` (the `min_open` floor), then switched to the monotonic gate so a danger-band position can improve *partially* without having to reach `min_open` (e.g. lift 1.12 → 1.15 even though 1.15 < `min_open`, which the floor rejected). Safe because a market (taker) fill settles immediately, so the monotonic check reflects the true final state: any exposure-*increasing* trade lowers the ratio and aborts, any deleveraging trade is allowed at any size. So there is **no quantity cap** — the monotonic gate *subsumes* the reduce-only cap for immediate fills — and an overshoot past the debt is fine (the repay caps debt reduction at the outstanding debt, zero debt has no bad-debt risk, surplus is the user's own holding, `assert_price` bounds slippage). Tested by `place_market_order_and_repay_loan_partial_close_below_min_open_ok`. Requires `pool_enabled`; in reduce-only mode use `place_reduce_only_market_order_and_repay_loan`. + +- The `…_and_repay_loan` pattern (repay before the solvency check) has a **limit** sibling, `place_reduce_only_limit_order_and_repay_loan`, for *price-bounded* reduces in the danger band. A limit order has three outcomes that matter here: **fully rests** (maker — no fill, locks balance counted in assets, ratio unchanged → the plain `place_reduce_only_limit_order_v2` already passes, no repay needed); **partial taker + maker rest** (the taker fills pay the spread, so the swap-only monotonic check in `…_v2` aborts — *this* is the gap the and-repay variant closes by repaying the settled taker proceeds first); **fully taker** (≈ a market order, covered by the market and-repay). So a `…_and_repay_loan` limit variant only earns its keep for the middle case — a crossing reduce-only limit. Note the repay uses `amount: none`, so it also pays down from *idle* balance, not only taker proceeds (intended: the `_and_repay` family deleverages; a user who just wants a resting order without deleveraging should use `place_reduce_only_limit_order_v2`). + +- **The monotonic gate is per-placement, not a cross-transaction invariant — and that's why the *direction* guard (not a size cap) protects the resting portion of a limit.** The gate runs **once, at placement**. A market order is 100% taker (check-time == final state — airtight, why `place_market_order_and_repay_loan` rides on monotonic alone). A crossing limit is taker (checked) **+** a resting maker remainder that is ratio-neutral at placement, so it passes invisibly and then fills **later, ungated and un-repaid** — the monotonic check guarantees *nothing* about that deferred fill. An earlier draft concluded "limits must keep a size cap on the resting remainder," but that's **superseded**: what actually keeps the deferred fill safe is the **direction guard**. A reduce-only bid is restricted to a *short* (`base_debt > 0`), and a short's resting bid — even one that over-covers — only converts quote→base, which is **price-invariant** (see above), so the ungated fill can't raise ratio-exposure. The dangerous case (a *long* placing a resting bid to grow its long) is blocked outright by the direction guard, not by a size bound. (Inherent maker risk remains: off-oracle resting fills can leak value, and because the monotonic check resets its baseline each placement, repeated cycles can *ratchet* the ratio down across txs — bounded by the `assert_price` band on the taker leg and the order TTL, and self-inflicted since order placement is owner-gated. This is true of any margin limit order, not specific to dropping the cap.) + +- **Self-trade / empty-book safety (audited, with PoC tests).** Why the ungated resting fill above can't be weaponized to drive a manager underwater without real price movement: (1) `assert_price` caps any single fill at ±5% of oracle, so one conversion leaks at most ~5%; (2) the **direction guard makes each manager one-way** — a short can *only* bid (convert quote→base), a long can *only* ask (base→quote), and a manager holds at most one debt side, so after one conversion it can't trade back to compound the leak (`base_debt > 0` for bids / `quote_debt > 0` for asks; `test_place_reduce_only_limit_order_ask_requires_quote_debt` pins it). 5% off a ratio `>=` liquidation (1.10) still lands `> 1.0`, so the manager stays solvent and liquidatable. The adversarial suite proves it: `resting_fill_from_danger_band_cannot_go_underwater` (M1 *at* 1.10, maximal self-trade → ~1.05, still > 1.0), `reduce_only_limit_resting_fill_at_band_edge_bounded_and_solvent`, `market_and_repay_empty_book_aborts` (empty book → `ENoLiquidityInOrderbook`, no execution surface), `resting_order_by_itself_is_ratio_neutral` (no counterparty ⇒ no ratio change). And `margin_manager::liquidate` calls `cancel_all_orders` + `withdraw_settled_amounts`, so collateral parked in resting orders is reclaimable — locked orders can't block recovery. + +- Risk-ratio config ordering (`calculate_risk_ratios` / `set_risk_params`): `liquidation_risk_ratio < min_borrow_risk_ratio < min_withdraw_risk_ratio`, and `liquidation_risk_ratio < target_liquidation_risk_ratio`, with `liquidation_risk_ratio >= float_scaling()`. Users in the `liquidation..min_borrow` "danger zone" cannot reach the borrow floor in a single swap; only an atomic deleveraging close (or liquidation) moves them up. + +- The post-trade solvency floor for *opening* (risk-increasing) orders is a **separate** threshold from the borrow gate: `min_open_risk_ratio`, strictly between `liquidation` and `min_borrow`. Why they must differ: the borrow gate passes at *exactly* `min_borrow` (pre-trade), but any opening trade pays the spread, so the post-trade ratio lands just under `min_borrow` — if `assert_post_trade_solvent` reused `min_borrow`, a max-leverage open would *always* abort. `min_open` (default = midpoint of liquidation and min_borrow) gives the open room to absorb its own spread while staying above the liquidatable zone. It's a per-pool admin override stored as an optional dynamic field (the `MaxOrderTtlKey` pattern); the getter derives the midpoint default from the pool's current `liquidation`/`min_borrow` (so it tracks risk-param edits) and **ignores a stranded override** outside `(liquidation, min_borrow]` rather than returning an unsafe value (self-heal). Only the opening paths (`place_limit_order_v2`/`place_market_order_v2` → `assert_post_trade_solvent`) use `min_open`; the borrow gate and the v2 TP/SL post-fill check still use `min_borrow`. + +- TP/SL conditional execution that must fire in the danger band has to **deleverage**, not just trade — `execute_conditional_orders_v3` repays the loan with the market proceeds after each fill, then gates on net (post-repay) monotonic `risk_ratio`. The v2 executor (swap-only, `min_borrow` gate) can't fire a danger-band stop and leaves the order looping the bot; v3 fixes both. Two design constraints worth remembering: (1) the executor is **permissionless**, so to call `repay` there the owner check was moved *out* of internal `repay` into the public `repay_base`/`repay_quote` wrappers — repay is owner-neutral (only moves the manager's own funds against its own debt, can't extract value), the same reason `withdraw_without_owner_check` backs both liquidation and repay. (2) Deleverage needs the margin pools as `&mut`, but `execute_conditional_orders_v2` takes them `&` — changing a **public** param from `&T` to `&mut T` breaks Sui upgrade compatibility, so this shipped as a **new** `_v3` entry, not a v2 signature change (same reason v1→v2 added entries). General rule for this deployed package: prefer a new `_vN` entry over changing any existing public signature; private-fn bodies, new functions, and new dynamic fields are the compatible levers. + +- **A pre-trade orderbook simulation is NOT binding under self-matching — enforce price bounds on the *realized* fill.** `place_triggered_orders`' price-bound check simulates a triggered market order via `pool.get_quote_quantity_out` / `get_quote_quantity_in`, which walks the **whole** book *including the manager's own resting maker orders*. But execution uses the order's `self_matching_option`: with `cancel_maker`, DeepBook cancels the manager's own makers first and the taker then fills **deeper into worse liquidity**, so the fill can settle outside the oracle bounds the simulation approved. DeepBook has **no self-match-aware quote** (`get_quote_quantity_out` takes no balance-manager param), so the fix is a **post-execution** check: after a triggered *market* order fills, re-verify the actual price `order_info.cumulative_quote_quantity() / executed_quantity()` against the same `(lower_bound, upper_bound)` and abort `EFillOutsidePriceBounds` (`margin_manager`, code 21). Limit fills are already bounded by their own limit price, so the check is market-only. Regression: `tpsl_cancel_maker_self_match_cannot_bypass_price_bounds`. General gotcha: any "simulate the fill, then place with `cancel_maker`" pattern has this pre-check/execution divergence — gate on the realized fill, not the simulated one. (Trade-off: the post-check **aborts** the conditional execution if a fill lands out of bounds, so a self-matching setup griefs the stop into staying pending — bounded, no fund loss, and the owner can cancel their own competing order; the alternative is to cancel the manager's own orders *before* the check so the simulation matches execution.) + ## Tool Calling Instructions - Use `sui move build --path packages/predict` from the repo root, or `sui move build` inside a package directory with `Move.toml`. diff --git a/packages/deepbook_margin/sources/margin_manager.move b/packages/deepbook_margin/sources/margin_manager.move index ed23e4abb..d1a40a99e 100644 --- a/packages/deepbook_margin/sources/margin_manager.move +++ b/packages/deepbook_margin/sources/margin_manager.move @@ -67,6 +67,11 @@ const EInsufficientRiskRatioAfterTrade: u64 = 19; /// Deprecated v1 entry was called. Use the `_v2` variant which enforces a /// post-trade risk_ratio invariant. const EDeprecatedUseV2: u64 = 20; +/// A triggered conditional **market** order settled outside the oracle price +/// bounds. The pre-check simulates the fill against the whole book, but +/// `cancel_maker` can remove the manager's own resting orders and fill deeper +/// into worse liquidity, so we re-check the *actual* executed price. +const EFillOutsidePriceBounds: u64 = 21; // === Structs === /// Witness type for authorizing MarginManager to call protected features of the DeepBook @@ -168,8 +173,18 @@ public struct WithdrawCollateralEvent has copy, drop { } // === Functions - Take Profit Stop Loss === -/// Add a conditional order. -/// Specifies the conditions under which the order is triggered and the pending order to be placed. +/// Add a conditional order (take-profit / stop-loss). Specifies the condition +/// under which it triggers and the pending order to place when it does. +/// +/// Lifetime: the conditional order itself is never clamped — it rests in the +/// queue until it triggers or is cancelled. A *market* pending order +/// (`tpsl::new_pending_market_order`) has no expiry, so it is the "until +/// cancelled" stop: it waits indefinitely and, when triggered, fires and +/// deleverages via `execute_conditional_orders_v3` (so it can protect even in +/// the danger band). A *limit* pending order is intentionally transient — when +/// it triggers, the resting order it places is clamped to `max_order_ttl_ms` +/// (default 3 days) by `clamp_expire_timestamp`, the same stale-price guard as +/// any margin limit order. For a permanent stop, use a market pending order. public fun add_conditional_order( self: &mut MarginManager, pool: &Pool, @@ -272,45 +287,85 @@ public fun execute_conditional_orders_v2( clock, ); + let orders_to_process = self.collect_triggered_orders(current_price); + let mut order_infos = vector[]; let mut executed_ids = vector[]; let mut expired_ids = vector[]; let mut insufficient_funds_ids = vector[]; let mut out_of_bounds_ids = vector[]; - // Collect orders to process (to avoid borrow conflicts) - let mut orders_to_process = vector[]; - - // Collect trigger_below orders (sorted high to low) - let mut i = 0; - while (i < self.take_profit_stop_loss.trigger_below().length()) { - let conditional_order = &self.take_profit_stop_loss.trigger_below()[i]; + self.process_collected_orders_v2( + pool, + registry, + base_margin_pool, + quote_margin_pool, + base_price_info_object, + quote_price_info_object, + orders_to_process, + &mut order_infos, + &mut executed_ids, + &mut expired_ids, + &mut insufficient_funds_ids, + &mut out_of_bounds_ids, + max_orders_to_execute, + clock, + ctx, + ); - // Break early if price doesn't trigger - if (current_price >= conditional_order.condition().trigger_price()) { - break - }; + self.finalize_conditional_execution( + pool.id(), + expired_ids, + insufficient_funds_ids, + out_of_bounds_ids, + executed_ids, + clock, + ); - orders_to_process.push_back(*conditional_order); - i = i + 1; - }; + order_infos +} - // Collect trigger_above orders (sorted low to high) - i = 0; - while (i < self.take_profit_stop_loss.trigger_above().length()) { - let conditional_order = &self.take_profit_stop_loss.trigger_above()[i]; +/// Execute conditional orders, deleveraging on each market-type fill. +/// Permissionless, like `execute_conditional_orders_v2`, with the same trigger +/// and cancellation handling — but takes the margin pools as `&mut` and repays +/// the loan with the market proceeds before gating on the net (post-repay) +/// `risk_ratio` being at least the pre-fill ratio. +/// +/// This is what lets a stop-loss fire in the `liquidation..min_borrow` danger +/// band: a swap alone only lowers the oracle-valued ratio (so the v2 borrow-floor +/// gate rejects it), while repaying actually improves it. If a single triggered +/// fill would worsen net solvency the whole txn aborts — no partial-state +/// landing. +public fun execute_conditional_orders_v3( + self: &mut MarginManager, + pool: &mut Pool, + base_margin_pool: &mut MarginPool, + quote_margin_pool: &mut MarginPool, + base_price_info_object: &PriceInfoObject, + quote_price_info_object: &PriceInfoObject, + registry: &MarginRegistry, + max_orders_to_execute: u64, + clock: &Clock, + ctx: &mut TxContext, +): vector { + registry.load_inner(); + assert!(pool.id() == self.deepbook_pool(), EIncorrectDeepBookPool); + let current_price = calculate_price( + registry, + base_price_info_object, + quote_price_info_object, + clock, + ); - // Break early if price doesn't trigger - if (current_price <= conditional_order.condition().trigger_price()) { - break - }; + let orders_to_process = self.collect_triggered_orders(current_price); - orders_to_process.push_back(*conditional_order); - i = i + 1; - }; + let mut order_infos = vector[]; + let mut executed_ids = vector[]; + let mut expired_ids = vector[]; + let mut insufficient_funds_ids = vector[]; + let mut out_of_bounds_ids = vector[]; - // Process collected orders - self.process_collected_orders_v2( + self.process_collected_orders_v3( pool, registry, base_margin_pool, @@ -328,32 +383,14 @@ public fun execute_conditional_orders_v2( ctx, ); - let manager_id = self.id(); - let pool_id = pool.id(); - - insufficient_funds_ids.do!(|id| { - self.take_profit_stop_loss.emit_insufficient_funds_event(manager_id, id, clock); - }); - - out_of_bounds_ids.do!(|id| { - self.take_profit_stop_loss.emit_price_out_of_bounds_event(manager_id, id, clock); - }); - - let mut cancelled_ids = expired_ids; - cancelled_ids.append(insufficient_funds_ids); - cancelled_ids.append(out_of_bounds_ids); - cancelled_ids.do!(|id| { - self.take_profit_stop_loss.cancel_conditional_order(manager_id, id, clock); - }); - - self - .take_profit_stop_loss - .remove_executed_conditional_orders( - manager_id, - pool_id, - executed_ids, - clock, - ); + self.finalize_conditional_execution( + pool.id(), + expired_ids, + insufficient_funds_ids, + out_of_bounds_ids, + executed_ids, + clock, + ); order_infos } @@ -842,11 +879,11 @@ public fun liquidate( clock, ); let quote_out = max_quote_out.min(quote_asset); - let base_coin = self.liquidation_withdraw( + let base_coin = self.withdraw_without_owner_check( base_out, ctx, ); - let quote_coin = self.liquidation_withdraw( + let quote_coin = self.withdraw_without_owner_check( quote_out, ctx, ); @@ -862,11 +899,11 @@ public fun liquidate( clock, ); let base_out = max_base_out.min(base_asset); - let base_coin = self.liquidation_withdraw( + let base_coin = self.withdraw_without_owner_check( base_out, ctx, ); - let quote_coin = self.liquidation_withdraw( + let quote_coin = self.withdraw_without_owner_check( quote_out, ctx, ); @@ -1476,8 +1513,14 @@ fun validate_owner( assert!(ctx.sender() == self.owner, EInvalidMarginManagerOwner); } -/// Repays the loan using the margin manager. -/// Returns the total amount repaid +/// Repays the loan using the margin manager. Returns the total amount repaid. +/// +/// Does not check ownership: owner-gating is the caller's responsibility. The +/// public `repay_base`/`repay_quote` wrappers validate the owner; the +/// permissionless conditional executor (`execute_conditional_orders_v3`) +/// intentionally repays without an owner check, which is safe because repay only +/// moves the manager's own funds to pay down its own debt — it deleverages and +/// cannot extract value. fun repay( self: &mut MarginManager, margin_pool: &mut MarginPool, @@ -1492,7 +1535,7 @@ fun repay( let repay_amount = repay_amount.min(borrowed_amount); let repay_shares = math::mul(borrowed_shares, math::div(repay_amount, borrowed_amount)); - let coin: Coin = self.repay_withdraw(repay_amount, ctx); + let coin: Coin = self.withdraw_without_owner_check(repay_amount, ctx); margin_pool.repay(repay_shares, coin, clock); if (type_name::with_defining_ids() == type_name::with_defining_ids()) { @@ -1516,7 +1559,11 @@ fun repay( repay_amount } -fun liquidation_withdraw( +/// Withdraws from the manager's balance manager without checking ownership. +/// Callers must either gate on the owner themselves or perform an owner-neutral +/// operation: liquidation (anyone may unwind an unhealthy manager) and repay +/// (only deleverages, paying the manager's own debt). +fun withdraw_without_owner_check( self: &mut MarginManager, withdraw_amount: u64, ctx: &mut TxContext, @@ -1530,24 +1577,6 @@ fun liquidation_withdraw( ) } -/// This can only be called by the manager owner -fun repay_withdraw( - self: &mut MarginManager, - withdraw_amount: u64, - ctx: &mut TxContext, -): Coin { - validate_owner(self, ctx); - let balance_manager = &mut self.balance_manager; - - let coin = balance_manager.withdraw_with_cap( - &self.withdraw_cap, - withdraw_amount, - ctx, - ); - - coin -} - /// Helper function to determine if margin manager can borrow from a margin pool fun can_borrow( self: &MarginManager, @@ -1716,22 +1745,49 @@ fun place_market_order_conditional_v2( ) } -/// Helper function to process collected conditional orders. -/// -/// After each successful `place_pending_order_v2` call, recompute `risk_ratio` -/// against Pyth via the public `risk_ratio` helper. If the manager has debt -/// and the post-fill ratio is below `min_borrow_risk_ratio`, abort the entire -/// txn with `EInsufficientRiskRatioAfterTrade`. We do not allow partial-fill -/// landing — one bad fill reverts everything so the manager is never left in -/// a state borrowing would have been forbidden from. -fun process_collected_orders_v2( +/// Collects the conditional orders whose trigger condition is met at +/// `current_price`. `trigger_below` orders fire when price falls to/below their +/// trigger (stored high→low, so stop at the first that doesn't fire); +/// `trigger_above` orders fire when price rises to/above their trigger (stored +/// low→high). +fun collect_triggered_orders( + self: &MarginManager, + current_price: u64, +): vector { + let mut orders_to_process = vector[]; + + let mut i = 0; + while (i < self.take_profit_stop_loss.trigger_below().length()) { + let conditional_order = &self.take_profit_stop_loss.trigger_below()[i]; + if (current_price >= conditional_order.condition().trigger_price()) { + break + }; + orders_to_process.push_back(*conditional_order); + i = i + 1; + }; + + i = 0; + while (i < self.take_profit_stop_loss.trigger_above().length()) { + let conditional_order = &self.take_profit_stop_loss.trigger_above()[i]; + if (current_price <= conditional_order.condition().trigger_price()) { + break + }; + orders_to_process.push_back(*conditional_order); + i = i + 1; + }; + + orders_to_process +} + +/// Places the triggered conditional orders, routing each to its outcome bucket +/// (executed / expired / insufficient-funds / out-of-bounds). Shared by the v2 +/// and v3 executors; the solvency gate and any deleveraging are applied by the +/// caller. Returns whether any *market* order executed (the v3 executor repays +/// only then). +fun place_triggered_orders( self: &mut MarginManager, pool: &mut Pool, registry: &MarginRegistry, - base_margin_pool: &MarginPool, - quote_margin_pool: &MarginPool, - base_oracle: &PriceInfoObject, - quote_oracle: &PriceInfoObject, orders: vector, order_infos: &mut vector, executed_ids: &mut vector, @@ -1741,7 +1797,8 @@ fun process_collected_orders_v2( max_orders_to_execute: u64, clock: &Clock, ctx: &TxContext, -) { +): bool { + let mut market_filled = false; let mut i = 0; while (i < orders.length() && order_infos.length() < max_orders_to_execute) { let conditional_order = &orders[i]; @@ -1829,6 +1886,26 @@ fun process_collected_orders_v2( ctx, ); + if (!pending_order.is_limit_order()) { + // Self-match-aware safety net: the price-bound pre-check above + // simulated the fill against the full book, but a market order + // with `cancel_maker` can cancel the manager's own resting orders + // and fill deeper into worse liquidity. Re-check the *actual* + // executed price so a self-match can't bypass the oracle bounds. + // (Limit fills are already bounded by their own limit price.) + let executed = order_info.executed_quantity(); + if (executed > 0) { + let actual_price = math::div( + order_info.cumulative_quote_quantity(), + executed, + ); + assert!( + (is_bid && actual_price <= upper_bound) || (!is_bid && actual_price >= lower_bound), + EFillOutsidePriceBounds, + ); + }; + market_filled = true; + }; order_infos.push_back(order_info); executed_ids.push_back(conditional_order_id); } else { @@ -1847,12 +1924,85 @@ fun process_collected_orders_v2( i = i + 1; }; - // Post-loop solvency check. Move PTBs are atomic, so checking once after - // all fills is functionally equivalent to checking after each fill — a - // breach of the invariant aborts the whole txn either way. Skipped when - // no fills landed (executed_ids empty) and skipped when manager has no - // debt (nothing to be insolvent against). Saves up to N-1 Pyth reads on - // the happy path versus per-fill checking. + market_filled +} + +/// Emits the outcome events and reconciles the conditional-order queue: +/// insufficient-funds, out-of-bounds, and expired orders are cancelled (with +/// their event); executed orders are removed. Shared by the v2 and v3 executors. +fun finalize_conditional_execution( + self: &mut MarginManager, + pool_id: ID, + expired_ids: vector, + insufficient_funds_ids: vector, + out_of_bounds_ids: vector, + executed_ids: vector, + clock: &Clock, +) { + let manager_id = self.id(); + + insufficient_funds_ids.do!(|id| { + self.take_profit_stop_loss.emit_insufficient_funds_event(manager_id, id, clock); + }); + + out_of_bounds_ids.do!(|id| { + self.take_profit_stop_loss.emit_price_out_of_bounds_event(manager_id, id, clock); + }); + + let mut cancelled_ids = expired_ids; + cancelled_ids.append(insufficient_funds_ids); + cancelled_ids.append(out_of_bounds_ids); + cancelled_ids.do!(|id| { + self.take_profit_stop_loss.cancel_conditional_order(manager_id, id, clock); + }); + + self + .take_profit_stop_loss + .remove_executed_conditional_orders( + manager_id, + pool_id, + executed_ids, + clock, + ); +} + +/// v2 solvency gate (legacy): places the triggered orders, then requires a +/// post-fill `risk_ratio >= min_borrow_risk_ratio` whenever a fill landed and +/// the manager has debt. A swap alone only lowers the oracle-valued ratio, so a +/// stop-loss in the `liquidation..min_borrow` band aborts here — use +/// `execute_conditional_orders_v3`, which deleverages and uses a monotonic gate. +fun process_collected_orders_v2( + self: &mut MarginManager, + pool: &mut Pool, + registry: &MarginRegistry, + base_margin_pool: &MarginPool, + quote_margin_pool: &MarginPool, + base_oracle: &PriceInfoObject, + quote_oracle: &PriceInfoObject, + orders: vector, + order_infos: &mut vector, + executed_ids: &mut vector, + expired_ids: &mut vector, + insufficient_funds_ids: &mut vector, + out_of_bounds_ids: &mut vector, + max_orders_to_execute: u64, + clock: &Clock, + ctx: &TxContext, +) { + self.place_triggered_orders( + pool, + registry, + orders, + order_infos, + executed_ids, + expired_ids, + insufficient_funds_ids, + out_of_bounds_ids, + max_orders_to_execute, + clock, + ctx, + ); + if ( !executed_ids.is_empty() && (self.borrowed_base_shares > 0 || self.borrowed_quote_shares > 0) @@ -1873,6 +2023,100 @@ fun process_collected_orders_v2( }; } +/// v3 solvency gate with deleveraging. Captures `risk_ratio` before any fill, +/// places the triggered orders, repays the debt side with the market proceeds, +/// then requires the net (post-repay) `risk_ratio` to be at least the pre-fill +/// ratio. Deleveraging lets a stop-loss fire even in the `liquidation..min_borrow` +/// danger band — a swap alone only lowers the oracle-valued ratio, so the v2 +/// borrow-floor gate would reject it. Repay is skipped when no market order +/// filled (resting limit orders don't change the ratio) or the manager has no +/// debt; the monotonic gate is skipped when the manager had no debt to begin +/// with. +fun process_collected_orders_v3( + self: &mut MarginManager, + pool: &mut Pool, + registry: &MarginRegistry, + base_margin_pool: &mut MarginPool, + quote_margin_pool: &mut MarginPool, + base_oracle: &PriceInfoObject, + quote_oracle: &PriceInfoObject, + orders: vector, + order_infos: &mut vector, + executed_ids: &mut vector, + expired_ids: &mut vector, + insufficient_funds_ids: &mut vector, + out_of_bounds_ids: &mut vector, + max_orders_to_execute: u64, + clock: &Clock, + ctx: &mut TxContext, +) { + let has_debt = self.borrowed_base_shares > 0 || self.borrowed_quote_shares > 0; + let risk_ratio_before = if (has_debt) { + self.risk_ratio( + registry, + base_oracle, + quote_oracle, + pool, + base_margin_pool, + quote_margin_pool, + clock, + ) + } else { + 0 + }; + + let market_filled = self.place_triggered_orders( + pool, + registry, + orders, + order_infos, + executed_ids, + expired_ids, + insufficient_funds_ids, + out_of_bounds_ids, + max_orders_to_execute, + clock, + ctx, + ); + + // Deleverage with the market proceeds so the fill improves (not just holds) + // solvency. The repay only moves the manager's own funds against its own + // debt, so it is safe in this permissionless path. A manager holds at most + // one debt side at a time (single `margin_pool_id: Option`, enforced by + // `can_borrow`), so `has_base_debt()`-else-quote is an exhaustive dispatch, + // not a both-sides-simultaneously assumption. + if (market_filled && has_debt) { + if (self.has_base_debt()) { + self.repay( + base_margin_pool, + option::none(), + clock, + ctx, + ); + } else { + self.repay( + quote_margin_pool, + option::none(), + clock, + ctx, + ); + }; + }; + + if (has_debt && !executed_ids.is_empty()) { + let risk_ratio_after = self.risk_ratio( + registry, + base_oracle, + quote_oracle, + pool, + base_margin_pool, + quote_margin_pool, + clock, + ); + assert!(risk_ratio_after >= risk_ratio_before, EInsufficientRiskRatioAfterTrade); + }; +} + fun balance_manager_unsafe_mut( self: &mut MarginManager, ): &mut BalanceManager { diff --git a/packages/deepbook_margin/sources/margin_registry.move b/packages/deepbook_margin/sources/margin_registry.move index ecdebec29..e21a65708 100644 --- a/packages/deepbook_margin/sources/margin_registry.move +++ b/packages/deepbook_margin/sources/margin_registry.move @@ -102,6 +102,11 @@ public struct CurrentPriceData has store { /// Stored value is `u64`; absent means use `margin_constants::default_max_order_ttl_ms()`. public struct MaxOrderTtlKey(ID) has copy, drop, store; +/// Dynamic field key for the per-pool `min_open_risk_ratio` override (9 +/// decimals). Stored value is `u64`; absent means use the midpoint of +/// `liquidation_risk_ratio` and `min_borrow_risk_ratio`. +public struct MinOpenRiskRatioKey(ID) has copy, drop, store; + // === Caps === public struct MarginAdminCap has key, store { id: UID, @@ -175,6 +180,12 @@ public struct MaxOrderTtlUpdated has copy, drop { timestamp: u64, } +public struct MinOpenRiskRatioUpdated has copy, drop { + pool_id: ID, + min_open_risk_ratio: u64, + timestamp: u64, +} + fun init(_: MARGIN_REGISTRY, ctx: &mut TxContext) { let id = object::new(ctx); let margin_registry_inner = MarginRegistryInner { @@ -543,6 +554,43 @@ public fun set_max_order_ttl( }); } +/// Set the per-pool `min_open_risk_ratio` override — the post-trade solvency +/// floor enforced on opening (risk-increasing) orders. Must sit in +/// `(liquidation_risk_ratio, min_borrow_risk_ratio]`: above liquidation so an +/// open can't land in the liquidatable zone, at or below the borrow floor. +/// Absent an override the floor defaults to the midpoint of liquidation and +/// min_borrow. Only Admin can set it. +public fun set_min_open_risk_ratio( + self: &mut MarginRegistry, + _admin_cap: &MarginAdminCap, + pool: &Pool, + min_open_risk_ratio: u64, + clock: &Clock, +) { + self.load_inner(); + let pool_id = pool.id(); + let liquidation = self.liquidation_risk_ratio(pool_id); + let min_borrow = self.min_borrow_risk_ratio(pool_id); + assert!( + min_open_risk_ratio > liquidation && min_open_risk_ratio <= min_borrow, + EInvalidRiskParam, + ); + + let key = MinOpenRiskRatioKey(pool_id); + if (self.id.exists_with_type(key)) { + let stored: &mut u64 = self.id.borrow_mut(key); + *stored = min_open_risk_ratio; + } else { + self.id.add(key, min_open_risk_ratio); + }; + + event::emit(MinOpenRiskRatioUpdated { + pool_id, + min_open_risk_ratio, + timestamp: clock.timestamp_ms(), + }); +} + // === Public Helper Functions === /// Create a PoolConfig with margin pool IDs and risk parameters /// Enable is false by default, must be enabled after registration @@ -690,6 +738,28 @@ public fun target_liquidation_risk_ratio(self: &MarginRegistry, deepbook_pool_id config.risk_ratios.target_liquidation_risk_ratio } +/// Post-trade solvency floor for *opening* (risk-increasing) orders, sitting in +/// `(liquidation_risk_ratio, min_borrow_risk_ratio]`. Lets a max-leverage open +/// absorb the opening trade's spread — which lands the post-trade ratio just +/// under the borrow floor — without aborting, while staying above the +/// liquidatable zone. Defaults to the midpoint of liquidation and min_borrow; an +/// admin override (`set_min_open_risk_ratio`) is honored only while it stays in +/// the valid band, so a later risk-param change can't strand it below +/// liquidation. +public fun min_open_risk_ratio(self: &MarginRegistry, deepbook_pool_id: ID): u64 { + let liquidation = self.liquidation_risk_ratio(deepbook_pool_id); + let min_borrow = self.min_borrow_risk_ratio(deepbook_pool_id); + let default = (liquidation + min_borrow) / 2; + + let key = MinOpenRiskRatioKey(deepbook_pool_id); + if (self.id.exists_with_type(key)) { + let stored = *self.id.borrow(key); + if (stored > liquidation && stored <= min_borrow) stored else default + } else { + default + } +} + public fun user_liquidation_reward(self: &MarginRegistry, deepbook_pool_id: ID): u64 { let config = self.get_pool_config(deepbook_pool_id); config.user_liquidation_reward diff --git a/packages/deepbook_margin/sources/pool_proxy.move b/packages/deepbook_margin/sources/pool_proxy.move index b771277d3..358678342 100644 --- a/packages/deepbook_margin/sources/pool_proxy.move +++ b/packages/deepbook_margin/sources/pool_proxy.move @@ -21,14 +21,16 @@ const EPoolNotEnabledForMarginTrading: u64 = 2; const ENotReduceOnlyOrder: u64 = 3; const EIncorrectDeepBookPool: u64 = 4; const ENoLiquidityInOrderbook: u64 = 5; -/// Post-trade risk ratio dropped below `min_borrow_risk_ratio`. -/// Raised by the v2 order placement entries when the manager would be left -/// in a state borrowing would be forbidden from. +/// Post-trade risk ratio dropped below `min_open_risk_ratio` (the opening +/// solvency floor between liquidation and the borrow floor). Raised by the v2 +/// order placement entries when an opening trade would leave the manager in the +/// liquidatable zone. const EInsufficientRiskRatioAfterTrade: u64 = 6; -/// Reduce-only fill leaked value to the counterparty: the manager's -/// risk_ratio after the trade is lower than before. Reduce-only orders must -/// monotonically improve (or hold) solvency. -const EReduceOnlyMustImproveRiskRatio: u64 = 7; +/// A risk-reducing fill left the manager's net (post-repay) risk_ratio lower +/// than before the trade. The reduce-only entries and the market close+repay +/// tool (`place_market_order_and_repay_loan`) require the post-trade ratio to +/// monotonically improve (or hold). +const ERiskRatioMustNotWorsen: u64 = 7; /// Deprecated v1 entry was called. Use the `_v2` variant which enforces a /// post-trade risk_ratio invariant. const EDeprecatedUseV2: u64 = 8; @@ -140,10 +142,11 @@ public fun place_reduce_only_market_order( // Each v2 entry mirrors its v1 counterpart and additionally recomputes // `risk_ratio` after the order settles (using Pyth via the public // `MarginManager::risk_ratio` helper). For non-reduce-only entries the -// post-trade ratio must be at least `min_borrow_risk_ratio` — same threshold -// the borrow path enforces, so trading cannot push a manager below where -// borrowing was already forbidden. Skipped when the manager has no debt -// (nothing to be insolvent against). +// post-trade ratio must be at least `min_open_risk_ratio` — a floor between +// liquidation and the borrow floor, so a max-leverage open can absorb the +// opening trade's spread (which lands the ratio just under `min_borrow`) +// without aborting, while staying above the liquidatable zone. Skipped when +// the manager has no debt (nothing to be insolvent against). // // For reduce-only entries the post-trade ratio must be `>= risk_ratio_before` // (monotonic improvement). The borrow-floor check would trap users in the @@ -299,13 +302,18 @@ public fun place_reduce_only_limit_order_v2( } else { margin_manager.calculate_debts(quote_margin_pool, clock) }; - let (base_asset, quote_asset) = margin_manager.calculate_assets( - pool, - ); - + let (base_asset, _) = margin_manager.calculate_assets(pool); + + // Reduce-only *direction* guard, symmetric for both sides — no debt-relative + // size cap. A bid requires base (short-side) debt and may buy any size: + // funding bounds it, and over-buying past the debt only converts quote into + // base, which for a base-denominated debt is price-invariant (it *reduces* + // price exposure, never increases it). The ask requires quote (long-side) debt + // and sells up to the gross base held. The monotonic risk-ratio check below + // guards value leak. assert!( - (is_bid && base_debt > base_asset && quantity <= base_debt - base_asset) || - (!is_bid && quote_debt > quote_asset && math::mul(quantity, price) <= quote_debt - quote_asset), + (is_bid && base_debt > 0) || + (!is_bid && quote_debt > 0 && quantity <= base_asset), ENotReduceOnlyOrder, ); @@ -355,7 +363,16 @@ public fun place_reduce_only_limit_order_v2( order_info } -/// Places a reduce-only market order in the pool. Used when margin trading is disabled. +/// Places a reduce-only market order in the pool. Used when margin trading is +/// disabled. +/// +/// Superseded by `place_reduce_only_market_order_and_repay_loan`. A market +/// (taker) fill always pays the spread, which lowers the oracle-valued +/// `risk_ratio` while the debt is unchanged, so the swap-only monotonic check +/// here rejects essentially every taker fill. The `_and_repay` variant +/// deleverages with the proceeds so the net-state ratio actually improves. Kept +/// callable for existing integrators; its quantity caps match the other +/// reduce-only entries (gross-base ask, round-up-to-lot bid). public fun place_reduce_only_market_order_v2( registry: &MarginRegistry, margin_manager: &mut MarginManager, @@ -380,11 +397,9 @@ public fun place_reduce_only_market_order_v2( } else { margin_manager.calculate_debts(quote_margin_pool, clock) }; - let (base_asset, quote_asset) = margin_manager.calculate_assets( - pool, - ); + let (base_asset, _) = margin_manager.calculate_assets(pool); - let (effective_price, quote_quantity) = calculate_effective_price( + let (effective_price, _) = calculate_effective_price( pool, quantity, is_bid, @@ -392,11 +407,14 @@ public fun place_reduce_only_market_order_v2( clock, ); - // The order is a bid, and quantity is less than the net base debt. - // The order is a ask, and quote quantity is less than the net quote debt. + // Reduce-only *direction* guard, same as the other entries — no size cap: a + // bid needs base (short-side) debt, the ask needs quote (long-side) debt and + // sells up to gross base held. Superseded for closing (see the doc above) — a + // market taker fill can't satisfy the monotonic check without a repay, so use + // `place_reduce_only_market_order_and_repay_loan`. assert!( - (is_bid && base_debt > base_asset && quantity <= base_debt - base_asset) || - (!is_bid && quote_debt > quote_asset && quote_quantity <= quote_debt - quote_asset), + (is_bid && base_debt > 0) || + (!is_bid && quote_debt > 0 && quantity <= base_asset), ENotReduceOnlyOrder, ); @@ -445,6 +463,344 @@ public fun place_reduce_only_market_order_v2( order_info } +/// Atomically winds down a leveraged position: places a reduce-only market +/// order, repays the loan with the proceeds, then requires the net (post-repay) +/// risk ratio to be at least the pre-trade ratio. +/// +/// The post-repay check is the point. A market close pays the spread, which +/// alone lowers the oracle-valued ratio (debt is unchanged until repay) and +/// would abort the plain reduce-only path. Repaying first deleverages and +/// absorbs the slippage (still bounded by the `assert_price` band), and lets a +/// manager in the `liquidation..min_borrow` band climb out — it cannot reach +/// the borrow floor in a single swap. +public fun place_reduce_only_market_order_and_repay_loan( + registry: &MarginRegistry, + margin_manager: &mut MarginManager, + pool: &mut Pool, + base_margin_pool: &mut MarginPool, + quote_margin_pool: &mut MarginPool, + base_oracle: &PriceInfoObject, + quote_oracle: &PriceInfoObject, + client_order_id: u64, + self_matching_option: u8, + quantity: u64, + is_bid: bool, + pay_with_deep: bool, + clock: &Clock, + ctx: &mut TxContext, +): OrderInfo { + registry.load_inner(); + assert!(margin_manager.deepbook_pool() == pool.id(), EIncorrectDeepBookPool); + + let (base_debt, quote_debt) = if (margin_manager.has_base_debt()) { + margin_manager.calculate_debts(base_margin_pool, clock) + } else { + margin_manager.calculate_debts(quote_margin_pool, clock) + }; + let (base_asset, _) = margin_manager.calculate_assets(pool); + + let (effective_price, _) = calculate_effective_price( + pool, + quantity, + is_bid, + pay_with_deep, + clock, + ); + + // Reduce-only *direction* guard, symmetric for both sides — no debt-relative + // size cap. A bid requires base (short-side) debt and may buy any size: + // over-buying past the debt converts quote into base, price-invariant for a + // base-denominated debt (it *reduces* price exposure, never increases it), and + // the repay below caps the actual debt paydown at the outstanding debt. The + // ask requires quote (long-side) debt and sells up to gross base held. The + // net-state monotonic check below guards value leak. + assert!( + (is_bid && base_debt > 0) || + (!is_bid && quote_debt > 0 && quantity <= base_asset), + ENotReduceOnlyOrder, + ); + + registry.assert_price(pool.id(), effective_price, is_bid, clock); + + let risk_ratio_before = margin_manager.risk_ratio( + registry, + base_oracle, + quote_oracle, + pool, + base_margin_pool, + quote_margin_pool, + clock, + ); + + let trade_proof = margin_manager.trade_proof(ctx); + let balance_manager = margin_manager.balance_manager_trading_mut(ctx); + let order_info = pool.place_market_order( + balance_manager, + &trade_proof, + client_order_id, + self_matching_option, + quantity, + is_bid, + pay_with_deep, + clock, + ctx, + ); + + // place_market_order settles the taker fill into the manager's balance, so + // the proceeds are drawable. Repay the debt side with that balance. + if (margin_manager.has_base_debt()) { + margin_manager.repay_base(registry, base_margin_pool, option::none(), clock, ctx); + } else { + margin_manager.repay_quote(registry, quote_margin_pool, option::none(), clock, ctx); + }; + + // Net-state solvency: if debt remains, the close must not have worsened the + // ratio. A full repay clears the debt, so the check is skipped. + if ( + margin_manager.borrowed_base_shares() > 0 + || margin_manager.borrowed_quote_shares() > 0 + ) { + let risk_ratio_after = margin_manager.risk_ratio( + registry, + base_oracle, + quote_oracle, + pool, + base_margin_pool, + quote_margin_pool, + clock, + ); + assert!(risk_ratio_after >= risk_ratio_before, ERiskRatioMustNotWorsen); + }; + + order_info +} + +/// Reduce-only **limit** order that atomically repays the loan with the taker +/// fills. It is the limit/maker behaviour of `place_reduce_only_limit_order_v2` +/// plus the repay-then-net-monotonic gate of +/// `place_reduce_only_market_order_and_repay_loan`: the portion that crosses the +/// book fills immediately and settles, the rest rests as a maker, then the +/// settled (taker) proceeds repay the debt before the monotonic check on the net +/// (post-repay) state. +/// +/// This is the danger-band tool for a *price-bounded* reduce: a crossing +/// reduce-only limit pays the spread on its taker fills, which alone would abort +/// `place_reduce_only_limit_order_v2`'s swap-only monotonic check; repaying first +/// deleverages so the net ratio holds. The resting remainder only locks balance +/// (counted in assets), so it doesn't move the ratio. Unfilled-and-resting +/// behaves exactly like `place_reduce_only_limit_order_v2` (nothing to repay). +public fun place_reduce_only_limit_order_and_repay_loan( + registry: &MarginRegistry, + margin_manager: &mut MarginManager, + pool: &mut Pool, + base_margin_pool: &mut MarginPool, + quote_margin_pool: &mut MarginPool, + base_oracle: &PriceInfoObject, + quote_oracle: &PriceInfoObject, + client_order_id: u64, + order_type: u8, + self_matching_option: u8, + price: u64, + quantity: u64, + is_bid: bool, + pay_with_deep: bool, + expire_timestamp: u64, + clock: &Clock, + ctx: &mut TxContext, +): OrderInfo { + registry.load_inner(); + assert!(margin_manager.deepbook_pool() == pool.id(), EIncorrectDeepBookPool); + + registry.assert_price(pool.id(), price, is_bid, clock); + let expire_timestamp = registry.clamp_expire_timestamp(pool.id(), expire_timestamp, clock); + + let (base_debt, quote_debt) = if (margin_manager.has_base_debt()) { + margin_manager.calculate_debts(base_margin_pool, clock) + } else { + margin_manager.calculate_debts(quote_margin_pool, clock) + }; + let (base_asset, _) = margin_manager.calculate_assets(pool); + + // Same reduce-only direction guard as `place_reduce_only_limit_order_v2` (see + // there) — no size cap: a bid needs base (short-side) debt (any size, funding- + // bounded), the ask needs quote (long-side) debt and sells up to gross base + // held. + assert!( + (is_bid && base_debt > 0) || + (!is_bid && quote_debt > 0 && quantity <= base_asset), + ENotReduceOnlyOrder, + ); + + let risk_ratio_before = margin_manager.risk_ratio( + registry, + base_oracle, + quote_oracle, + pool, + base_margin_pool, + quote_margin_pool, + clock, + ); + + let trade_proof = margin_manager.trade_proof(ctx); + let balance_manager = margin_manager.balance_manager_trading_mut(ctx); + let order_info = pool.place_limit_order( + balance_manager, + &trade_proof, + client_order_id, + order_type, + self_matching_option, + price, + quantity, + is_bid, + pay_with_deep, + expire_timestamp, + clock, + ctx, + ); + + // Repay the debt side with the settled taker fills before the monotonic + // check, so a crossing reduce-only limit deleverages instead of aborting. A + // fully-resting order has no taker proceeds, so this repays nothing. + if (margin_manager.has_base_debt()) { + margin_manager.repay_base(registry, base_margin_pool, option::none(), clock, ctx); + } else { + margin_manager.repay_quote(registry, quote_margin_pool, option::none(), clock, ctx); + }; + + // Net-state solvency: if debt remains, the order must not have worsened the + // ratio. A full repay clears the debt, so the check is skipped. + if ( + margin_manager.borrowed_base_shares() > 0 + || margin_manager.borrowed_quote_shares() > 0 + ) { + let risk_ratio_after = margin_manager.risk_ratio( + registry, + base_oracle, + quote_oracle, + pool, + base_margin_pool, + quote_margin_pool, + clock, + ); + assert!(risk_ratio_after >= risk_ratio_before, ERiskRatioMustNotWorsen); + }; + + order_info +} + +/// Atomically places a market order and repays the loan with the proceeds, +/// gating on a **monotonic** net-state check: if any debt remains after the +/// repay, the post-repay `risk_ratio` must be at least the pre-trade ratio +/// (improve-or-hold). A full close drives debt to 0 (`risk_ratio` MAX), which +/// always passes. +/// +/// This is the everyday close / deleverage tool. The monotonic gate — rather than +/// the `min_open` opening floor used by `place_market_order_v2` — lets a position +/// in the `liquidation..min_borrow` danger band wind down *partially*: a small +/// close that lifts the ratio from, say, 1.12 to 1.15 is allowed even though 1.15 +/// is still below `min_open`, which the opening floor would reject. +/// +/// Not reduce-only and uncapped, but the monotonic check makes a quantity cap +/// unnecessary: a market (taker) fill settles immediately, so any genuinely +/// exposure-*increasing* trade lowers the ratio and aborts here, while any +/// deleveraging trade is allowed at any size — an overshoot past the debt is fine +/// (surplus is the manager's own holding) and `assert_price` still bounds +/// slippage. Requires margin trading enabled; in reduce-only mode use +/// `place_reduce_only_market_order_and_repay_loan`. +public fun place_market_order_and_repay_loan( + registry: &MarginRegistry, + margin_manager: &mut MarginManager, + pool: &mut Pool, + base_margin_pool: &mut MarginPool, + quote_margin_pool: &mut MarginPool, + base_oracle: &PriceInfoObject, + quote_oracle: &PriceInfoObject, + client_order_id: u64, + self_matching_option: u8, + quantity: u64, + is_bid: bool, + pay_with_deep: bool, + clock: &Clock, + ctx: &mut TxContext, +): OrderInfo { + registry.load_inner(); + assert!(margin_manager.deepbook_pool() == pool.id(), EIncorrectDeepBookPool); + assert!(registry.pool_enabled(pool), EPoolNotEnabledForMarginTrading); + + let (effective_price, _) = calculate_effective_price( + pool, + quantity, + is_bid, + pay_with_deep, + clock, + ); + registry.assert_price(pool.id(), effective_price, is_bid, clock); + + // Capture the pre-trade ratio for the monotonic gate, but only while the + // manager has debt — `risk_ratio` is only meaningful against a debt. + let risk_ratio_before = if ( + margin_manager.borrowed_base_shares() > 0 + || margin_manager.borrowed_quote_shares() > 0 + ) { + margin_manager.risk_ratio( + registry, + base_oracle, + quote_oracle, + pool, + base_margin_pool, + quote_margin_pool, + clock, + ) + } else { + 0 + }; + + let trade_proof = margin_manager.trade_proof(ctx); + let balance_manager = margin_manager.balance_manager_trading_mut(ctx); + let order_info = pool.place_market_order( + balance_manager, + &trade_proof, + client_order_id, + self_matching_option, + quantity, + is_bid, + pay_with_deep, + clock, + ctx, + ); + + // Repay the debt side with the settled proceeds *before* the solvency check, + // so a deleveraging close passes where the bare swap would not. Skipped when + // the manager has no debt. + if (margin_manager.has_base_debt()) { + margin_manager.repay_base(registry, base_margin_pool, option::none(), clock, ctx); + } else if (margin_manager.borrowed_quote_shares() > 0) { + margin_manager.repay_quote(registry, quote_margin_pool, option::none(), clock, ctx); + }; + + // Net-state monotonic gate: if debt remains, the trade+repay must not have + // worsened the ratio. A full repay clears the debt (`risk_ratio` -> MAX), so + // the check is skipped. Debt only decreases here, so surviving debt implies + // there was debt before and `risk_ratio_before` is a real ratio. + if ( + margin_manager.borrowed_base_shares() > 0 + || margin_manager.borrowed_quote_shares() > 0 + ) { + let risk_ratio_after = margin_manager.risk_ratio( + registry, + base_oracle, + quote_oracle, + pool, + base_margin_pool, + quote_margin_pool, + clock, + ); + assert!(risk_ratio_after >= risk_ratio_before, ERiskRatioMustNotWorsen); + }; + + order_info +} + /// Modifies an order public fun modify_order( registry: &MarginRegistry, @@ -697,10 +1053,12 @@ fun calculate_effective_price( } } -/// Asserts the manager remains above the borrow-floor risk ratio after a +/// Asserts the manager remains solvent after an opening (risk-increasing) /// trade. Skipped when the manager has no debt (nothing to be insolvent -/// against). Threshold reuses `min_borrow_risk_ratio` so trading cannot push -/// a manager below the level borrowing is already gated at. +/// against). Threshold is `min_open_risk_ratio` — between liquidation and the +/// borrow floor — so a max-leverage open can absorb the opening trade's spread +/// (which lands the ratio just under `min_borrow`) without aborting, while +/// staying above the liquidatable zone. fun assert_post_trade_solvent( margin_manager: &MarginManager, registry: &MarginRegistry, @@ -728,7 +1086,7 @@ fun assert_post_trade_solvent( clock, ); assert!( - risk_ratio_after >= registry.min_borrow_risk_ratio(pool.id()), + risk_ratio_after >= registry.min_open_risk_ratio(pool.id()), EInsufficientRiskRatioAfterTrade, ); } @@ -758,5 +1116,5 @@ fun assert_reduce_only_monotonic( quote_margin_pool, clock, ); - assert!(risk_ratio_after >= risk_ratio_before, EReduceOnlyMustImproveRiskRatio); + assert!(risk_ratio_after >= risk_ratio_before, ERiskRatioMustNotWorsen); } diff --git a/packages/deepbook_margin/tests/helper/test_helpers.move b/packages/deepbook_margin/tests/helper/test_helpers.move index 629e1f82b..d9e640593 100644 --- a/packages/deepbook_margin/tests/helper/test_helpers.move +++ b/packages/deepbook_margin/tests/helper/test_helpers.move @@ -1045,6 +1045,71 @@ public fun setup_orderbook_liquidity_stablecoin( return_shared(pool); } +/// Places a single bid + ask at the given 1e9-scaled prices. For tests that +/// drift the oracle and need liquidity around the new price (the fixed +/// `setup_orderbook_liquidity_stablecoin` bids/asks sit at $0.99/$1.01). +public fun setup_orderbook_liquidity_at_prices( + scenario: &mut Scenario, + pool_id: ID, + bid_price: u64, + ask_price: u64, + clock: &Clock, +) { + use deepbook::balance_manager; + + scenario.next_tx(test_constants::user2()); + let mut pool = scenario.take_shared_by_id>(pool_id); + let mut balance_manager = balance_manager::new(scenario.ctx()); + + balance_manager.deposit( + mint_coin(1_000_000 * test_constants::usdc_multiplier(), scenario.ctx()), + scenario.ctx(), + ); + balance_manager.deposit( + mint_coin(1_000_000 * test_constants::usdt_multiplier(), scenario.ctx()), + scenario.ctx(), + ); + balance_manager.deposit( + mint_coin(10000 * test_constants::deep_multiplier(), scenario.ctx()), + scenario.ctx(), + ); + + let trade_proof = balance_manager.generate_proof_as_owner(scenario.ctx()); + + pool.place_limit_order( + &mut balance_manager, + &trade_proof, + 1, + constants::no_restriction(), + constants::self_matching_allowed(), + ask_price, + 10000 * test_constants::usdc_multiplier(), + false, + false, + constants::max_u64(), + clock, + scenario.ctx(), + ); + + pool.place_limit_order( + &mut balance_manager, + &trade_proof, + 2, + constants::no_restriction(), + constants::self_matching_allowed(), + bid_price, + 10000 * test_constants::usdc_multiplier(), + true, + false, + constants::max_u64(), + clock, + scenario.ctx(), + ); + + transfer::public_transfer(balance_manager, test_constants::user2()); + return_shared(pool); +} + /// Sets up orderbook liquidity with prices OUTSIDE the 5% tolerance for testing price bound failures. /// Asks at $1.10 (10% above oracle) - will fail upper bound check for market buys /// Bids at $0.90 (10% below oracle) - will fail lower bound check for market sells @@ -1379,6 +1444,132 @@ public fun place_reduce_only_market_order_v2_for_test( order_info } +/// Wraps `place_reduce_only_market_order_and_repay_loan` with demo ($1.00) +/// oracle prices built/destroyed inline. For tests that drift the oracle, call +/// the proxy entry directly with custom price objects instead. +public fun place_reduce_only_market_order_and_repay_loan_for_test( + scenario: &mut Scenario, + registry: &MarginRegistry, + mm: &mut deepbook_margin::margin_manager::MarginManager, + pool: &mut Pool, + base_margin_pool: &mut MarginPool, + quote_margin_pool: &mut MarginPool, + client_order_id: u64, + self_matching_option: u8, + quantity: u64, + is_bid: bool, + pay_with_deep: bool, + clock: &Clock, +): deepbook::order_info::OrderInfo { + let base_oracle = build_price_info_for_type(scenario, clock); + let quote_oracle = build_price_info_for_type(scenario, clock); + let order_info = pool_proxy::place_reduce_only_market_order_and_repay_loan< + BaseAsset, + QuoteAsset, + >( + registry, + mm, + pool, + base_margin_pool, + quote_margin_pool, + &base_oracle, + "e_oracle, + client_order_id, + self_matching_option, + quantity, + is_bid, + pay_with_deep, + clock, + scenario.ctx(), + ); + destroy(base_oracle); + destroy(quote_oracle); + order_info +} + +public fun place_market_order_and_repay_loan_for_test( + scenario: &mut Scenario, + registry: &MarginRegistry, + mm: &mut deepbook_margin::margin_manager::MarginManager, + pool: &mut Pool, + base_margin_pool: &mut MarginPool, + quote_margin_pool: &mut MarginPool, + client_order_id: u64, + self_matching_option: u8, + quantity: u64, + is_bid: bool, + pay_with_deep: bool, + clock: &Clock, +): deepbook::order_info::OrderInfo { + let base_oracle = build_price_info_for_type(scenario, clock); + let quote_oracle = build_price_info_for_type(scenario, clock); + let order_info = pool_proxy::place_market_order_and_repay_loan( + registry, + mm, + pool, + base_margin_pool, + quote_margin_pool, + &base_oracle, + "e_oracle, + client_order_id, + self_matching_option, + quantity, + is_bid, + pay_with_deep, + clock, + scenario.ctx(), + ); + destroy(base_oracle); + destroy(quote_oracle); + order_info +} + +public fun place_reduce_only_limit_order_and_repay_loan_for_test( + scenario: &mut Scenario, + registry: &MarginRegistry, + mm: &mut deepbook_margin::margin_manager::MarginManager, + pool: &mut Pool, + base_margin_pool: &mut MarginPool, + quote_margin_pool: &mut MarginPool, + client_order_id: u64, + order_type: u8, + self_matching_option: u8, + price: u64, + quantity: u64, + is_bid: bool, + pay_with_deep: bool, + expire_timestamp: u64, + clock: &Clock, +): deepbook::order_info::OrderInfo { + let base_oracle = build_price_info_for_type(scenario, clock); + let quote_oracle = build_price_info_for_type(scenario, clock); + let order_info = pool_proxy::place_reduce_only_limit_order_and_repay_loan< + BaseAsset, + QuoteAsset, + >( + registry, + mm, + pool, + base_margin_pool, + quote_margin_pool, + &base_oracle, + "e_oracle, + client_order_id, + order_type, + self_matching_option, + price, + quantity, + is_bid, + pay_with_deep, + expire_timestamp, + clock, + scenario.ctx(), + ); + destroy(base_oracle); + destroy(quote_oracle); + order_info +} + public fun execute_conditional_orders_v2_for_test( scenario: &mut Scenario, base_margin_pool: &MarginPool, @@ -1403,3 +1594,28 @@ public fun execute_conditional_orders_v2_for_test( scenario.ctx(), ) } + +public fun execute_conditional_orders_v3_for_test( + scenario: &mut Scenario, + base_margin_pool: &mut MarginPool, + quote_margin_pool: &mut MarginPool, + mm: &mut deepbook_margin::margin_manager::MarginManager, + pool: &mut Pool, + base_price_info_object: &PriceInfoObject, + quote_price_info_object: &PriceInfoObject, + registry: &MarginRegistry, + max_orders_to_execute: u64, + clock: &Clock, +): vector { + mm.execute_conditional_orders_v3( + pool, + base_margin_pool, + quote_margin_pool, + base_price_info_object, + quote_price_info_object, + registry, + max_orders_to_execute, + clock, + scenario.ctx(), + ) +} diff --git a/packages/deepbook_margin/tests/margin_manager_tests.move b/packages/deepbook_margin/tests/margin_manager_tests.move index 51ad7751d..0bede1670 100644 --- a/packages/deepbook_margin/tests/margin_manager_tests.move +++ b/packages/deepbook_margin/tests/margin_manager_tests.move @@ -890,6 +890,55 @@ fun test_repay_fails_wrong_pool() { abort } +#[test, expected_failure(abort_code = margin_manager::EInvalidMarginManagerOwner)] +fun test_repay_base_fails_non_owner() { + // The owner gate that backstops withdraw_without_owner_check: a non-owner + // cannot reach `repay` (and the underlying withdraw) through the public + // repay_base wrapper — validate_owner aborts first. The only owner-unchecked + // callers of withdraw_without_owner_check are the internal permissionless + // paths (liquidation, conditional execution), which route funds into the + // manager's own debt or to a liquidator under the reward formula, never to an + // arbitrary caller. + let ( + mut scenario, + clock, + _admin_cap, + _maintainer_cap, + _usdc_pool_id, + usdt_pool_id, + _pool_id, + registry_id, + ) = setup_usdc_usdt_deepbook_margin(); + + scenario.next_tx(test_constants::user1()); + let mut registry = scenario.take_shared(); + let pool = scenario.take_shared>(); + let deepbook_registry = scenario.take_shared_by_id(registry_id); + margin_manager::new( + &pool, + &deepbook_registry, + &mut registry, + &clock, + scenario.ctx(), + ); + return_shared(deepbook_registry); + + // A different account (not the manager owner) attempts to repay. + scenario.next_tx(test_constants::user2()); + let mut mm = scenario.take_shared>(); + let mut usdt_pool = scenario.take_shared_by_id>(usdt_pool_id); + + mm.repay_base( + ®istry, + &mut usdt_pool, + option::none(), + &clock, + scenario.ctx(), + ); + + abort 999 +} + #[test] fun test_repay_full_with_none() { let ( diff --git a/packages/deepbook_margin/tests/pool_proxy_tests.move b/packages/deepbook_margin/tests/pool_proxy_tests.move index 9986bcd14..e609fff3e 100644 --- a/packages/deepbook_margin/tests/pool_proxy_tests.move +++ b/packages/deepbook_margin/tests/pool_proxy_tests.move @@ -29,11 +29,13 @@ use deepbook_margin::{ return_shared_3, build_demo_usdc_price_info_object, build_demo_usdt_price_info_object, + build_demo_usdc_price_info_object_with_price, setup_orderbook_liquidity_stablecoin, + setup_orderbook_liquidity_at_prices, setup_orderbook_liquidity_out_of_bounds_stablecoin } }; -use std::unit_test::destroy; +use std::unit_test::{assert_eq, destroy}; use sui::test_scenario::return_shared; use token::deep::DEEP; @@ -690,6 +692,106 @@ fun test_place_reduce_only_limit_order_ok_ask() { cleanup_margin_test(registry, _admin_cap, _maintainer_cap, clock, scenario); } +// Pure reduce-only limit ask selling *more* than the net quote debt — the #5 +// case the old net-debt cap forbade. Long: 10000 USDC collateral, 500 USDT +// debt, ~200 USDT free (net quote debt ~300). A reduce-only limit ask for 500 +// USDC now rests (it is below the 10000 gross base the manager holds); the old +// cap rejected anything above ~300. +#[test] +fun reduce_only_limit_ask_above_net_debt_ok() { + let ( + mut scenario, + clock, + _admin_cap, + _maintainer_cap, + base_pool_id, + quote_pool_id, + pool_id, + registry_id, + ) = setup_pool_proxy_test_env(); + + scenario.next_tx(test_constants::user1()); + let mut pool = scenario.take_shared_by_id>(pool_id); + let mut registry = scenario.take_shared(); + let base_pool = scenario.take_shared_by_id>(base_pool_id); + let mut quote_pool = scenario.take_shared_by_id>(quote_pool_id); + let deepbook_registry = scenario.take_shared_by_id(registry_id); + margin_manager::new( + &pool, + &deepbook_registry, + &mut registry, + &clock, + scenario.ctx(), + ); + return_shared(deepbook_registry); + + scenario.next_tx(test_constants::user1()); + let mut mm = scenario.take_shared>(); + let usdc_price = build_demo_usdc_price_info_object(&mut scenario, &clock); + let usdt_price = build_demo_usdt_price_info_object(&mut scenario, &clock); + + mm.deposit( + ®istry, + &usdc_price, + &usdt_price, + mint_coin(10000 * test_constants::usdc_multiplier(), scenario.ctx()), + &clock, + scenario.ctx(), + ); + mm.borrow_quote( + ®istry, + &mut quote_pool, + &usdc_price, + &usdt_price, + &pool, + 500 * test_constants::usdt_multiplier(), + &clock, + scenario.ctx(), + ); + let withdrawn_coin = mm.withdraw( + ®istry, + &base_pool, + "e_pool, + &usdc_price, + &usdt_price, + &pool, + 300 * test_constants::usdt_multiplier(), + &clock, + scenario.ctx(), + ); + destroy(withdrawn_coin); + + // 500 USDC > the ~300 net quote debt, < the 10000 gross base held. + let order_info = test_helpers::place_reduce_only_limit_order_v2_for_test( + &mut scenario, + ®istry, + &base_pool, + "e_pool, + &mut mm, + &mut pool, + 1, + constants::no_restriction(), + constants::self_matching_allowed(), + 1_000_000_000, + 500 * test_constants::usdc_multiplier(), + false, + false, + 2000000, + &clock, + ); + + // The order rested (no book liquidity) at the full requested size. + assert_eq!(order_info.original_quantity(), 500 * test_constants::usdc_multiplier()); + assert_eq!(order_info.executed_quantity(), 0); + + destroy(order_info); + return_shared_2!(mm, pool); + return_shared_2!(base_pool, quote_pool); + destroy(usdc_price); + destroy(usdt_price); + cleanup_margin_test(registry, _admin_cap, _maintainer_cap, clock, scenario); +} + #[test, expected_failure(abort_code = pool_proxy::EIncorrectDeepBookPool)] fun test_place_reduce_only_limit_order_incorrect_pool() { let ( @@ -897,7 +999,10 @@ fun test_place_reduce_only_limit_order_not_reduce_only() { } #[test, expected_failure(abort_code = pool_proxy::ENotReduceOnlyOrder)] -fun test_place_reduce_only_limit_order_not_reduce_only_quantity_bid() { +fun test_place_reduce_only_limit_order_bid_requires_base_debt() { + // A reduce-only bid covers a *short*, so it requires base (short-side) debt. + // A manager with only quote debt (a long) placing a bid would increase its + // long, so the direction guard rejects it as not reduce-only. let ( mut scenario, clock, @@ -912,8 +1017,8 @@ fun test_place_reduce_only_limit_order_not_reduce_only_quantity_bid() { scenario.next_tx(test_constants::user1()); let mut pool = scenario.take_shared_by_id>(pool_id); let mut registry = scenario.take_shared(); - let mut base_pool = scenario.take_shared_by_id>(base_pool_id); - let quote_pool = scenario.take_shared_by_id>(quote_pool_id); + let base_pool = scenario.take_shared_by_id>(base_pool_id); + let mut quote_pool = scenario.take_shared_by_id>(quote_pool_id); let deepbook_registry = scenario.take_shared_by_id(registry_id); margin_manager::new( &pool, @@ -929,46 +1034,112 @@ fun test_place_reduce_only_limit_order_not_reduce_only_quantity_bid() { let usdc_price = build_demo_usdc_price_info_object(&mut scenario, &clock); let usdt_price = build_demo_usdt_price_info_object(&mut scenario, &clock); - // Deposit some USDT to use as collateral - mm.deposit( + // USDC collateral + USDT (quote) debt -> a long, no base debt. + mm.deposit( ®istry, &usdc_price, &usdt_price, - mint_coin(10000 * test_constants::usdt_multiplier(), scenario.ctx()), + mint_coin(10000 * test_constants::usdc_multiplier(), scenario.ctx()), + &clock, + scenario.ctx(), + ); + mm.borrow_quote( + ®istry, + &mut quote_pool, + &usdc_price, + &usdt_price, + &pool, + 100 * test_constants::usdt_multiplier(), &clock, scenario.ctx(), ); destroy_2!(usdc_price, usdt_price); + // Bid (buy USDC) with no base debt -> not reduce-only (direction guard). + test_helpers::place_reduce_only_limit_order_v2_for_test( + &mut scenario, + ®istry, + &base_pool, + "e_pool, + &mut mm, + &mut pool, + 1, + constants::no_restriction(), + constants::self_matching_allowed(), + constants::float_scaling(), + 50 * test_constants::usdc_multiplier(), + true, + false, + 2000000, + &clock, + ); + + abort 999 +} + +#[test, expected_failure(abort_code = pool_proxy::ENotReduceOnlyOrder)] +fun test_place_reduce_only_limit_order_ask_requires_quote_debt() { + // Mirror of the bid guard, and the *one-way* property that bounds the + // resting-fill ratchet: a reduce-only ask closes a long, so it requires quote + // (long-side) debt. A short (base debt, no quote debt) is rejected. So a short + // can only ever *bid* (convert quote->base) and a long can only *ask* + // (base->quote) — neither can trade back and forth, capping any value leak to + // one conversion (~the price band) and keeping the manager solvent. Without + // this guard, a manager could ratchet quote->base->quote... unboundedly via + // ungated resting fills and drive itself underwater. + let ( + mut scenario, + clock, + _admin_cap, + _maintainer_cap, + base_pool_id, + quote_pool_id, + pool_id, + registry_id, + ) = setup_pool_proxy_test_env(); + + scenario.next_tx(test_constants::user1()); + let mut pool = scenario.take_shared_by_id>(pool_id); + let mut registry = scenario.take_shared(); + let mut base_pool = scenario.take_shared_by_id>(base_pool_id); + let quote_pool = scenario.take_shared_by_id>(quote_pool_id); + let deepbook_registry = scenario.take_shared_by_id(registry_id); + margin_manager::new( + &pool, + &deepbook_registry, + &mut registry, + &clock, + scenario.ctx(), + ); + return_shared(deepbook_registry); + + scenario.next_tx(test_constants::user1()); + let mut mm = scenario.take_shared>(); let usdc_price = build_demo_usdc_price_info_object(&mut scenario, &clock); let usdt_price = build_demo_usdt_price_info_object(&mut scenario, &clock); - // Borrow some USDC - mm.borrow_base( + + // USDT collateral + USDC (base) debt -> a short, no quote debt. + mm.deposit( ®istry, - &mut base_pool, &usdc_price, &usdt_price, - &pool, - 100 * test_constants::usdc_multiplier(), // Small borrow amount + mint_coin(10000 * test_constants::usdt_multiplier(), scenario.ctx()), &clock, scenario.ctx(), ); - - let coin = mm.withdraw( + mm.borrow_base( ®istry, - &base_pool, - "e_pool, // Pass quote_pool since we have USDT debt + &mut base_pool, &usdc_price, &usdt_price, &pool, - 100 * test_constants::usdc_multiplier(), // Withdraw some USDC so we have have net debt + 100 * test_constants::usdc_multiplier(), &clock, scenario.ctx(), ); - destroy(coin); + destroy_2!(usdc_price, usdt_price); - // User has USDC debt, tries to buy more USDC than debt - // This should fail because user is trying to buy more USDC than debt + // Ask (sell USDC) with no quote debt -> not reduce-only (direction guard). test_helpers::place_reduce_only_limit_order_v2_for_test( &mut scenario, ®istry, @@ -976,27 +1147,18 @@ fun test_place_reduce_only_limit_order_not_reduce_only_quantity_bid() { "e_pool, &mut mm, &mut pool, - // Pass quote_pool since we have USDT debt 1, constants::no_restriction(), constants::self_matching_allowed(), constants::float_scaling(), - // price - 101 * test_constants::usdc_multiplier(), - // quantity - true, - // is_bid = true (buying USDC) + 50 * test_constants::usdc_multiplier(), + false, false, 2000000, - // expire_timestamp &clock, ); - return_shared_2!(mm, pool); - return_shared_2!(base_pool, quote_pool); - destroy(usdc_price); - destroy(usdt_price); - cleanup_margin_test(registry, _admin_cap, _maintainer_cap, clock, scenario); + abort 999 } #[test, expected_failure(abort_code = pool_proxy::ENotReduceOnlyOrder)] @@ -1066,8 +1228,9 @@ fun test_place_reduce_only_limit_order_not_reduce_only_quantity_ask() { ); destroy(coin); - // User has USDC debt, tries to buy more USDC than debt - // This should fail because user is trying to buy more USDC than debt + // Long position (USDC collateral, USDT debt). The ask tries to sell 10001 + // USDC but the manager holds only 10000 — beyond the gross-holdings cap, so + // it aborts. Selling between net debt and gross holdings is now allowed. test_helpers::place_reduce_only_limit_order_v2_for_test( &mut scenario, ®istry, @@ -1081,10 +1244,10 @@ fun test_place_reduce_only_limit_order_not_reduce_only_quantity_ask() { constants::self_matching_allowed(), constants::float_scaling(), // price - 101 * test_constants::usdc_multiplier(), - // quantity + 10001 * test_constants::usdc_multiplier(), + // quantity (exceeds the 10000 USDC the manager holds) false, - // is_bid = false (buying USDT) + // is_bid = false (selling USDC) false, 2000000, // expire_timestamp @@ -1106,7 +1269,7 @@ fun test_place_reduce_only_limit_order_not_reduce_only_quantity_ask() { // reduce-only fills must monotonically improve (or hold) solvency. The // matching limit-order path (placed at exact oracle price) still passes its // `_ok` test above. -#[test, expected_failure(abort_code = pool_proxy::EReduceOnlyMustImproveRiskRatio)] +#[test, expected_failure(abort_code = pool_proxy::ERiskRatioMustNotWorsen)] fun test_place_reduce_only_market_order_ok() { let ( mut scenario, @@ -1210,7 +1373,7 @@ fun test_place_reduce_only_market_order_ok() { // Symmetric to `test_place_reduce_only_market_order_ok` — sell-side reduce-only // market hits bids at $0.99 (1% below oracle), degrading risk_ratio. Aborts on // the monotonic invariant. -#[test, expected_failure(abort_code = pool_proxy::EReduceOnlyMustImproveRiskRatio)] +#[test, expected_failure(abort_code = pool_proxy::ERiskRatioMustNotWorsen)] fun test_place_reduce_only_market_order_ok_ask() { let ( mut scenario, @@ -1308,8 +1471,15 @@ fun test_place_reduce_only_market_order_ok_ask() { cleanup_margin_test(registry, _admin_cap, _maintainer_cap, clock, scenario); } -#[test, expected_failure(abort_code = pool_proxy::EIncorrectDeepBookPool)] -fun test_place_reduce_only_market_order_incorrect_pool() { +// === Place Reduce Only Market Order And Repay Loan Tests === + +// Identical position to the `test_place_reduce_only_market_order_ok` +// expected_failure above: a reduce-only market BID buying USDC fills at $1.01 +// (1% above oracle). On the swap-only path that strictly lowers risk_ratio and +// aborts on the monotonic invariant. Bundling the repay turns the slippage into +// a net deleverage, so risk_ratio improves instead and the close succeeds. +#[test] +fun reduce_only_and_repay_bid_succeeds_where_swap_only_fails() { let ( mut scenario, clock, @@ -1321,14 +1491,13 @@ fun test_place_reduce_only_market_order_incorrect_pool() { registry_id, ) = setup_pool_proxy_test_env(); - let (wrong_pool_id, _wrong_registry_id) = create_pool_for_testing(&mut scenario); + setup_orderbook_liquidity_stablecoin(&mut scenario, pool_id, &clock); scenario.next_tx(test_constants::user1()); - let pool = scenario.take_shared_by_id>(pool_id); - let mut wrong_pool = scenario.take_shared_by_id>(wrong_pool_id); + let mut pool = scenario.take_shared_by_id>(pool_id); let mut registry = scenario.take_shared(); - let quote_pool = scenario.take_shared_by_id>(quote_pool_id); - + let mut base_pool = scenario.take_shared_by_id>(base_pool_id); + let mut quote_pool = scenario.take_shared_by_id>(quote_pool_id); let deepbook_registry = scenario.take_shared_by_id(registry_id); margin_manager::new( &pool, @@ -1341,80 +1510,98 @@ fun test_place_reduce_only_market_order_incorrect_pool() { scenario.next_tx(test_constants::user1()); let mut mm = scenario.take_shared>(); - let base_pool = scenario.take_shared_by_id>(base_pool_id); + let usdc_price = build_demo_usdc_price_info_object(&mut scenario, &clock); + let usdt_price = build_demo_usdt_price_info_object(&mut scenario, &clock); - test_helpers::place_reduce_only_market_order_v2_for_test( - &mut scenario, + mm.deposit( + ®istry, + &usdc_price, + &usdt_price, + mint_coin(10000 * test_constants::usdt_multiplier(), scenario.ctx()), + &clock, + scenario.ctx(), + ); + mm.borrow_base( + ®istry, + &mut base_pool, + &usdc_price, + &usdt_price, + &pool, + 500 * test_constants::usdc_multiplier(), + &clock, + scenario.ctx(), + ); + let withdrawn = mm.withdraw( + ®istry, + &base_pool, + "e_pool, + &usdc_price, + &usdt_price, + &pool, + 300 * test_constants::usdc_multiplier(), + &clock, + scenario.ctx(), + ); + destroy(withdrawn); + + let shares_before = mm.borrowed_base_shares(); + let rr_before = mm.risk_ratio( ®istry, + &usdc_price, + &usdt_price, + &pool, &base_pool, "e_pool, + &clock, + ); + + let order_info = test_helpers::place_reduce_only_market_order_and_repay_loan_for_test< + USDC, + USDT, + >( + &mut scenario, + ®istry, &mut mm, - &mut wrong_pool, - 4, + &mut pool, + &mut base_pool, + &mut quote_pool, + 2, constants::self_matching_allowed(), - 500, - false, + 100 * test_constants::usdc_multiplier(), + true, false, &clock, ); + destroy(order_info); - abort -} - -// Market-order counterpart of `test_place_reduce_only_limit_order_v2_unregistered_pool`. -// See that test for the rationale (registry uniqueness blocks a debt-side -// mismatch; only the no-debt path is constructable). -#[test, expected_failure(abort_code = margin_manager::EIncorrectMarginPool)] -fun test_place_reduce_only_market_order_v2_unregistered_pool() { - let ( - mut scenario, - clock, - _admin_cap, - _maintainer_cap, - base_pool_id, - quote_pool_id, - pool_id, - registry_id, - ) = setup_pool_proxy_test_env(); - - scenario.next_tx(test_constants::user1()); - let mut pool = scenario.take_shared_by_id>(pool_id); - let mut registry = scenario.take_shared(); - let base_pool = scenario.take_shared_by_id>(base_pool_id); - let quote_pool = scenario.take_shared_by_id>(quote_pool_id); - let deepbook_registry = scenario.take_shared_by_id(registry_id); - margin_manager::new( - &pool, - &deepbook_registry, - &mut registry, - &clock, - scenario.ctx(), - ); - return_shared(deepbook_registry); - - scenario.next_tx(test_constants::user1()); - let mut mm = scenario.take_shared>(); - - test_helpers::place_reduce_only_market_order_v2_for_test( - &mut scenario, + let rr_after = mm.risk_ratio( ®istry, + &usdc_price, + &usdt_price, + &pool, &base_pool, "e_pool, - &mut mm, - &mut pool, - 2, - constants::self_matching_allowed(), - 100 * test_constants::usdc_multiplier(), - true, - false, &clock, ); - abort + // Some base debt was repaid (but not all — net debt position remains), and + // the deleverage strictly improved solvency despite the 1% buy slippage. + assert!(mm.borrowed_base_shares() < shares_before); + assert!(mm.borrowed_base_shares() > 0); + assert!(rr_after > rr_before); + + return_shared_3!(mm, pool, base_pool); + return_shared(quote_pool); + destroy(usdc_price); + destroy(usdt_price); + cleanup_margin_test(registry, _admin_cap, _maintainer_cap, clock, scenario); } -#[test, expected_failure(abort_code = pool_proxy::ENotReduceOnlyOrder)] -fun test_place_reduce_only_market_order_not_reduce_only() { +// Sell-side counterpart: reduce-only market ASK selling USDC fills at $0.99 (1% +// below oracle), which aborts the swap-only path. Repaying the proceeds against +// the quote debt deleverages and lifts risk_ratio, so the close succeeds. +#[test] +fun reduce_only_and_repay_ask_succeeds_where_swap_only_fails() { let ( mut scenario, clock, @@ -1426,12 +1613,12 @@ fun test_place_reduce_only_market_order_not_reduce_only() { registry_id, ) = setup_pool_proxy_test_env(); - // Set up orderbook liquidity for market orders setup_orderbook_liquidity_stablecoin(&mut scenario, pool_id, &clock); scenario.next_tx(test_constants::user1()); let mut pool = scenario.take_shared_by_id>(pool_id); let mut registry = scenario.take_shared(); + let mut base_pool = scenario.take_shared_by_id>(base_pool_id); let mut quote_pool = scenario.take_shared_by_id>(quote_pool_id); let deepbook_registry = scenario.take_shared_by_id(registry_id); margin_manager::new( @@ -1448,52 +1635,81 @@ fun test_place_reduce_only_market_order_not_reduce_only() { let usdc_price = build_demo_usdc_price_info_object(&mut scenario, &clock); let usdt_price = build_demo_usdt_price_info_object(&mut scenario, &clock); - // Deposit some USDT to use as collateral - mm.deposit( + mm.deposit( ®istry, &usdc_price, &usdt_price, - mint_coin(10000 * test_constants::usdt_multiplier(), scenario.ctx()), + mint_coin(10000 * test_constants::usdc_multiplier(), scenario.ctx()), &clock, scenario.ctx(), ); - destroy_2!(usdc_price, usdt_price); - - let usdc_price = build_demo_usdc_price_info_object(&mut scenario, &clock); - let usdt_price = build_demo_usdt_price_info_object(&mut scenario, &clock); - // Borrow some USDT to establish relationship with quote pool mm.borrow_quote( ®istry, &mut quote_pool, &usdc_price, &usdt_price, &pool, - 100 * test_constants::usdt_multiplier(), // Small borrow amount + 500 * test_constants::usdt_multiplier(), + &clock, + scenario.ctx(), + ); + let withdrawn = mm.withdraw( + ®istry, + &base_pool, + "e_pool, + &usdc_price, + &usdt_price, + &pool, + 300 * test_constants::usdt_multiplier(), &clock, scenario.ctx(), ); + destroy(withdrawn); - // User has no USDC debt but has USDT debt, tries to buy USDC (is_bid = true) - // This should fail because it's not reducing any USDC position - user is increasing exposure - let base_pool = scenario.take_shared_by_id>(base_pool_id); - test_helpers::place_reduce_only_market_order_v2_for_test( - &mut scenario, + let shares_before = mm.borrowed_quote_shares(); + let rr_before = mm.risk_ratio( ®istry, + &usdc_price, + &usdt_price, + &pool, &base_pool, "e_pool, + &clock, + ); + + let order_info = test_helpers::place_reduce_only_market_order_and_repay_loan_for_test< + USDC, + USDT, + >( + &mut scenario, + ®istry, &mut mm, &mut pool, - // Pass quote_pool since we have USDT debt - 3, + &mut base_pool, + &mut quote_pool, + 2, constants::self_matching_allowed(), 100 * test_constants::usdc_multiplier(), - // quantity - true, - // is_bid = true (buying USDC) false, + false, + &clock, + ); + destroy(order_info); + + let rr_after = mm.risk_ratio( + ®istry, + &usdc_price, + &usdt_price, + &pool, + &base_pool, + "e_pool, &clock, ); + assert!(mm.borrowed_quote_shares() < shares_before); + assert!(mm.borrowed_quote_shares() > 0); + assert!(rr_after > rr_before); + return_shared_3!(mm, pool, quote_pool); return_shared(base_pool); destroy(usdc_price); @@ -1501,8 +1717,18 @@ fun test_place_reduce_only_market_order_not_reduce_only() { cleanup_margin_test(registry, _admin_cap, _maintainer_cap, clock, scenario); } -#[test, expected_failure(abort_code = pool_proxy::ENotReduceOnlyOrder)] -fun test_place_reduce_only_market_order_not_reduce_only_quantity_bid() { +// The motivating scenario: a leveraged long whose risk_ratio has drifted to +// 1.20 — below `min_borrow` (1.25) but above `liquidation` (1.10). It is stuck +// in the danger zone: it cannot borrow, and a normal market order would abort +// (`place_market_order_v2` requires post-trade risk_ratio >= min_borrow). The +// bundled close-and-repay is its only wind-down path: it deleverages the +// position back above the borrow floor with a market order. +// +// Position: deposit 2000 USDC, borrow 1000 USDT, withdraw the 1000 USDT +// (risk_ratio at $1.00 is exactly 2.0 = min_withdraw). USDC then drifts to +// $0.60, giving risk_ratio = 2000 * 0.60 / 1000 = 1.20. +#[test] +fun reduce_only_and_repay_closes_from_danger_zone() { let ( mut scenario, clock, @@ -1514,13 +1740,21 @@ fun test_place_reduce_only_market_order_not_reduce_only_quantity_bid() { registry_id, ) = setup_pool_proxy_test_env(); - setup_orderbook_liquidity_stablecoin(&mut scenario, pool_id, &clock); + // Liquidity around the drifted $0.60 oracle: a $0.59 bid (within the 5% + // band, ~1.7% adverse) for the manager's sell to fill against. + setup_orderbook_liquidity_at_prices( + &mut scenario, + pool_id, + 590_000_000, + 610_000_000, + &clock, + ); scenario.next_tx(test_constants::user1()); let mut pool = scenario.take_shared_by_id>(pool_id); let mut registry = scenario.take_shared(); let mut base_pool = scenario.take_shared_by_id>(base_pool_id); - let quote_pool = scenario.take_shared_by_id>(quote_pool_id); + let mut quote_pool = scenario.take_shared_by_id>(quote_pool_id); let deepbook_registry = scenario.take_shared_by_id(registry_id); margin_manager::new( &pool, @@ -1536,61 +1770,117 @@ fun test_place_reduce_only_market_order_not_reduce_only_quantity_bid() { let usdc_price = build_demo_usdc_price_info_object(&mut scenario, &clock); let usdt_price = build_demo_usdt_price_info_object(&mut scenario, &clock); - mm.deposit( + mm.deposit( ®istry, &usdc_price, &usdt_price, - mint_coin(10000 * test_constants::usdt_multiplier(), scenario.ctx()), + mint_coin(2000 * test_constants::usdc_multiplier(), scenario.ctx()), &clock, scenario.ctx(), ); - - mm.borrow_base( + mm.borrow_quote( ®istry, - &mut base_pool, + &mut quote_pool, &usdc_price, &usdt_price, &pool, - 100 * test_constants::usdc_multiplier(), + 1000 * test_constants::usdt_multiplier(), &clock, scenario.ctx(), ); - - let coin = mm.withdraw( + let withdrawn = mm.withdraw( ®istry, &base_pool, "e_pool, &usdc_price, &usdt_price, &pool, - 100 * test_constants::usdc_multiplier(), + 1000 * test_constants::usdt_multiplier(), &clock, scenario.ctx(), ); - destroy(coin); + destroy(withdrawn); + destroy(usdc_price); - // User has USDC debt of 100, tries to buy 101 USDC (more than debt) - test_helpers::place_reduce_only_market_order_v2_for_test( + // Drift USDC to $0.60 and refresh the stored price the band keys off of. + let usdc_drifted = build_demo_usdc_price_info_object_with_price( &mut scenario, + 60_000_000, + &clock, + ); + pool_proxy::update_current_price( + &mut registry, + &pool, + &usdc_drifted, + &usdt_price, + &clock, + ); + + let pool_id_inner = pool.id(); + let shares_before = mm.borrowed_quote_shares(); + let rr_before = mm.risk_ratio( ®istry, + &usdc_drifted, + &usdt_price, + &pool, &base_pool, "e_pool, + &clock, + ); + // Exactly 1.20 — in the danger zone: below min_borrow, above liquidation. + assert!(rr_before == 1_200_000_000); + assert!(rr_before < registry.min_borrow_risk_ratio(pool_id_inner)); + assert!(rr_before >= registry.liquidation_risk_ratio(pool_id_inner)); + + let order_info = pool_proxy::place_reduce_only_market_order_and_repay_loan( + ®istry, &mut mm, &mut pool, - 4, + &mut base_pool, + &mut quote_pool, + &usdc_drifted, + &usdt_price, + 2, constants::self_matching_allowed(), - 101 * test_constants::usdc_multiplier(), - true, - // is_bid + 1000 * test_constants::usdc_multiplier(), false, + false, + &clock, + scenario.ctx(), + ); + destroy(order_info); + + let rr_after = mm.risk_ratio( + ®istry, + &usdc_drifted, + &usdt_price, + &pool, + &base_pool, + "e_pool, &clock, ); - abort + // Debt repaid and the position climbed back above the borrow floor. + assert!(mm.borrowed_quote_shares() < shares_before); + assert!(rr_after > rr_before); + assert!(rr_after >= registry.min_borrow_risk_ratio(pool_id_inner)); + + return_shared_3!(mm, pool, quote_pool); + return_shared(base_pool); + destroy(usdc_drifted); + destroy(usdt_price); + cleanup_margin_test(registry, _admin_cap, _maintainer_cap, clock, scenario); } -#[test, expected_failure(abort_code = pool_proxy::ENotReduceOnlyOrder)] -fun test_place_reduce_only_market_order_not_reduce_only_quantity_ask() { +#[test] +fun place_market_order_and_repay_loan_partial_close_below_min_open_ok() { + // The monotonic gate (vs. the min_open floor) lets a danger-band position + // improve *partially* without reaching min_open. Long: 2000 USDC collateral, + // 1000 USDT debt; drift USDC to $0.60 -> ratio 1.20. min_open is raised to the + // borrow floor so the post-close ratio stays below it; a small ask (sell 200 + // USDC, repay ~118 USDT) lifts the ratio but leaves it under min_open. The old + // min_open gate would abort this; the monotonic gate passes it because the + // ratio improved. let ( mut scenario, clock, @@ -1602,12 +1892,18 @@ fun test_place_reduce_only_market_order_not_reduce_only_quantity_ask() { registry_id, ) = setup_pool_proxy_test_env(); - setup_orderbook_liquidity_stablecoin(&mut scenario, pool_id, &clock); + setup_orderbook_liquidity_at_prices( + &mut scenario, + pool_id, + 590_000_000, + 610_000_000, + &clock, + ); scenario.next_tx(test_constants::user1()); let mut pool = scenario.take_shared_by_id>(pool_id); let mut registry = scenario.take_shared(); - let base_pool = scenario.take_shared_by_id>(base_pool_id); + let mut base_pool = scenario.take_shared_by_id>(base_pool_id); let mut quote_pool = scenario.take_shared_by_id>(quote_pool_id); let deepbook_registry = scenario.take_shared_by_id(registry_id); margin_manager::new( @@ -1628,73 +1924,138 @@ fun test_place_reduce_only_market_order_not_reduce_only_quantity_ask() { ®istry, &usdc_price, &usdt_price, - mint_coin(10000 * test_constants::usdc_multiplier(), scenario.ctx()), + mint_coin(2000 * test_constants::usdc_multiplier(), scenario.ctx()), &clock, scenario.ctx(), ); - mm.borrow_quote( ®istry, &mut quote_pool, &usdc_price, &usdt_price, &pool, - 100 * test_constants::usdt_multiplier(), + 1000 * test_constants::usdt_multiplier(), &clock, scenario.ctx(), ); - - let coin = mm.withdraw( + let withdrawn = mm.withdraw( ®istry, &base_pool, "e_pool, &usdc_price, &usdt_price, &pool, - 100 * test_constants::usdt_multiplier(), + 1000 * test_constants::usdt_multiplier(), &clock, scenario.ctx(), ); - destroy(coin); + destroy(withdrawn); + destroy(usdc_price); - // User has USDT debt of 100, tries to sell enough to get more than 100 USDT (quote_quantity > debt) - // Selling 150 USDC at ~1:1 should yield ~150 USDT, exceeding the 100 USDT debt - test_helpers::place_reduce_only_market_order_v2_for_test( + let usdc_drifted = build_demo_usdc_price_info_object_with_price( &mut scenario, + 60_000_000, + &clock, + ); + pool_proxy::update_current_price( + &mut registry, + &pool, + &usdc_drifted, + &usdt_price, + &clock, + ); + + let pool_id_inner = pool.id(); + // Raise min_open to the borrow floor so the post-close ratio is guaranteed to + // stay below it — isolating the monotonic gate as the reason the close passes. + let strict_floor = registry.min_borrow_risk_ratio(pool_id_inner); + registry.set_min_open_risk_ratio( + &_admin_cap, + &pool, + strict_floor, + &clock, + ); + + let shares_before = mm.borrowed_quote_shares(); + let rr_before = mm.risk_ratio( ®istry, + &usdc_drifted, + &usdt_price, + &pool, &base_pool, "e_pool, + &clock, + ); + assert!(rr_before < registry.min_open_risk_ratio(pool_id_inner)); + assert!(rr_before >= registry.liquidation_risk_ratio(pool_id_inner)); + + let order_info = pool_proxy::place_market_order_and_repay_loan( + ®istry, &mut mm, &mut pool, - 5, + &mut base_pool, + &mut quote_pool, + &usdc_drifted, + &usdt_price, + 2, constants::self_matching_allowed(), - 150 * test_constants::usdc_multiplier(), - false, - // is_bid = false (selling USDC to get USDT) + 200 * test_constants::usdc_multiplier(), + false, // ask: sell USDC base to repay the USDT debt false, &clock, + scenario.ctx(), ); + destroy(order_info); - abort + let rr_after = mm.risk_ratio( + ®istry, + &usdc_drifted, + &usdt_price, + &pool, + &base_pool, + "e_pool, + &clock, + ); + + // Debt partially repaid (not cleared), the ratio improved, and it is still + // below min_open — exactly the state the old floor gate would have rejected. + assert!(mm.borrowed_quote_shares() > 0); + assert!(mm.borrowed_quote_shares() < shares_before); + assert!(rr_after > rr_before); + assert!(rr_after < registry.min_open_risk_ratio(pool_id_inner)); + + return_shared_3!(mm, pool, quote_pool); + return_shared(base_pool); + destroy(usdc_drifted); + destroy(usdt_price); + cleanup_margin_test(registry, _admin_cap, _maintainer_cap, clock, scenario); } -// === Stake Tests === +// Full close where the ask sells *more* base than the net quote debt — the case +// the old net-debt cap forbade. Long position: 10000 USDC collateral, 500 USDT +// debt, ~200 USDT free, so net quote debt is ~300. Selling 600 USDC produces +// ~594 USDT, which repays the whole 500 debt and clears the loan. Demonstrates +// the gross-holdings cap (issues #4 and #5). #[test] -fun test_stake_ok() { +fun reduce_only_and_repay_fully_closes_selling_above_net_debt() { let ( mut scenario, clock, _admin_cap, _maintainer_cap, - _base_pool_id, - _quote_pool_id, + base_pool_id, + quote_pool_id, pool_id, registry_id, ) = setup_pool_proxy_test_env(); + setup_orderbook_liquidity_stablecoin(&mut scenario, pool_id, &clock); + scenario.next_tx(test_constants::user1()); let mut pool = scenario.take_shared_by_id>(pool_id); let mut registry = scenario.take_shared(); + let mut base_pool = scenario.take_shared_by_id>(base_pool_id); + let mut quote_pool = scenario.take_shared_by_id>(quote_pool_id); let deepbook_registry = scenario.take_shared_by_id(registry_id); margin_manager::new( &pool, @@ -1710,74 +2071,79 @@ fun test_stake_ok() { let usdc_price = build_demo_usdc_price_info_object(&mut scenario, &clock); let usdt_price = build_demo_usdt_price_info_object(&mut scenario, &clock); - // Deposit DEEP tokens - mm.deposit( + mm.deposit( ®istry, &usdc_price, &usdt_price, - mint_coin(1000 * test_constants::deep_multiplier(), scenario.ctx()), + mint_coin(10000 * test_constants::usdc_multiplier(), scenario.ctx()), &clock, scenario.ctx(), ); - destroy_2!(usdc_price, usdt_price); - - // Stake DEEP tokens - should work since this is not a DEEP margin manager - pool_proxy::stake( + mm.borrow_quote( ®istry, - &mut mm, - &mut pool, - 100 * test_constants::deep_multiplier(), // 100 DEEP + &mut quote_pool, + &usdc_price, + &usdt_price, + &pool, + 500 * test_constants::usdt_multiplier(), + &clock, scenario.ctx(), ); - - return_shared_2!(mm, pool); - cleanup_margin_test(registry, _admin_cap, _maintainer_cap, clock, scenario); -} - -#[test, expected_failure(abort_code = pool_proxy::ECannotStakeWithDeepMarginManager)] -fun test_stake_with_deep_margin_manager() { - let ( - mut scenario, - clock, - _admin_cap, - _maintainer_cap, - _base_pool_id, - _quote_pool_id, - pool_id, - registry_id, - ) = setup_pool_proxy_test_env(); - - scenario.next_tx(test_constants::user1()); - let mut pool = scenario.take_shared_by_id>(pool_id); - let mut registry = scenario.take_shared(); - let deepbook_registry = scenario.take_shared_by_id(registry_id); - margin_manager::new( + let withdrawn = mm.withdraw( + ®istry, + &base_pool, + "e_pool, + &usdc_price, + &usdt_price, &pool, - &deepbook_registry, - &mut registry, + 300 * test_constants::usdt_multiplier(), &clock, scenario.ctx(), ); - return_shared(deepbook_registry); + destroy(withdrawn); - scenario.next_tx(test_constants::user1()); - let mut mm = scenario.take_shared>(); + let shares_before = mm.borrowed_quote_shares(); + let base_before = mm.base_balance(); - // Try to stake with DEEP margin manager - should fail - pool_proxy::stake( + // Sell 600 USDC: above the ~300 net quote debt, below the 10000 gross base. + let order_info = test_helpers::place_reduce_only_market_order_and_repay_loan_for_test< + USDC, + USDT, + >( + &mut scenario, ®istry, &mut mm, &mut pool, - 100 * test_constants::deep_multiplier(), - scenario.ctx(), + &mut base_pool, + &mut quote_pool, + 2, + constants::self_matching_allowed(), + 600 * test_constants::usdc_multiplier(), + false, + false, + &clock, ); + destroy(order_info); - abort + // The proceeds cleared the entire loan and base was sold down. + assert!(shares_before > 0); + assert_eq!(mm.borrowed_quote_shares(), 0); + assert!(mm.base_balance() < base_before); + + return_shared_3!(mm, pool, quote_pool); + return_shared(base_pool); + destroy(usdc_price); + destroy(usdt_price); + cleanup_margin_test(registry, _admin_cap, _maintainer_cap, clock, scenario); } -// === Other Function Tests === +// A reduce-only-and-repay bid may now exceed the net base debt. Over-covering a +// short just converts USDC into USDC-debt coverage, which is price-invariant, so +// the size cap was dropped (only the direction guard remains). A 400 USDC bid +// against a 300 net short (500 borrowed, 200 held) over-covers, and the repay +// clears the whole 500 debt. #[test] -fun test_modify_order_ok() { +fun reduce_only_and_repay_bid_over_net_debt_fully_closes() { let ( mut scenario, clock, @@ -1789,9 +2155,13 @@ fun test_modify_order_ok() { registry_id, ) = setup_pool_proxy_test_env(); + setup_orderbook_liquidity_stablecoin(&mut scenario, pool_id, &clock); + scenario.next_tx(test_constants::user1()); let mut pool = scenario.take_shared_by_id>(pool_id); let mut registry = scenario.take_shared(); + let mut base_pool = scenario.take_shared_by_id>(base_pool_id); + let mut quote_pool = scenario.take_shared_by_id>(quote_pool_id); let deepbook_registry = scenario.take_shared_by_id(registry_id); margin_manager::new( &pool, @@ -1807,62 +2177,79 @@ fun test_modify_order_ok() { let usdc_price = build_demo_usdc_price_info_object(&mut scenario, &clock); let usdt_price = build_demo_usdt_price_info_object(&mut scenario, &clock); - mm.deposit( + mm.deposit( ®istry, &usdc_price, &usdt_price, - mint_coin(10000 * test_constants::usdc_multiplier(), scenario.ctx()), + mint_coin(10000 * test_constants::usdt_multiplier(), scenario.ctx()), &clock, scenario.ctx(), ); - destroy_2!(usdc_price, usdt_price); - - // First place an order - let base_pool = scenario.take_shared_by_id>(base_pool_id); - let quote_pool = scenario.take_shared_by_id>(quote_pool_id); - let order_info = test_helpers::place_limit_order_v2_for_test( - &mut scenario, + mm.borrow_base( + ®istry, + &mut base_pool, + &usdc_price, + &usdt_price, + &pool, + 500 * test_constants::usdc_multiplier(), + &clock, + scenario.ctx(), + ); + let withdrawn = mm.withdraw( ®istry, &base_pool, "e_pool, - &mut mm, - &mut pool, - 1, - constants::no_restriction(), - constants::self_matching_allowed(), - 1_000_000_000, - // price (1.0 in 9 decimals) - 100 * test_constants::usdc_multiplier(), - false, - // is_bid (sell USDC for USDT) - false, - 2000000, - // expire_timestamp + &usdc_price, + &usdt_price, + &pool, + 300 * test_constants::usdc_multiplier(), &clock, + scenario.ctx(), ); - return_shared(base_pool); - return_shared(quote_pool); - - let order_id = order_info.order_id(); + destroy(withdrawn); + destroy_2!(usdc_price, usdt_price); - // Now modify the order (new quantity must be less than original) - pool_proxy::modify_order( + // Net base debt is 500 - 200 = 300; the 400 USDC bid overshoots it and is now + // allowed (no size cap). Buying 400 (held 200 -> 600) and repaying clears the + // whole 500 debt. + let order_info = test_helpers::place_reduce_only_market_order_and_repay_loan_for_test< + USDC, + USDT, + >( + &mut scenario, ®istry, &mut mm, &mut pool, - order_id, - 50 * test_constants::usdc_multiplier(), // new quantity (less than original) + &mut base_pool, + &mut quote_pool, + 2, + constants::self_matching_allowed(), + 400 * test_constants::usdc_multiplier(), + true, + false, &clock, - scenario.ctx(), ); - destroy(order_info); - return_shared_2!(mm, pool); + + // Over-covered past the net short and fully closed. + assert_eq!(mm.borrowed_base_shares(), 0); + + return_shared_3!(mm, pool, base_pool); + return_shared(quote_pool); cleanup_margin_test(registry, _admin_cap, _maintainer_cap, clock, scenario); } #[test] -fun test_cancel_order_ok() { +fun reduce_only_limit_resting_fill_at_band_edge_bounded_and_solvent() { + // PoC for the "empty book + self-trade" concern: M1 (a short) rests a + // reduce-only bid at the +5% band edge ($1.05) — it only rests because the + // book is empty — and a counterparty M2 (a plain balance manager) takes it. + // M1 overpays 5% with NO margin re-check at the fill. We assert the leak is + // real (ratio drops) but bounded: M1 stays solvent (> 1.0), so its borrowed + // principal is still covered and it remains liquidatable. Repeating is + // impossible because a short can't ask (see + // test_place_reduce_only_limit_order_ask_requires_quote_debt), so the one-way + // direction guard caps total leak at one conversion. let ( mut scenario, clock, @@ -1873,10 +2260,13 @@ fun test_cancel_order_ok() { pool_id, registry_id, ) = setup_pool_proxy_test_env(); + // No orderbook liquidity: the band-edge bid will rest (nothing to cross). scenario.next_tx(test_constants::user1()); let mut pool = scenario.take_shared_by_id>(pool_id); let mut registry = scenario.take_shared(); + let mut base_pool = scenario.take_shared_by_id>(base_pool_id); + let mut quote_pool = scenario.take_shared_by_id>(quote_pool_id); let deepbook_registry = scenario.take_shared_by_id(registry_id); margin_manager::new( &pool, @@ -1892,60 +2282,139 @@ fun test_cancel_order_ok() { let usdc_price = build_demo_usdc_price_info_object(&mut scenario, &clock); let usdt_price = build_demo_usdt_price_info_object(&mut scenario, &clock); - mm.deposit( + // M1 short: deposit 3000 USDT, borrow 1000 USDC, withdraw it -> holds 3000 + // USDT, owes 1000 USDC (ratio 3.0). + mm.deposit( ®istry, &usdc_price, &usdt_price, - mint_coin(10000 * test_constants::usdc_multiplier(), scenario.ctx()), + mint_coin(3000 * test_constants::usdt_multiplier(), scenario.ctx()), &clock, scenario.ctx(), ); - destroy_2!(usdc_price, usdt_price); + mm.borrow_base( + ®istry, + &mut base_pool, + &usdc_price, + &usdt_price, + &pool, + 1000 * test_constants::usdc_multiplier(), + &clock, + scenario.ctx(), + ); + let withdrawn = mm.withdraw( + ®istry, + &base_pool, + "e_pool, + &usdc_price, + &usdt_price, + &pool, + 1000 * test_constants::usdc_multiplier(), + &clock, + scenario.ctx(), + ); + destroy(withdrawn); - let base_pool = scenario.take_shared_by_id>(base_pool_id); - let quote_pool = scenario.take_shared_by_id>(quote_pool_id); - let order_info = test_helpers::place_limit_order_v2_for_test( - &mut scenario, + let rr_before = mm.risk_ratio( ®istry, + &usdc_price, + &usdt_price, + &pool, &base_pool, "e_pool, + &clock, + ); + + // M1 rests a reduce-only-and-repay bid at $1.05 (the +5% band edge) to buy + // 1000 USDC. Empty book -> it rests (no taker fill, no repay at placement). + let order_info = test_helpers::place_reduce_only_limit_order_and_repay_loan_for_test< + USDC, + USDT, + >( + &mut scenario, + ®istry, &mut mm, &mut pool, + &mut base_pool, + &mut quote_pool, 1, constants::no_restriction(), constants::self_matching_allowed(), - 1_000_000_000, - // price (1.0 in 9 decimals) - 100 * test_constants::usdc_multiplier(), - false, - // is_bid (sell USDC for USDT) + 1_050_000_000, + 1000 * test_constants::usdc_multiplier(), + true, false, - 2000000, - // expire_timestamp + 2_000_000, &clock, ); - return_shared(base_pool); - return_shared(quote_pool); + destroy(order_info); + destroy_2!(usdc_price, usdt_price); - let order_id = order_info.order_id(); + // Counterparty M2 (a plain balance manager) takes M1's resting bid at $1.05, + // selling 1000 USDC for 1050 USDT. M1 overpays 5%, with no margin re-check. + scenario.next_tx(test_constants::user2()); + let mut m2 = deepbook::balance_manager::new(scenario.ctx()); + m2.deposit( + mint_coin(2000 * test_constants::usdc_multiplier(), scenario.ctx()), + scenario.ctx(), + ); + m2.deposit( + mint_coin(2000 * test_constants::usdt_multiplier(), scenario.ctx()), + scenario.ctx(), + ); + m2.deposit( + mint_coin(10000 * test_constants::deep_multiplier(), scenario.ctx()), + scenario.ctx(), + ); + let m2_proof = m2.generate_proof_as_owner(scenario.ctx()); + pool.place_limit_order( + &mut m2, + &m2_proof, + 1, + constants::no_restriction(), + constants::self_matching_allowed(), + 1_050_000_000, + 1000 * test_constants::usdc_multiplier(), + false, + false, + constants::max_u64(), + &clock, + scenario.ctx(), + ); + sui::transfer::public_transfer(m2, test_constants::user2()); - // Cancel the order - pool_proxy::cancel_order( + let usdc_price = build_demo_usdc_price_info_object(&mut scenario, &clock); + let usdt_price = build_demo_usdt_price_info_object(&mut scenario, &clock); + let rr_after = mm.risk_ratio( ®istry, - &mut mm, - &mut pool, - order_id, + &usdc_price, + &usdt_price, + &pool, + &base_pool, + "e_pool, &clock, - scenario.ctx(), ); + destroy_2!(usdc_price, usdt_price); - destroy(order_info); - return_shared_2!(mm, pool); + // The ungated self-trade leaked value (ratio dropped)... + assert!(rr_after < rr_before); + // ...but it stays bounded: M1 remains solvent (> 1.0), borrowed principal + // covered, still liquidatable. The one-way guard stops M1 repeating it. + assert!(rr_after > constants::float_scaling()); + + return_shared_3!(mm, pool, base_pool); + return_shared(quote_pool); cleanup_margin_test(registry, _admin_cap, _maintainer_cap, clock, scenario); } #[test] -fun test_cancel_orders_ok() { +fun resting_fill_from_danger_band_cannot_go_underwater() { + // Worst case for the self-trade vector: M1 starts *at liquidation* (1.10) via a + // price drift, then a counterparty M2 takes a maximal band-edge resting bid. + // Even this ungated, maximal one-shot conversion leaves M1 at ~1.05 — still + // **above 1.0** (solvent, liquidatable). It cannot be pushed underwater: the + // loss per conversion is capped by the 5% band, and the one-way direction + // guard (a short can't ask) means there's no second conversion to compound. let ( mut scenario, clock, @@ -1956,10 +2425,13 @@ fun test_cancel_orders_ok() { pool_id, registry_id, ) = setup_pool_proxy_test_env(); + // No liquidity: the band-edge bid will rest. scenario.next_tx(test_constants::user1()); let mut pool = scenario.take_shared_by_id>(pool_id); let mut registry = scenario.take_shared(); + let mut base_pool = scenario.take_shared_by_id>(base_pool_id); + let mut quote_pool = scenario.take_shared_by_id>(quote_pool_id); let deepbook_registry = scenario.take_shared_by_id(registry_id); margin_manager::new( &pool, @@ -1975,95 +2447,171 @@ fun test_cancel_orders_ok() { let usdc_price = build_demo_usdc_price_info_object(&mut scenario, &clock); let usdt_price = build_demo_usdt_price_info_object(&mut scenario, &clock); - mm.deposit( + // M1 short: 2200 USDT, owe 1000 USDC (ratio 2.2 at $1, so withdraw is allowed). + mm.deposit( ®istry, &usdc_price, &usdt_price, - mint_coin(10000 * test_constants::usdc_multiplier(), scenario.ctx()), + mint_coin(2200 * test_constants::usdt_multiplier(), scenario.ctx()), &clock, scenario.ctx(), ); - destroy_2!(usdc_price, usdt_price); - - let base_pool = scenario.take_shared_by_id>(base_pool_id); - let quote_pool = scenario.take_shared_by_id>(quote_pool_id); - let order_info1 = test_helpers::place_limit_order_v2_for_test( - &mut scenario, + mm.borrow_base( ®istry, - &base_pool, - "e_pool, - &mut mm, - &mut pool, - 1, - constants::no_restriction(), - constants::self_matching_allowed(), - 1_000_000_000, - // price (1.0 in 9 decimals) + &mut base_pool, + &usdc_price, + &usdt_price, + &pool, 1000 * test_constants::usdc_multiplier(), - // Increased quantity to meet minimum size - false, - // is_bid (sell USDC for USDT) - false, - 2000000, - // expire_timestamp &clock, + scenario.ctx(), ); - let order_info2 = test_helpers::place_limit_order_v2_for_test( + let withdrawn = mm.withdraw( + ®istry, + &base_pool, + "e_pool, + &usdc_price, + &usdt_price, + &pool, + 1000 * test_constants::usdc_multiplier(), + &clock, + scenario.ctx(), + ); + destroy(withdrawn); + destroy(usdc_price); + + // Drift USDC up to $2.00 -> the short's ratio drops to 1.10 (at liquidation). + let usdc_drifted = build_demo_usdc_price_info_object_with_price( &mut scenario, + 200_000_000, + &clock, + ); + pool_proxy::update_current_price( + &mut registry, + &pool, + &usdc_drifted, + &usdt_price, + &clock, + ); + + let rr_before = mm.risk_ratio( ®istry, + &usdc_drifted, + &usdt_price, + &pool, &base_pool, "e_pool, + &clock, + ); + assert!(rr_before <= registry.liquidation_risk_ratio(pool.id()) + 1); + + // M1 rests a reduce-only bid at the +5% band edge ($2.10) for 1000 USDC, + // locking ~2100 USDT. Empty book -> rests. + let order_info = test_helpers::place_reduce_only_limit_order_and_repay_loan_for_test< + USDC, + USDT, + >( + &mut scenario, + ®istry, &mut mm, &mut pool, - 2, + &mut base_pool, + &mut quote_pool, + 1, constants::no_restriction(), constants::self_matching_allowed(), - 1_020_000_000, - // price (1.02 in 9 decimals, slightly higher) + 2_100_000_000, 1000 * test_constants::usdc_multiplier(), - // Increased quantity to meet minimum size - false, - // is_bid (sell USDC for USDT) + true, false, - 2000000, - // expire_timestamp + 2_000_000, &clock, ); - return_shared(base_pool); - return_shared(quote_pool); + destroy(order_info); + destroy(usdt_price); - let order_ids = vector[order_info1.order_id(), order_info2.order_id()]; + // Counterparty M2 takes the resting bid at $2.10 -> M1 overpays 5%, ungated. + scenario.next_tx(test_constants::user2()); + let mut m2 = deepbook::balance_manager::new(scenario.ctx()); + m2.deposit( + mint_coin(2000 * test_constants::usdc_multiplier(), scenario.ctx()), + scenario.ctx(), + ); + m2.deposit( + mint_coin(2000 * test_constants::usdt_multiplier(), scenario.ctx()), + scenario.ctx(), + ); + m2.deposit( + mint_coin(10000 * test_constants::deep_multiplier(), scenario.ctx()), + scenario.ctx(), + ); + let m2_proof = m2.generate_proof_as_owner(scenario.ctx()); + pool.place_limit_order( + &mut m2, + &m2_proof, + 1, + constants::no_restriction(), + constants::self_matching_allowed(), + 2_100_000_000, + 1000 * test_constants::usdc_multiplier(), + false, + false, + constants::max_u64(), + &clock, + scenario.ctx(), + ); + sui::transfer::public_transfer(m2, test_constants::user2()); - pool_proxy::cancel_orders( + let usdc_drifted2 = build_demo_usdc_price_info_object_with_price( + &mut scenario, + 200_000_000, + &clock, + ); + let usdt_price = build_demo_usdt_price_info_object(&mut scenario, &clock); + let rr_after = mm.risk_ratio( ®istry, - &mut mm, - &mut pool, - order_ids, + &usdc_drifted2, + &usdt_price, + &pool, + &base_pool, + "e_pool, &clock, - scenario.ctx(), ); + destroy(usdc_drifted); + destroy(usdc_drifted2); + destroy(usdt_price); - destroy_2!(order_info1, order_info2); - return_shared_2!(mm, pool); + // Leaked ~5% (ratio dropped below liquidation) but is NEVER under 1.0. + assert!(rr_after < rr_before); + assert!(rr_after > constants::float_scaling()); + + return_shared_3!(mm, pool, base_pool); + return_shared(quote_pool); cleanup_margin_test(registry, _admin_cap, _maintainer_cap, clock, scenario); } -#[test] -fun test_cancel_all_orders_ok() { +#[test, expected_failure(abort_code = pool_proxy::ENoLiquidityInOrderbook)] +fun market_and_repay_empty_book_aborts() { + // An empty book gives no execution surface: a market-and-repay aborts on + // ENoLiquidityInOrderbook (in calculate_effective_price) before any trade, so a + // manager can't be manipulated via a market order when the book is empty. let ( mut scenario, clock, _admin_cap, _maintainer_cap, - _base_pool_id, - _quote_pool_id, + base_pool_id, + quote_pool_id, pool_id, registry_id, ) = setup_pool_proxy_test_env(); + // Deliberately no orderbook liquidity. scenario.next_tx(test_constants::user1()); let mut pool = scenario.take_shared_by_id>(pool_id); let mut registry = scenario.take_shared(); + let mut base_pool = scenario.take_shared_by_id>(base_pool_id); + let mut quote_pool = scenario.take_shared_by_id>(quote_pool_id); let deepbook_registry = scenario.take_shared_by_id(registry_id); margin_manager::new( &pool, @@ -2079,37 +2627,60 @@ fun test_cancel_all_orders_ok() { let usdc_price = build_demo_usdc_price_info_object(&mut scenario, &clock); let usdt_price = build_demo_usdt_price_info_object(&mut scenario, &clock); - mm.deposit( + mm.deposit( ®istry, &usdc_price, &usdt_price, - mint_coin(10000 * test_constants::usdc_multiplier(), scenario.ctx()), + mint_coin(3000 * test_constants::usdt_multiplier(), scenario.ctx()), + &clock, + scenario.ctx(), + ); + mm.borrow_base( + ®istry, + &mut base_pool, + &usdc_price, + &usdt_price, + &pool, + 100 * test_constants::usdc_multiplier(), &clock, scenario.ctx(), ); destroy_2!(usdc_price, usdt_price); - pool_proxy::cancel_all_orders( + // Market-and-repay against an empty book -> no liquidity, aborts. + let order_info = test_helpers::place_market_order_and_repay_loan_for_test( + &mut scenario, ®istry, &mut mm, &mut pool, + &mut base_pool, + &mut quote_pool, + 1, + constants::self_matching_allowed(), + 50 * test_constants::usdc_multiplier(), + true, + false, &clock, - scenario.ctx(), ); + destroy(order_info); - return_shared_2!(mm, pool); - cleanup_margin_test(registry, _admin_cap, _maintainer_cap, clock, scenario); + abort 999 } #[test] -fun test_withdraw_settled_amounts_ok() { +fun resting_order_by_itself_is_ratio_neutral() { + // "By itself" (no counterparty fill) a manager can't move its own ratio: + // placing a reduce-only resting order locks balance that still counts as + // assets, so the ratio is unchanged. Only a real fill can move it — and a + // taker fill is gated, while a self-match (same balance manager) is + // value-neutral. So a manager can't manipulate itself underwater. let ( mut scenario, clock, _admin_cap, _maintainer_cap, - _base_pool_id, - _quote_pool_id, + base_pool_id, + quote_pool_id, pool_id, registry_id, ) = setup_pool_proxy_test_env(); @@ -2117,6 +2688,8 @@ fun test_withdraw_settled_amounts_ok() { scenario.next_tx(test_constants::user1()); let mut pool = scenario.take_shared_by_id>(pool_id); let mut registry = scenario.take_shared(); + let mut base_pool = scenario.take_shared_by_id>(base_pool_id); + let mut quote_pool = scenario.take_shared_by_id>(quote_pool_id); let deepbook_registry = scenario.take_shared_by_id(registry_id); margin_manager::new( &pool, @@ -2129,34 +2702,115 @@ fun test_withdraw_settled_amounts_ok() { scenario.next_tx(test_constants::user1()); let mut mm = scenario.take_shared>(); + let usdc_price = build_demo_usdc_price_info_object(&mut scenario, &clock); + let usdt_price = build_demo_usdt_price_info_object(&mut scenario, &clock); - pool_proxy::withdraw_settled_amounts( + mm.deposit( + ®istry, + &usdc_price, + &usdt_price, + mint_coin(3000 * test_constants::usdt_multiplier(), scenario.ctx()), + &clock, + scenario.ctx(), + ); + mm.borrow_base( + ®istry, + &mut base_pool, + &usdc_price, + &usdt_price, + &pool, + 1000 * test_constants::usdc_multiplier(), + &clock, + scenario.ctx(), + ); + // Withdraw the borrowed USDC so M1 is a real short (no idle USDC for the + // and-repay to sweep; the placement is then a pure resting order). + let withdrawn = mm.withdraw( + ®istry, + &base_pool, + "e_pool, + &usdc_price, + &usdt_price, + &pool, + 1000 * test_constants::usdc_multiplier(), + &clock, + scenario.ctx(), + ); + destroy(withdrawn); + + let rr_before = mm.risk_ratio( + ®istry, + &usdc_price, + &usdt_price, + &pool, + &base_pool, + "e_pool, + &clock, + ); + + // Place a reduce-only resting bid; no counterparty, so it just rests. + let order_info = test_helpers::place_reduce_only_limit_order_and_repay_loan_for_test< + USDC, + USDT, + >( + &mut scenario, ®istry, &mut mm, &mut pool, - scenario.ctx(), + &mut base_pool, + &mut quote_pool, + 1, + constants::no_restriction(), + constants::self_matching_allowed(), + 1_050_000_000, + 500 * test_constants::usdc_multiplier(), + true, + false, + 2_000_000, + &clock, ); + destroy(order_info); - return_shared_2!(mm, pool); + let rr_after = mm.risk_ratio( + ®istry, + &usdc_price, + &usdt_price, + &pool, + &base_pool, + "e_pool, + &clock, + ); + destroy_2!(usdc_price, usdt_price); + + // Locked-in-order balance still counts as assets -> ratio unchanged. + assert_eq!(rr_after, rr_before); + + return_shared_3!(mm, pool, base_pool); + return_shared(quote_pool); cleanup_margin_test(registry, _admin_cap, _maintainer_cap, clock, scenario); } -#[test] -fun test_unstake_ok() { +#[test, expected_failure(abort_code = pool_proxy::EIncorrectDeepBookPool)] +fun test_place_reduce_only_market_order_incorrect_pool() { let ( mut scenario, clock, _admin_cap, _maintainer_cap, - _base_pool_id, - _quote_pool_id, + base_pool_id, + quote_pool_id, pool_id, registry_id, ) = setup_pool_proxy_test_env(); + let (wrong_pool_id, _wrong_registry_id) = create_pool_for_testing(&mut scenario); + scenario.next_tx(test_constants::user1()); - let mut pool = scenario.take_shared_by_id>(pool_id); + let pool = scenario.take_shared_by_id>(pool_id); + let mut wrong_pool = scenario.take_shared_by_id>(wrong_pool_id); let mut registry = scenario.take_shared(); + let quote_pool = scenario.take_shared_by_id>(quote_pool_id); + let deepbook_registry = scenario.take_shared_by_id(registry_id); margin_manager::new( &pool, @@ -2169,27 +2823,38 @@ fun test_unstake_ok() { scenario.next_tx(test_constants::user1()); let mut mm = scenario.take_shared>(); + let base_pool = scenario.take_shared_by_id>(base_pool_id); - pool_proxy::unstake( + test_helpers::place_reduce_only_market_order_v2_for_test( + &mut scenario, ®istry, + &base_pool, + "e_pool, &mut mm, - &mut pool, - scenario.ctx(), + &mut wrong_pool, + 4, + constants::self_matching_allowed(), + 500, + false, + false, + &clock, ); - return_shared_2!(mm, pool); - cleanup_margin_test(registry, _admin_cap, _maintainer_cap, clock, scenario); + abort } -#[test] -fun test_submit_proposal_ok() { +// Market-order counterpart of `test_place_reduce_only_limit_order_v2_unregistered_pool`. +// See that test for the rationale (registry uniqueness blocks a debt-side +// mismatch; only the no-debt path is constructable). +#[test, expected_failure(abort_code = margin_manager::EIncorrectMarginPool)] +fun test_place_reduce_only_market_order_v2_unregistered_pool() { let ( mut scenario, clock, - admin_cap, + _admin_cap, _maintainer_cap, - _base_pool_id, - _quote_pool_id, + base_pool_id, + quote_pool_id, pool_id, registry_id, ) = setup_pool_proxy_test_env(); @@ -2197,6 +2862,8 @@ fun test_submit_proposal_ok() { scenario.next_tx(test_constants::user1()); let mut pool = scenario.take_shared_by_id>(pool_id); let mut registry = scenario.take_shared(); + let base_pool = scenario.take_shared_by_id>(base_pool_id); + let quote_pool = scenario.take_shared_by_id>(quote_pool_id); let deepbook_registry = scenario.take_shared_by_id(registry_id); margin_manager::new( &pool, @@ -2209,69 +2876,45 @@ fun test_submit_proposal_ok() { scenario.next_tx(test_constants::user1()); let mut mm = scenario.take_shared>(); - let usdc_price = build_demo_usdc_price_info_object(&mut scenario, &clock); - let usdt_price = build_demo_usdt_price_info_object(&mut scenario, &clock); - // Deposit DEEP tokens - mm.deposit( - ®istry, - &usdc_price, - &usdt_price, - mint_coin( - 20000 * test_constants::deep_multiplier(), - scenario.ctx(), - ), // 20000 DEEP with 6 decimals - &clock, - scenario.ctx(), - ); - destroy_2!(usdc_price, usdt_price); - - // Stake DEEP tokens (10000 DEEP to be safe) - pool_proxy::stake( - ®istry, - &mut mm, - &mut pool, - 10000 * test_constants::deep_multiplier(), // 10000 DEEP stake amount - scenario.ctx(), - ); - - // Transition to next epoch for stake to become active - scenario.next_epoch(test_constants::admin()); - - // Continue the transaction as user1 - scenario.next_tx(test_constants::user1()); - - // Now submit a proposal - pool_proxy::submit_proposal( + test_helpers::place_reduce_only_market_order_v2_for_test( + &mut scenario, ®istry, + &base_pool, + "e_pool, &mut mm, &mut pool, - 600000, // taker_fee - 200000, // maker_fee - 10000 * test_constants::deep_multiplier(), // stake_required - scenario.ctx(), + 2, + constants::self_matching_allowed(), + 100 * test_constants::usdc_multiplier(), + true, + false, + &clock, ); - return_shared_2!(mm, pool); - cleanup_margin_test(registry, admin_cap, _maintainer_cap, clock, scenario); + abort } -#[test] -fun test_vote_ok() { +#[test, expected_failure(abort_code = pool_proxy::ENotReduceOnlyOrder)] +fun test_place_reduce_only_market_order_not_reduce_only() { let ( mut scenario, clock, - admin_cap, + _admin_cap, _maintainer_cap, - _base_pool_id, - _quote_pool_id, + base_pool_id, + quote_pool_id, pool_id, registry_id, ) = setup_pool_proxy_test_env(); + // Set up orderbook liquidity for market orders + setup_orderbook_liquidity_stablecoin(&mut scenario, pool_id, &clock); + scenario.next_tx(test_constants::user1()); let mut pool = scenario.take_shared_by_id>(pool_id); let mut registry = scenario.take_shared(); + let mut quote_pool = scenario.take_shared_by_id>(quote_pool_id); let deepbook_registry = scenario.take_shared_by_id(registry_id); margin_manager::new( &pool, @@ -2287,79 +2930,82 @@ fun test_vote_ok() { let usdc_price = build_demo_usdc_price_info_object(&mut scenario, &clock); let usdt_price = build_demo_usdt_price_info_object(&mut scenario, &clock); - // Deposit DEEP tokens - mm.deposit( + // Deposit some USDT to use as collateral + mm.deposit( ®istry, &usdc_price, &usdt_price, - mint_coin( - 20000 * test_constants::deep_multiplier(), - scenario.ctx(), - ), // 20000 DEEP with 6 decimals + mint_coin(10000 * test_constants::usdt_multiplier(), scenario.ctx()), &clock, scenario.ctx(), ); destroy_2!(usdc_price, usdt_price); - // Stake DEEP tokens (10000 DEEP to be safe) - pool_proxy::stake( - ®istry, - &mut mm, - &mut pool, - 10000 * test_constants::deep_multiplier(), // 10000 DEEP stake amount - scenario.ctx(), - ); - - // Transition to next epoch for stake to become active - scenario.next_epoch(test_constants::admin()); - - // Continue the transaction as user1 - scenario.next_tx(test_constants::user1()); - - // Get the balance manager ID to use as proposal ID - let balance_manager = mm.balance_manager(); - let balance_manager_id = object::id(balance_manager); - - // First submit a proposal (this creates a proposal with balance_manager_id as the key) - pool_proxy::submit_proposal( + let usdc_price = build_demo_usdc_price_info_object(&mut scenario, &clock); + let usdt_price = build_demo_usdt_price_info_object(&mut scenario, &clock); + // Borrow some USDT to establish relationship with quote pool + mm.borrow_quote( ®istry, - &mut mm, - &mut pool, - 600000, // taker_fee - 200000, // maker_fee - 10000 * test_constants::deep_multiplier(), // stake_required + &mut quote_pool, + &usdc_price, + &usdt_price, + &pool, + 100 * test_constants::usdt_multiplier(), // Small borrow amount + &clock, scenario.ctx(), ); - // Vote on the proposal using balance manager ID as proposal ID - pool_proxy::vote( + // User has no USDC debt but has USDT debt, tries to buy USDC (is_bid = true) + // This should fail because it's not reducing any USDC position - user is increasing exposure + let base_pool = scenario.take_shared_by_id>(base_pool_id); + test_helpers::place_reduce_only_market_order_v2_for_test( + &mut scenario, ®istry, + &base_pool, + "e_pool, &mut mm, &mut pool, - balance_manager_id, - scenario.ctx(), + // Pass quote_pool since we have USDT debt + 3, + constants::self_matching_allowed(), + 100 * test_constants::usdc_multiplier(), + // quantity + true, + // is_bid = true (buying USDC) + false, + &clock, ); - return_shared_2!(mm, pool); - cleanup_margin_test(registry, admin_cap, _maintainer_cap, clock, scenario); + return_shared_3!(mm, pool, quote_pool); + return_shared(base_pool); + destroy(usdc_price); + destroy(usdt_price); + cleanup_margin_test(registry, _admin_cap, _maintainer_cap, clock, scenario); } -#[test] -fun test_claim_rebates_ok() { +#[test, expected_failure(abort_code = pool_proxy::ENotReduceOnlyOrder)] +fun test_place_reduce_only_market_order_bid_requires_base_debt() { + // Market sibling of the limit direction-guard test: a reduce-only bid needs + // base (short-side) debt. A long (quote debt, no base debt) bidding is not + // reduce-only. let ( mut scenario, clock, _admin_cap, _maintainer_cap, - _base_pool_id, - _quote_pool_id, + base_pool_id, + quote_pool_id, pool_id, registry_id, ) = setup_pool_proxy_test_env(); + setup_orderbook_liquidity_stablecoin(&mut scenario, pool_id, &clock); + scenario.next_tx(test_constants::user1()); let mut pool = scenario.take_shared_by_id>(pool_id); let mut registry = scenario.take_shared(); + let base_pool = scenario.take_shared_by_id>(base_pool_id); + let mut quote_pool = scenario.take_shared_by_id>(quote_pool_id); let deepbook_registry = scenario.take_shared_by_id(registry_id); margin_manager::new( &pool, @@ -2372,21 +3018,51 @@ fun test_claim_rebates_ok() { scenario.next_tx(test_constants::user1()); let mut mm = scenario.take_shared>(); + let usdc_price = build_demo_usdc_price_info_object(&mut scenario, &clock); + let usdt_price = build_demo_usdt_price_info_object(&mut scenario, &clock); - pool_proxy::claim_rebates( + // USDC collateral + USDT (quote) debt -> a long, no base debt. + mm.deposit( + ®istry, + &usdc_price, + &usdt_price, + mint_coin(10000 * test_constants::usdc_multiplier(), scenario.ctx()), + &clock, + scenario.ctx(), + ); + mm.borrow_quote( + ®istry, + &mut quote_pool, + &usdc_price, + &usdt_price, + &pool, + 100 * test_constants::usdt_multiplier(), + &clock, + scenario.ctx(), + ); + destroy_2!(usdc_price, usdt_price); + + // Bid (buy USDC) with no base debt -> not reduce-only (direction guard). + test_helpers::place_reduce_only_market_order_v2_for_test( + &mut scenario, ®istry, + &base_pool, + "e_pool, &mut mm, &mut pool, - scenario.ctx(), + 4, + constants::self_matching_allowed(), + 50 * test_constants::usdc_multiplier(), + true, + false, + &clock, ); - return_shared_2!(mm, pool); - cleanup_margin_test(registry, _admin_cap, _maintainer_cap, clock, scenario); + abort 999 } -// === Permissionless Settlement Tests === -#[test] -fun test_withdraw_settled_amounts_permissionless_ok() { +#[test, expected_failure(abort_code = pool_proxy::ENotReduceOnlyOrder)] +fun test_place_reduce_only_market_order_not_reduce_only_quantity_ask() { let ( mut scenario, clock, @@ -2398,10 +3074,13 @@ fun test_withdraw_settled_amounts_permissionless_ok() { registry_id, ) = setup_pool_proxy_test_env(); - // User1 creates margin manager and places an order + setup_orderbook_liquidity_stablecoin(&mut scenario, pool_id, &clock); + scenario.next_tx(test_constants::user1()); let mut pool = scenario.take_shared_by_id>(pool_id); let mut registry = scenario.take_shared(); + let base_pool = scenario.take_shared_by_id>(base_pool_id); + let mut quote_pool = scenario.take_shared_by_id>(quote_pool_id); let deepbook_registry = scenario.take_shared_by_id(registry_id); margin_manager::new( &pool, @@ -2417,7 +3096,6 @@ fun test_withdraw_settled_amounts_permissionless_ok() { let usdc_price = build_demo_usdc_price_info_object(&mut scenario, &clock); let usdt_price = build_demo_usdt_price_info_object(&mut scenario, &clock); - // Deposit USDC mm.deposit( ®istry, &usdc_price, @@ -2426,42 +3104,70 @@ fun test_withdraw_settled_amounts_permissionless_ok() { &clock, scenario.ctx(), ); - destroy_2!(usdc_price, usdt_price); - // Place a sell order - // Price must be within 5% of oracle price (1_000_000_000 for USDC/USDT at $1 each) - let base_pool = scenario.take_shared_by_id>(base_pool_id); - let quote_pool = scenario.take_shared_by_id>(quote_pool_id); - let order_info = test_helpers::place_limit_order_v2_for_test( + mm.borrow_quote( + ®istry, + &mut quote_pool, + &usdc_price, + &usdt_price, + &pool, + 100 * test_constants::usdt_multiplier(), + &clock, + scenario.ctx(), + ); + + let coin = mm.withdraw( + ®istry, + &base_pool, + "e_pool, + &usdc_price, + &usdt_price, + &pool, + 100 * test_constants::usdt_multiplier(), + &clock, + scenario.ctx(), + ); + destroy(coin); + + // User holds 10000 USDC base and has USDT (quote) debt. The reduce-only ask + // cap is the gross base held, so selling more base than held (11000 > 10000) + // is not a reduce-only order. + test_helpers::place_reduce_only_market_order_v2_for_test( &mut scenario, ®istry, &base_pool, "e_pool, &mut mm, &mut pool, - 1, - // client_order_id - constants::no_restriction(), + 5, constants::self_matching_allowed(), - 1_000_000_000, - // price (1.0 in 9 decimals, within 5% of oracle price) - 100 * test_constants::usdc_multiplier(), - // quantity + 11000 * test_constants::usdc_multiplier(), false, - // is_bid (sell USDC for USDT) + // is_bid = false (selling USDC to get USDT) false, - // pay_with_deep - 2000000, - // expire_timestamp &clock, ); - return_shared(base_pool); - return_shared(quote_pool); - destroy(order_info); + abort +} - // User2 places a matching buy order to fill user1's order - scenario.next_tx(test_constants::user2()); +// === Stake Tests === +#[test] +fun test_stake_ok() { + let ( + mut scenario, + clock, + _admin_cap, + _maintainer_cap, + _base_pool_id, + _quote_pool_id, + pool_id, + registry_id, + ) = setup_pool_proxy_test_env(); + + scenario.next_tx(test_constants::user1()); + let mut pool = scenario.take_shared_by_id>(pool_id); + let mut registry = scenario.take_shared(); let deepbook_registry = scenario.take_shared_by_id(registry_id); margin_manager::new( &pool, @@ -2472,68 +3178,37 @@ fun test_withdraw_settled_amounts_permissionless_ok() { ); return_shared(deepbook_registry); - scenario.next_tx(test_constants::user2()); - let mut mm2 = scenario.take_shared>(); + scenario.next_tx(test_constants::user1()); + let mut mm = scenario.take_shared>(); let usdc_price = build_demo_usdc_price_info_object(&mut scenario, &clock); let usdt_price = build_demo_usdt_price_info_object(&mut scenario, &clock); - // Deposit USDT for user2 - mm2.deposit( + // Deposit DEEP tokens + mm.deposit( ®istry, &usdc_price, &usdt_price, - mint_coin(10000 * test_constants::usdt_multiplier(), scenario.ctx()), + mint_coin(1000 * test_constants::deep_multiplier(), scenario.ctx()), &clock, scenario.ctx(), ); destroy_2!(usdc_price, usdt_price); - // Place a buy order that matches user1's sell order - let base_pool = scenario.take_shared_by_id>(base_pool_id); - let quote_pool = scenario.take_shared_by_id>(quote_pool_id); - let order_info2 = test_helpers::place_limit_order_v2_for_test( - &mut scenario, - ®istry, - &base_pool, - "e_pool, - &mut mm2, - &mut pool, - 2, - // client_order_id - constants::no_restriction(), - constants::self_matching_allowed(), - 1_000_000_000, - // same price (1.0 in 9 decimals) - 100 * test_constants::usdc_multiplier(), - // same quantity - true, - // is_bid (buy USDC with USDT) - false, - // pay_with_deep - 2000000, - // expire_timestamp - &clock, - ); - return_shared(base_pool); - return_shared(quote_pool); - destroy(order_info2); - - // Now user1 has settled balances (received USDT from the trade) - // User2 (not the owner) calls withdraw_settled_amounts_permissionless for user1 - scenario.next_tx(test_constants::user2()); - pool_proxy::withdraw_settled_amounts_permissionless( + // Stake DEEP tokens - should work since this is not a DEEP margin manager + pool_proxy::stake( ®istry, &mut mm, &mut pool, + 100 * test_constants::deep_multiplier(), // 100 DEEP + scenario.ctx(), ); - // Verify that the settlement succeeded (if it failed, we would have aborted) - return_shared_3!(mm, mm2, pool); + return_shared_2!(mm, pool); cleanup_margin_test(registry, _admin_cap, _maintainer_cap, clock, scenario); } -#[test, expected_failure(abort_code = ::deepbook::vault::ENoBalanceToSettle)] -fun test_withdraw_settled_amounts_permissionless_no_balance_e() { +#[test, expected_failure(abort_code = pool_proxy::ECannotStakeWithDeepMarginManager)] +fun test_stake_with_deep_margin_manager() { let ( mut scenario, clock, @@ -2543,14 +3218,13 @@ fun test_withdraw_settled_amounts_permissionless_no_balance_e() { _quote_pool_id, pool_id, registry_id, - ) = setup_pool_proxy_test_env(); + ) = setup_pool_proxy_test_env(); - // User1 creates margin manager but doesn't trade scenario.next_tx(test_constants::user1()); - let mut pool = scenario.take_shared_by_id>(pool_id); + let mut pool = scenario.take_shared_by_id>(pool_id); let mut registry = scenario.take_shared(); let deepbook_registry = scenario.take_shared_by_id(registry_id); - margin_manager::new( + margin_manager::new( &pool, &deepbook_registry, &mut registry, @@ -2560,68 +3234,23 @@ fun test_withdraw_settled_amounts_permissionless_no_balance_e() { return_shared(deepbook_registry); scenario.next_tx(test_constants::user1()); - let mut mm = scenario.take_shared>(); + let mut mm = scenario.take_shared>(); - // Try to settle when there's nothing to settle - should fail - scenario.next_tx(test_constants::user2()); - pool_proxy::withdraw_settled_amounts_permissionless( + // Try to stake with DEEP margin manager - should fail + pool_proxy::stake( ®istry, &mut mm, &mut pool, - ); - - abort 0 -} - -#[test, expected_failure(abort_code = margin_manager::EIncorrectDeepBookPool)] -fun test_withdraw_settled_amounts_permissionless_incorrect_pool_e() { - let ( - mut scenario, - clock, - _admin_cap, - _maintainer_cap, - _base_pool_id, - _quote_pool_id, - pool_id, - registry_id, - ) = setup_pool_proxy_test_env(); - - // Create a wrong pool - let (wrong_pool_id, _wrong_registry_id) = create_pool_for_testing(&mut scenario); - - scenario.next_tx(test_constants::user1()); - let pool = scenario.take_shared_by_id>(pool_id); - let mut wrong_pool = scenario.take_shared_by_id>(wrong_pool_id); - let mut registry = scenario.take_shared(); - let deepbook_registry = scenario.take_shared_by_id(registry_id); - margin_manager::new( - &pool, - &deepbook_registry, - &mut registry, - &clock, + 100 * test_constants::deep_multiplier(), scenario.ctx(), ); - return_shared(deepbook_registry); - - scenario.next_tx(test_constants::user1()); - let mut mm = scenario.take_shared>(); - - // Try to settle with wrong pool - should fail - scenario.next_tx(test_constants::user2()); - pool_proxy::withdraw_settled_amounts_permissionless( - ®istry, - &mut mm, - &mut wrong_pool, // Wrong pool! - ); - abort 0 + abort } -// === Price Protection Tests === - -#[test, expected_failure(abort_code = margin_registry::EPriceDeviationTooHigh)] -fun test_limit_order_price_too_high() { - // Test that a limit order with price > 5% above oracle price is rejected +// === Other Function Tests === +#[test] +fun test_modify_order_ok() { let ( mut scenario, clock, @@ -2651,7 +3280,6 @@ fun test_limit_order_price_too_high() { let usdc_price = build_demo_usdc_price_info_object(&mut scenario, &clock); let usdt_price = build_demo_usdt_price_info_object(&mut scenario, &clock); - // Deposit collateral mm.deposit( ®istry, &usdc_price, @@ -2660,15 +3288,12 @@ fun test_limit_order_price_too_high() { &clock, scenario.ctx(), ); - destroy_2!(usdc_price, usdt_price); - // Try to place order at price 10% above oracle (1_100_000_000) - // Oracle price is 1_000_000_000 (1.0), tolerance is 5% - // 10% above = 1_100_000_000, which exceeds upper bound of 1_050_000_000 + // First place an order let base_pool = scenario.take_shared_by_id>(base_pool_id); let quote_pool = scenario.take_shared_by_id>(quote_pool_id); - test_helpers::place_limit_order_v2_for_test( + let order_info = test_helpers::place_limit_order_v2_for_test( &mut scenario, ®istry, &base_pool, @@ -2678,24 +3303,39 @@ fun test_limit_order_price_too_high() { 1, constants::no_restriction(), constants::self_matching_allowed(), - 1_100_000_000, - // 10% above oracle - should fail + 1_000_000_000, + // price (1.0 in 9 decimals) 100 * test_constants::usdc_multiplier(), - true, false, - 18446744073709551615, + // is_bid (sell USDC for USDT) + false, + 2000000, + // expire_timestamp &clock, ); return_shared(base_pool); return_shared(quote_pool); - abort 0 + let order_id = order_info.order_id(); + + // Now modify the order (new quantity must be less than original) + pool_proxy::modify_order( + ®istry, + &mut mm, + &mut pool, + order_id, + 50 * test_constants::usdc_multiplier(), // new quantity (less than original) + &clock, + scenario.ctx(), + ); + + destroy(order_info); + return_shared_2!(mm, pool); + cleanup_margin_test(registry, _admin_cap, _maintainer_cap, clock, scenario); } -#[test, expected_failure(abort_code = margin_registry::EPriceDeviationTooHigh)] -fun test_limit_order_price_too_low() { - // Test that an ask (sell) order with price < 5% below oracle price is rejected - // Asks only check lower bound - selling below oracle is bad +#[test] +fun test_cancel_order_ok() { let ( mut scenario, clock, @@ -2725,7 +3365,6 @@ fun test_limit_order_price_too_low() { let usdc_price = build_demo_usdc_price_info_object(&mut scenario, &clock); let usdt_price = build_demo_usdt_price_info_object(&mut scenario, &clock); - // Deposit collateral mm.deposit( ®istry, &usdc_price, @@ -2734,16 +3373,11 @@ fun test_limit_order_price_too_low() { &clock, scenario.ctx(), ); - destroy_2!(usdc_price, usdt_price); - // Try to place ask order at price 10% below oracle (900_000_000) - // Oracle price is 1_000_000_000 (1.0), tolerance is 5% - // 10% below = 900_000_000, which is below lower bound of 950_000_000 - // For asks, lower bound is checked - should fail let base_pool = scenario.take_shared_by_id>(base_pool_id); let quote_pool = scenario.take_shared_by_id>(quote_pool_id); - test_helpers::place_limit_order_v2_for_test( + let order_info = test_helpers::place_limit_order_v2_for_test( &mut scenario, ®istry, &base_pool, @@ -2753,24 +3387,38 @@ fun test_limit_order_price_too_low() { 1, constants::no_restriction(), constants::self_matching_allowed(), - 900_000_000, - // 10% below oracle - should fail for asks + 1_000_000_000, + // price (1.0 in 9 decimals) 100 * test_constants::usdc_multiplier(), false, - // is_bid = false (ask/sell order) + // is_bid (sell USDC for USDT) false, - 18446744073709551615, + 2000000, + // expire_timestamp &clock, ); return_shared(base_pool); return_shared(quote_pool); - abort 0 + let order_id = order_info.order_id(); + + // Cancel the order + pool_proxy::cancel_order( + ®istry, + &mut mm, + &mut pool, + order_id, + &clock, + scenario.ctx(), + ); + + destroy(order_info); + return_shared_2!(mm, pool); + cleanup_margin_test(registry, _admin_cap, _maintainer_cap, clock, scenario); } #[test] -fun test_limit_order_price_at_upper_bound_ok() { - // Test that a limit order at exactly 5% above oracle is allowed +fun test_cancel_orders_ok() { let ( mut scenario, clock, @@ -2800,7 +3448,6 @@ fun test_limit_order_price_at_upper_bound_ok() { let usdc_price = build_demo_usdc_price_info_object(&mut scenario, &clock); let usdt_price = build_demo_usdt_price_info_object(&mut scenario, &clock); - // Deposit collateral mm.deposit( ®istry, &usdc_price, @@ -2809,15 +3456,11 @@ fun test_limit_order_price_at_upper_bound_ok() { &clock, scenario.ctx(), ); - destroy_2!(usdc_price, usdt_price); - // Place order at price exactly at 5% above oracle (1_050_000_000) - // Oracle price is 1_000_000_000 (1.0), upper bound = 1_050_000_000 - // Using is_bid=false (sell USDC) since we deposited USDC let base_pool = scenario.take_shared_by_id>(base_pool_id); let quote_pool = scenario.take_shared_by_id>(quote_pool_id); - let order_info = test_helpers::place_limit_order_v2_for_test( + let order_info1 = test_helpers::place_limit_order_v2_for_test( &mut scenario, ®istry, &base_pool, @@ -2827,34 +3470,66 @@ fun test_limit_order_price_at_upper_bound_ok() { 1, constants::no_restriction(), constants::self_matching_allowed(), - 1_050_000_000, - // Exactly at upper bound - should succeed - 100 * test_constants::usdc_multiplier(), + 1_000_000_000, + // price (1.0 in 9 decimals) + 1000 * test_constants::usdc_multiplier(), + // Increased quantity to meet minimum size false, - // sell USDC for USDT + // is_bid (sell USDC for USDT) false, - 18446744073709551615, + 2000000, + // expire_timestamp + &clock, + ); + let order_info2 = test_helpers::place_limit_order_v2_for_test( + &mut scenario, + ®istry, + &base_pool, + "e_pool, + &mut mm, + &mut pool, + 2, + constants::no_restriction(), + constants::self_matching_allowed(), + 1_020_000_000, + // price (1.02 in 9 decimals, slightly higher) + 1000 * test_constants::usdc_multiplier(), + // Increased quantity to meet minimum size + false, + // is_bid (sell USDC for USDT) + false, + 2000000, + // expire_timestamp &clock, ); return_shared(base_pool); return_shared(quote_pool); - assert!(order_info.client_order_id() == 1); - destroy(order_info); + let order_ids = vector[order_info1.order_id(), order_info2.order_id()]; + + pool_proxy::cancel_orders( + ®istry, + &mut mm, + &mut pool, + order_ids, + &clock, + scenario.ctx(), + ); + + destroy_2!(order_info1, order_info2); return_shared_2!(mm, pool); cleanup_margin_test(registry, _admin_cap, _maintainer_cap, clock, scenario); } #[test] -fun test_limit_order_price_at_lower_bound_ok() { - // Test that a limit order at exactly 5% below oracle is allowed +fun test_cancel_all_orders_ok() { let ( mut scenario, clock, _admin_cap, _maintainer_cap, - base_pool_id, - quote_pool_id, + _base_pool_id, + _quote_pool_id, pool_id, registry_id, ) = setup_pool_proxy_test_env(); @@ -2877,7 +3552,6 @@ fun test_limit_order_price_at_lower_bound_ok() { let usdc_price = build_demo_usdc_price_info_object(&mut scenario, &clock); let usdt_price = build_demo_usdt_price_info_object(&mut scenario, &clock); - // Deposit collateral mm.deposit( ®istry, &usdc_price, @@ -2886,613 +3560,554 @@ fun test_limit_order_price_at_lower_bound_ok() { &clock, scenario.ctx(), ); - destroy_2!(usdc_price, usdt_price); - // Place order at price exactly at 5% below oracle (950_000_000) - // Oracle price is 1_000_000_000 (1.0), lower bound = 950_000_000 - // Using is_bid=false (sell USDC) since we deposited USDC - let base_pool = scenario.take_shared_by_id>(base_pool_id); - let quote_pool = scenario.take_shared_by_id>(quote_pool_id); - let order_info = test_helpers::place_limit_order_v2_for_test( - &mut scenario, + pool_proxy::cancel_all_orders( ®istry, - &base_pool, - "e_pool, &mut mm, &mut pool, - 1, - constants::no_restriction(), - constants::self_matching_allowed(), - 950_000_000, - // Exactly at lower bound - should succeed - 100 * test_constants::usdc_multiplier(), - false, - // sell USDC for USDT - false, - 18446744073709551615, &clock, + scenario.ctx(), ); - return_shared(base_pool); - return_shared(quote_pool); - assert!(order_info.client_order_id() == 1); - destroy(order_info); return_shared_2!(mm, pool); cleanup_margin_test(registry, _admin_cap, _maintainer_cap, clock, scenario); } -#[test, expected_failure(abort_code = margin_registry::EToleranceTooLow)] -fun test_set_tolerance_too_low() { - // Test that tolerance below 1% is rejected - let (mut scenario, clock, admin_cap, maintainer_cap) = setup_margin_registry(); - - // Create margin pools first (required for enabling DeepBook pool) - let _base_pool_id = create_margin_pool( - &mut scenario, - &maintainer_cap, - default_protocol_config(), - &clock, - ); - let _quote_pool_id = create_margin_pool( - &mut scenario, - &maintainer_cap, - default_protocol_config(), - &clock, - ); - - // Create and enable a pool - let (pool_id, _registry_id) = create_pool_for_testing(&mut scenario); +#[test] +fun test_withdraw_settled_amounts_ok() { + let ( + mut scenario, + clock, + _admin_cap, + _maintainer_cap, + _base_pool_id, + _quote_pool_id, + pool_id, + registry_id, + ) = setup_pool_proxy_test_env(); - scenario.next_tx(test_constants::admin()); + scenario.next_tx(test_constants::user1()); + let mut pool = scenario.take_shared_by_id>(pool_id); let mut registry = scenario.take_shared(); - enable_deepbook_margin_on_pool( - pool_id, + let deepbook_registry = scenario.take_shared_by_id(registry_id); + margin_manager::new( + &pool, + &deepbook_registry, &mut registry, - &admin_cap, &clock, - &mut scenario, + scenario.ctx(), ); - return_shared(registry); + return_shared(deepbook_registry); - scenario.next_tx(test_constants::admin()); - let pool = scenario.take_shared_by_id>(pool_id); - let mut registry = scenario.take_shared(); + scenario.next_tx(test_constants::user1()); + let mut mm = scenario.take_shared>(); - // Try to set tolerance below 1% (10_000_000) - registry.set_price_tolerance( - &admin_cap, - &pool, - 5_000_000, // 0.5% - too low - &clock, + pool_proxy::withdraw_settled_amounts( + ®istry, + &mut mm, + &mut pool, + scenario.ctx(), ); - abort 0 + return_shared_2!(mm, pool); + cleanup_margin_test(registry, _admin_cap, _maintainer_cap, clock, scenario); } -#[test, expected_failure(abort_code = margin_registry::EToleranceTooHigh)] -fun test_set_tolerance_too_high() { - // Test that tolerance above 50% is rejected - let (mut scenario, clock, admin_cap, maintainer_cap) = setup_margin_registry(); - - // Create margin pools first (required for enabling DeepBook pool) - let _base_pool_id = create_margin_pool( - &mut scenario, - &maintainer_cap, - default_protocol_config(), - &clock, - ); - let _quote_pool_id = create_margin_pool( - &mut scenario, - &maintainer_cap, - default_protocol_config(), - &clock, - ); - - // Create and enable a pool - let (pool_id, _registry_id) = create_pool_for_testing(&mut scenario); +#[test] +fun test_unstake_ok() { + let ( + mut scenario, + clock, + _admin_cap, + _maintainer_cap, + _base_pool_id, + _quote_pool_id, + pool_id, + registry_id, + ) = setup_pool_proxy_test_env(); - scenario.next_tx(test_constants::admin()); + scenario.next_tx(test_constants::user1()); + let mut pool = scenario.take_shared_by_id>(pool_id); let mut registry = scenario.take_shared(); - enable_deepbook_margin_on_pool( - pool_id, + let deepbook_registry = scenario.take_shared_by_id(registry_id); + margin_manager::new( + &pool, + &deepbook_registry, &mut registry, - &admin_cap, &clock, - &mut scenario, + scenario.ctx(), ); - return_shared(registry); + return_shared(deepbook_registry); - scenario.next_tx(test_constants::admin()); - let pool = scenario.take_shared_by_id>(pool_id); - let mut registry = scenario.take_shared(); + scenario.next_tx(test_constants::user1()); + let mut mm = scenario.take_shared>(); - // Try to set tolerance above 50% (500_000_000) - registry.set_price_tolerance( - &admin_cap, - &pool, - 600_000_000, // 60% - too high - &clock, + pool_proxy::unstake( + ®istry, + &mut mm, + &mut pool, + scenario.ctx(), ); - abort 0 + return_shared_2!(mm, pool); + cleanup_margin_test(registry, _admin_cap, _maintainer_cap, clock, scenario); } #[test] -fun test_set_tolerance_within_bounds_ok() { - // Test that setting tolerance within 1%-50% is allowed - let (mut scenario, clock, admin_cap, maintainer_cap) = setup_margin_registry(); +fun test_submit_proposal_ok() { + let ( + mut scenario, + clock, + admin_cap, + _maintainer_cap, + _base_pool_id, + _quote_pool_id, + pool_id, + registry_id, + ) = setup_pool_proxy_test_env(); - // Create margin pools first (required for enabling DeepBook pool) - let _base_pool_id = create_margin_pool( - &mut scenario, - &maintainer_cap, - default_protocol_config(), - &clock, - ); - let _quote_pool_id = create_margin_pool( - &mut scenario, - &maintainer_cap, - default_protocol_config(), + scenario.next_tx(test_constants::user1()); + let mut pool = scenario.take_shared_by_id>(pool_id); + let mut registry = scenario.take_shared(); + let deepbook_registry = scenario.take_shared_by_id(registry_id); + margin_manager::new( + &pool, + &deepbook_registry, + &mut registry, &clock, + scenario.ctx(), ); + return_shared(deepbook_registry); - // Create and enable a pool - let (pool_id, _registry_id) = create_pool_for_testing(&mut scenario); + scenario.next_tx(test_constants::user1()); + let mut mm = scenario.take_shared>(); + let usdc_price = build_demo_usdc_price_info_object(&mut scenario, &clock); + let usdt_price = build_demo_usdt_price_info_object(&mut scenario, &clock); - scenario.next_tx(test_constants::admin()); - let mut registry = scenario.take_shared(); - enable_deepbook_margin_on_pool( - pool_id, - &mut registry, - &admin_cap, + // Deposit DEEP tokens + mm.deposit( + ®istry, + &usdc_price, + &usdt_price, + mint_coin( + 20000 * test_constants::deep_multiplier(), + scenario.ctx(), + ), // 20000 DEEP with 6 decimals &clock, - &mut scenario, + scenario.ctx(), ); + destroy_2!(usdc_price, usdt_price); - // Initialize price for the pool (required for set_price_tolerance) - initialize_pool_price(pool_id, &mut registry, &clock, &mut scenario); + // Stake DEEP tokens (10000 DEEP to be safe) + pool_proxy::stake( + ®istry, + &mut mm, + &mut pool, + 10000 * test_constants::deep_multiplier(), // 10000 DEEP stake amount + scenario.ctx(), + ); - return_shared(registry); + // Transition to next epoch for stake to become active + scenario.next_epoch(test_constants::admin()); - scenario.next_tx(test_constants::admin()); - let pool = scenario.take_shared_by_id>(pool_id); - let mut registry = scenario.take_shared(); + // Continue the transaction as user1 + scenario.next_tx(test_constants::user1()); - // Set tolerance to 10% (100_000_000) - should succeed - registry.set_price_tolerance( - &admin_cap, - &pool, - 100_000_000, // 10% - within bounds - &clock, + // Now submit a proposal + pool_proxy::submit_proposal( + ®istry, + &mut mm, + &mut pool, + 600000, // taker_fee + 200000, // maker_fee + 10000 * test_constants::deep_multiplier(), // stake_required + scenario.ctx(), ); - return_shared_2!(registry, pool); - destroy(admin_cap); - destroy(maintainer_cap); - destroy(clock); - scenario.end(); + return_shared_2!(mm, pool); + cleanup_margin_test(registry, admin_cap, _maintainer_cap, clock, scenario); } -#[test, expected_failure(abort_code = margin_registry::EMaxPriceAgeTooLow)] -fun test_set_max_price_age_too_low() { - let (mut scenario, clock, admin_cap, maintainer_cap) = setup_margin_registry(); - - let _base_pool_id = create_margin_pool( - &mut scenario, - &maintainer_cap, - default_protocol_config(), - &clock, - ); - let _quote_pool_id = create_margin_pool( - &mut scenario, - &maintainer_cap, - default_protocol_config(), - &clock, - ); - - let (pool_id, _registry_id) = create_pool_for_testing(&mut scenario); +#[test] +fun test_vote_ok() { + let ( + mut scenario, + clock, + admin_cap, + _maintainer_cap, + _base_pool_id, + _quote_pool_id, + pool_id, + registry_id, + ) = setup_pool_proxy_test_env(); - scenario.next_tx(test_constants::admin()); + scenario.next_tx(test_constants::user1()); + let mut pool = scenario.take_shared_by_id>(pool_id); let mut registry = scenario.take_shared(); - enable_deepbook_margin_on_pool( - pool_id, + let deepbook_registry = scenario.take_shared_by_id(registry_id); + margin_manager::new( + &pool, + &deepbook_registry, &mut registry, - &admin_cap, &clock, - &mut scenario, + scenario.ctx(), ); - initialize_pool_price(pool_id, &mut registry, &clock, &mut scenario); - return_shared(registry); + return_shared(deepbook_registry); - scenario.next_tx(test_constants::admin()); - let pool = scenario.take_shared_by_id>(pool_id); - let mut registry = scenario.take_shared(); + scenario.next_tx(test_constants::user1()); + let mut mm = scenario.take_shared>(); + let usdc_price = build_demo_usdc_price_info_object(&mut scenario, &clock); + let usdt_price = build_demo_usdt_price_info_object(&mut scenario, &clock); - registry.set_max_price_age( - &admin_cap, - &pool, - 10_000u64, // 10 seconds - too low + // Deposit DEEP tokens + mm.deposit( + ®istry, + &usdc_price, + &usdt_price, + mint_coin( + 20000 * test_constants::deep_multiplier(), + scenario.ctx(), + ), // 20000 DEEP with 6 decimals &clock, + scenario.ctx(), ); + destroy_2!(usdc_price, usdt_price); - abort 0 -} + // Stake DEEP tokens (10000 DEEP to be safe) + pool_proxy::stake( + ®istry, + &mut mm, + &mut pool, + 10000 * test_constants::deep_multiplier(), // 10000 DEEP stake amount + scenario.ctx(), + ); -#[test, expected_failure(abort_code = margin_registry::EMaxPriceAgeTooHigh)] -fun test_set_max_price_age_too_high() { - let (mut scenario, clock, admin_cap, maintainer_cap) = setup_margin_registry(); + // Transition to next epoch for stake to become active + scenario.next_epoch(test_constants::admin()); - let _base_pool_id = create_margin_pool( - &mut scenario, - &maintainer_cap, - default_protocol_config(), - &clock, + // Continue the transaction as user1 + scenario.next_tx(test_constants::user1()); + + // Get the balance manager ID to use as proposal ID + let balance_manager = mm.balance_manager(); + let balance_manager_id = object::id(balance_manager); + + // First submit a proposal (this creates a proposal with balance_manager_id as the key) + pool_proxy::submit_proposal( + ®istry, + &mut mm, + &mut pool, + 600000, // taker_fee + 200000, // maker_fee + 10000 * test_constants::deep_multiplier(), // stake_required + scenario.ctx(), ); - let _quote_pool_id = create_margin_pool( - &mut scenario, - &maintainer_cap, - default_protocol_config(), - &clock, + + // Vote on the proposal using balance manager ID as proposal ID + pool_proxy::vote( + ®istry, + &mut mm, + &mut pool, + balance_manager_id, + scenario.ctx(), ); - let (pool_id, _registry_id) = create_pool_for_testing(&mut scenario); + return_shared_2!(mm, pool); + cleanup_margin_test(registry, admin_cap, _maintainer_cap, clock, scenario); +} - scenario.next_tx(test_constants::admin()); - let mut registry = scenario.take_shared(); - enable_deepbook_margin_on_pool( +#[test] +fun test_claim_rebates_ok() { + let ( + mut scenario, + clock, + _admin_cap, + _maintainer_cap, + _base_pool_id, + _quote_pool_id, pool_id, + registry_id, + ) = setup_pool_proxy_test_env(); + + scenario.next_tx(test_constants::user1()); + let mut pool = scenario.take_shared_by_id>(pool_id); + let mut registry = scenario.take_shared(); + let deepbook_registry = scenario.take_shared_by_id(registry_id); + margin_manager::new( + &pool, + &deepbook_registry, &mut registry, - &admin_cap, &clock, - &mut scenario, + scenario.ctx(), ); - initialize_pool_price(pool_id, &mut registry, &clock, &mut scenario); - return_shared(registry); + return_shared(deepbook_registry); - scenario.next_tx(test_constants::admin()); - let pool = scenario.take_shared_by_id>(pool_id); - let mut registry = scenario.take_shared(); + scenario.next_tx(test_constants::user1()); + let mut mm = scenario.take_shared>(); - registry.set_max_price_age( - &admin_cap, - &pool, - 4_000_000u64, // 66+ minutes - too high - &clock, + pool_proxy::claim_rebates( + ®istry, + &mut mm, + &mut pool, + scenario.ctx(), ); - abort 0 + return_shared_2!(mm, pool); + cleanup_margin_test(registry, _admin_cap, _maintainer_cap, clock, scenario); } -// === Tolerance and Max Price Age Update Effect Tests === - +// === Permissionless Settlement Tests === #[test] -fun test_tolerance_decrease_changes_bounds() { - // Verify that decreasing tolerance from 5% to 1% correctly changes price bounds - let (mut scenario, clock, admin_cap, maintainer_cap) = setup_margin_registry(); +fun test_withdraw_settled_amounts_permissionless_ok() { + let ( + mut scenario, + clock, + _admin_cap, + _maintainer_cap, + base_pool_id, + quote_pool_id, + pool_id, + registry_id, + ) = setup_pool_proxy_test_env(); - let _base_pool_id = create_margin_pool( - &mut scenario, - &maintainer_cap, - default_protocol_config(), + // User1 creates margin manager and places an order + scenario.next_tx(test_constants::user1()); + let mut pool = scenario.take_shared_by_id>(pool_id); + let mut registry = scenario.take_shared(); + let deepbook_registry = scenario.take_shared_by_id(registry_id); + margin_manager::new( + &pool, + &deepbook_registry, + &mut registry, &clock, + scenario.ctx(), ); - let _quote_pool_id = create_margin_pool( + return_shared(deepbook_registry); + + scenario.next_tx(test_constants::user1()); + let mut mm = scenario.take_shared>(); + let usdc_price = build_demo_usdc_price_info_object(&mut scenario, &clock); + let usdt_price = build_demo_usdt_price_info_object(&mut scenario, &clock); + + // Deposit USDC + mm.deposit( + ®istry, + &usdc_price, + &usdt_price, + mint_coin(10000 * test_constants::usdc_multiplier(), scenario.ctx()), + &clock, + scenario.ctx(), + ); + destroy_2!(usdc_price, usdt_price); + + // Place a sell order + // Price must be within 5% of oracle price (1_000_000_000 for USDC/USDT at $1 each) + let base_pool = scenario.take_shared_by_id>(base_pool_id); + let quote_pool = scenario.take_shared_by_id>(quote_pool_id); + let order_info = test_helpers::place_limit_order_v2_for_test( &mut scenario, - &maintainer_cap, - default_protocol_config(), + ®istry, + &base_pool, + "e_pool, + &mut mm, + &mut pool, + 1, + // client_order_id + constants::no_restriction(), + constants::self_matching_allowed(), + 1_000_000_000, + // price (1.0 in 9 decimals, within 5% of oracle price) + 100 * test_constants::usdc_multiplier(), + // quantity + false, + // is_bid (sell USDC for USDT) + false, + // pay_with_deep + 2000000, + // expire_timestamp &clock, ); + return_shared(base_pool); + return_shared(quote_pool); - let (pool_id, _registry_id) = create_pool_for_testing(&mut scenario); + destroy(order_info); - scenario.next_tx(test_constants::admin()); - let mut registry = scenario.take_shared(); - enable_deepbook_margin_on_pool( - pool_id, + // User2 places a matching buy order to fill user1's order + scenario.next_tx(test_constants::user2()); + let deepbook_registry = scenario.take_shared_by_id(registry_id); + margin_manager::new( + &pool, + &deepbook_registry, &mut registry, - &admin_cap, &clock, - &mut scenario, + scenario.ctx(), ); - initialize_pool_price(pool_id, &mut registry, &clock, &mut scenario); - return_shared(registry); + return_shared(deepbook_registry); - // Verify default 5% tolerance bounds - scenario.next_tx(test_constants::user1()); - let registry = scenario.take_shared(); - let (lower_bound, upper_bound) = registry.get_price_bounds(pool_id, &clock); - // Oracle price is 1_000_000_000 - // With 5% tolerance: lower = 950_000_000, upper = 1_050_000_000 - assert!(lower_bound == 950_000_000); - assert!(upper_bound == 1_050_000_000); - return_shared(registry); - - // Admin decreases tolerance to 1% - scenario.next_tx(test_constants::admin()); - let pool = scenario.take_shared_by_id>(pool_id); - let mut registry = scenario.take_shared(); + scenario.next_tx(test_constants::user2()); + let mut mm2 = scenario.take_shared>(); + let usdc_price = build_demo_usdc_price_info_object(&mut scenario, &clock); + let usdt_price = build_demo_usdt_price_info_object(&mut scenario, &clock); - registry.set_price_tolerance( - &admin_cap, - &pool, - 10_000_000, // 1% tolerance + // Deposit USDT for user2 + mm2.deposit( + ®istry, + &usdc_price, + &usdt_price, + mint_coin(10000 * test_constants::usdt_multiplier(), scenario.ctx()), &clock, + scenario.ctx(), ); - return_shared_2!(registry, pool); - - // Verify new 1% tolerance bounds - scenario.next_tx(test_constants::user1()); - let registry = scenario.take_shared(); - let (lower_bound, upper_bound) = registry.get_price_bounds(pool_id, &clock); - // With 1% tolerance: lower = 990_000_000, upper = 1_010_000_000 - assert!(lower_bound == 990_000_000); - assert!(upper_bound == 1_010_000_000); - - return_shared(registry); - destroy(admin_cap); - destroy(maintainer_cap); - destroy(clock); - scenario.end(); -} - -#[test] -fun test_tolerance_increase_changes_bounds() { - // Verify that increasing tolerance from 5% to 10% correctly changes price bounds - let (mut scenario, clock, admin_cap, maintainer_cap) = setup_margin_registry(); + destroy_2!(usdc_price, usdt_price); - let _base_pool_id = create_margin_pool( + // Place a buy order that matches user1's sell order + let base_pool = scenario.take_shared_by_id>(base_pool_id); + let quote_pool = scenario.take_shared_by_id>(quote_pool_id); + let order_info2 = test_helpers::place_limit_order_v2_for_test( &mut scenario, - &maintainer_cap, - default_protocol_config(), + ®istry, + &base_pool, + "e_pool, + &mut mm2, + &mut pool, + 2, + // client_order_id + constants::no_restriction(), + constants::self_matching_allowed(), + 1_000_000_000, + // same price (1.0 in 9 decimals) + 100 * test_constants::usdc_multiplier(), + // same quantity + true, + // is_bid (buy USDC with USDT) + false, + // pay_with_deep + 2000000, + // expire_timestamp &clock, ); - let _quote_pool_id = create_margin_pool( - &mut scenario, - &maintainer_cap, - default_protocol_config(), - &clock, + return_shared(base_pool); + return_shared(quote_pool); + destroy(order_info2); + + // Now user1 has settled balances (received USDT from the trade) + // User2 (not the owner) calls withdraw_settled_amounts_permissionless for user1 + scenario.next_tx(test_constants::user2()); + pool_proxy::withdraw_settled_amounts_permissionless( + ®istry, + &mut mm, + &mut pool, ); - let (pool_id, _registry_id) = create_pool_for_testing(&mut scenario); + // Verify that the settlement succeeded (if it failed, we would have aborted) + return_shared_3!(mm, mm2, pool); + cleanup_margin_test(registry, _admin_cap, _maintainer_cap, clock, scenario); +} - scenario.next_tx(test_constants::admin()); - let mut registry = scenario.take_shared(); - enable_deepbook_margin_on_pool( +#[test, expected_failure(abort_code = ::deepbook::vault::ENoBalanceToSettle)] +fun test_withdraw_settled_amounts_permissionless_no_balance_e() { + let ( + mut scenario, + clock, + _admin_cap, + _maintainer_cap, + _base_pool_id, + _quote_pool_id, pool_id, - &mut registry, - &admin_cap, - &clock, - &mut scenario, - ); - initialize_pool_price(pool_id, &mut registry, &clock, &mut scenario); - return_shared(registry); + registry_id, + ) = setup_pool_proxy_test_env(); - // Verify default 5% tolerance bounds + // User1 creates margin manager but doesn't trade scenario.next_tx(test_constants::user1()); - let registry = scenario.take_shared(); - let (lower_bound, upper_bound) = registry.get_price_bounds(pool_id, &clock); - assert!(lower_bound == 950_000_000); // 1_000_000_000 * 0.95 - assert!(upper_bound == 1_050_000_000); // 1_000_000_000 * 1.05 - return_shared(registry); - - // Admin increases tolerance to 10% - scenario.next_tx(test_constants::admin()); - let pool = scenario.take_shared_by_id>(pool_id); + let mut pool = scenario.take_shared_by_id>(pool_id); let mut registry = scenario.take_shared(); - - registry.set_price_tolerance( - &admin_cap, + let deepbook_registry = scenario.take_shared_by_id(registry_id); + margin_manager::new( &pool, - 100_000_000, // 10% tolerance + &deepbook_registry, + &mut registry, &clock, + scenario.ctx(), ); - return_shared_2!(registry, pool); + return_shared(deepbook_registry); - // Verify new 10% tolerance bounds scenario.next_tx(test_constants::user1()); - let registry = scenario.take_shared(); - let (lower_bound, upper_bound) = registry.get_price_bounds(pool_id, &clock); - // With 10% tolerance: - // lower_bound = 1_000_000_000 * 0.90 = 900_000_000 - // upper_bound = 1_000_000_000 * 1.10 = 1_100_000_000 - assert!(lower_bound == 900_000_000); - assert!(upper_bound == 1_100_000_000); - - // A price of 920_000_000 (8% below oracle) is now within bounds - // Previously with 5% tolerance, lower_bound was 950_000_000, so 920_000_000 would fail - // Now with 10% tolerance, lower_bound is 900_000_000, so 920_000_000 passes - - return_shared(registry); - destroy(admin_cap); - destroy(maintainer_cap); - destroy(clock); - scenario.end(); -} - -#[test, expected_failure(abort_code = margin_registry::EPriceUpdateRequired)] -fun test_max_price_age_decrease_makes_price_stale() { - // Price is fresh with 5 minute max age, becomes stale after decreasing to 30 seconds - let (mut scenario, mut clock, admin_cap, maintainer_cap) = setup_margin_registry(); - - clock.set_for_testing(1_000_000); // Start at 1 second + let mut mm = scenario.take_shared>(); - let _base_pool_id = create_margin_pool( - &mut scenario, - &maintainer_cap, - default_protocol_config(), - &clock, - ); - let _quote_pool_id = create_margin_pool( - &mut scenario, - &maintainer_cap, - default_protocol_config(), - &clock, + // Try to settle when there's nothing to settle - should fail + scenario.next_tx(test_constants::user2()); + pool_proxy::withdraw_settled_amounts_permissionless( + ®istry, + &mut mm, + &mut pool, ); - let (pool_id, registry_id) = create_pool_for_testing(&mut scenario); + abort 0 +} - scenario.next_tx(test_constants::admin()); - let mut registry = scenario.take_shared(); - enable_deepbook_margin_on_pool( +#[test, expected_failure(abort_code = margin_manager::EIncorrectDeepBookPool)] +fun test_withdraw_settled_amounts_permissionless_incorrect_pool_e() { + let ( + mut scenario, + clock, + _admin_cap, + _maintainer_cap, + _base_pool_id, + _quote_pool_id, pool_id, - &mut registry, - &admin_cap, - &clock, - &mut scenario, - ); - initialize_pool_price(pool_id, &mut registry, &clock, &mut scenario); - return_shared(registry); + registry_id, + ) = setup_pool_proxy_test_env(); - // Advance clock by 1 minute (60,000 ms) - still fresh with default 5 min max age - clock.increment_for_testing(60_000); + // Create a wrong pool + let (wrong_pool_id, _wrong_registry_id) = create_pool_for_testing(&mut scenario); - // Verify price is still fresh scenario.next_tx(test_constants::user1()); - let registry = scenario.take_shared(); - let (_lower_bound, _upper_bound) = registry.get_price_bounds(pool_id, &clock); - return_shared(registry); - - // Admin decreases max_price_age to 30 seconds - scenario.next_tx(test_constants::admin()); let pool = scenario.take_shared_by_id>(pool_id); + let mut wrong_pool = scenario.take_shared_by_id>(wrong_pool_id); let mut registry = scenario.take_shared(); - - registry.set_max_price_age( - &admin_cap, + let deepbook_registry = scenario.take_shared_by_id(registry_id); + margin_manager::new( &pool, - 30_000, // 30 seconds + &deepbook_registry, + &mut registry, &clock, + scenario.ctx(), ); - return_shared_2!(registry, pool); + return_shared(deepbook_registry); - // Now price should be stale (1 minute old > 30 second max age) scenario.next_tx(test_constants::user1()); - let registry = scenario.take_shared(); - let (_lower_bound, _upper_bound) = registry.get_price_bounds(pool_id, &clock); // Should fail + let mut mm = scenario.take_shared>(); - return_shared(registry); - destroy(admin_cap); - destroy(maintainer_cap); - destroy(registry_id); - destroy(clock); - scenario.end(); + // Try to settle with wrong pool - should fail + scenario.next_tx(test_constants::user2()); + pool_proxy::withdraw_settled_amounts_permissionless( + ®istry, + &mut mm, + &mut wrong_pool, // Wrong pool! + ); + + abort 0 } -#[test] -fun test_max_price_age_increase_makes_price_fresh() { - // Price is stale with 5 minute max age, becomes fresh after increasing to 10 minutes - let (mut scenario, mut clock, admin_cap, maintainer_cap) = setup_margin_registry(); - - clock.set_for_testing(1_000_000); // Start at 1 second - - let _base_pool_id = create_margin_pool( - &mut scenario, - &maintainer_cap, - default_protocol_config(), - &clock, - ); - let _quote_pool_id = create_margin_pool( - &mut scenario, - &maintainer_cap, - default_protocol_config(), - &clock, - ); - - let (pool_id, _registry_id) = create_pool_for_testing(&mut scenario); - - scenario.next_tx(test_constants::admin()); - let mut registry = scenario.take_shared(); - enable_deepbook_margin_on_pool( - pool_id, - &mut registry, - &admin_cap, - &clock, - &mut scenario, - ); - initialize_pool_price(pool_id, &mut registry, &clock, &mut scenario); - return_shared(registry); - - // Advance clock by 6 minutes (360,000 ms) - stale with default 5 min max age - clock.increment_for_testing(360_000); - - // Admin increases max_price_age to 10 minutes before checking - scenario.next_tx(test_constants::admin()); - let pool = scenario.take_shared_by_id>(pool_id); - let mut registry = scenario.take_shared(); - - registry.set_max_price_age( - &admin_cap, - &pool, - 600_000, // 10 minutes - &clock, - ); - return_shared_2!(registry, pool); - - // Now price should be fresh (6 minutes old < 10 minute max age) - scenario.next_tx(test_constants::user1()); - let registry = scenario.take_shared(); - let (lower_bound, upper_bound) = registry.get_price_bounds(pool_id, &clock); - - // Verify we got valid bounds (price is fresh) - assert!(lower_bound > 0); - assert!(upper_bound > lower_bound); - - return_shared(registry); - destroy(admin_cap); - destroy(maintainer_cap); - destroy(clock); - scenario.end(); -} +// === Price Protection Tests === #[test, expected_failure(abort_code = margin_registry::EPriceDeviationTooHigh)] -fun test_tolerance_decrease_rejects_order_e2e() { - // End-to-end test: place order fails after tolerance is decreased - let (mut scenario, clock, admin_cap, maintainer_cap) = setup_margin_registry(); - - let base_pool_id = create_margin_pool( - &mut scenario, - &maintainer_cap, - default_protocol_config(), - &clock, - ); - let quote_pool_id = create_margin_pool( - &mut scenario, - &maintainer_cap, - default_protocol_config(), - &clock, - ); - - let (pool_id, registry_id) = create_pool_for_testing(&mut scenario); - - scenario.next_tx(test_constants::admin()); - let mut registry = scenario.take_shared(); - enable_deepbook_margin_on_pool( +fun test_limit_order_price_too_high() { + // Test that a limit order with price > 5% above oracle price is rejected + let ( + mut scenario, + clock, + _admin_cap, + _maintainer_cap, + base_pool_id, + quote_pool_id, pool_id, - &mut registry, - &admin_cap, - &clock, - &mut scenario, - ); - initialize_pool_price(pool_id, &mut registry, &clock, &mut scenario); - return_shared(registry); - - // Admin decreases tolerance to 1% - scenario.next_tx(test_constants::admin()); - let pool = scenario.take_shared_by_id>(pool_id); - let mut registry = scenario.take_shared(); - - registry.set_price_tolerance( - &admin_cap, - &pool, - 10_000_000, // 1% tolerance - &clock, - ); - return_shared_2!(registry, pool); + registry_id, + ) = setup_pool_proxy_test_env(); - // Create margin manager and deposit collateral scenario.next_tx(test_constants::user1()); - let pool = scenario.take_shared_by_id>(pool_id); + let mut pool = scenario.take_shared_by_id>(pool_id); let mut registry = scenario.take_shared(); let deepbook_registry = scenario.take_shared_by_id(registry_id); margin_manager::new( @@ -3503,15 +4118,13 @@ fun test_tolerance_decrease_rejects_order_e2e() { scenario.ctx(), ); return_shared(deepbook_registry); - return_shared_2!(registry, pool); scenario.next_tx(test_constants::user1()); let mut mm = scenario.take_shared>(); - let mut pool = scenario.take_shared_by_id>(pool_id); - let registry = scenario.take_shared(); let usdc_price = build_demo_usdc_price_info_object(&mut scenario, &clock); let usdt_price = build_demo_usdt_price_info_object(&mut scenario, &clock); + // Deposit collateral mm.deposit( ®istry, &usdc_price, @@ -3520,9 +4133,12 @@ fun test_tolerance_decrease_rejects_order_e2e() { &clock, scenario.ctx(), ); + destroy_2!(usdc_price, usdt_price); - // Try to place bid at 4% above oracle - should FAIL with 1% tolerance + // Try to place order at price 10% above oracle (1_100_000_000) + // Oracle price is 1_000_000_000 (1.0), tolerance is 5% + // 10% above = 1_100_000_000, which exceeds upper bound of 1_050_000_000 let base_pool = scenario.take_shared_by_id>(base_pool_id); let quote_pool = scenario.take_shared_by_id>(quote_pool_id); test_helpers::place_limit_order_v2_for_test( @@ -3535,13 +4151,12 @@ fun test_tolerance_decrease_rejects_order_e2e() { 1, constants::no_restriction(), constants::self_matching_allowed(), - 1_040_000_000, - // 4% above oracle + 1_100_000_000, + // 10% above oracle - should fail 100 * test_constants::usdc_multiplier(), true, - // is_bid false, - 2000000, + 18446744073709551615, &clock, ); return_shared(base_pool); @@ -3550,59 +4165,23 @@ fun test_tolerance_decrease_rejects_order_e2e() { abort 0 } -#[test, expected_failure(abort_code = margin_registry::EPriceUpdateRequired)] -fun test_max_price_age_decrease_rejects_order_e2e() { - // End-to-end test: order fails after max_price_age is decreased and price becomes stale - let (mut scenario, mut clock, admin_cap, maintainer_cap) = setup_margin_registry(); - - clock.set_for_testing(1_000_000); - - let base_pool_id = create_margin_pool( - &mut scenario, - &maintainer_cap, - default_protocol_config(), - &clock, - ); - let quote_pool_id = create_margin_pool( - &mut scenario, - &maintainer_cap, - default_protocol_config(), - &clock, - ); - - let (pool_id, registry_id) = create_pool_for_testing(&mut scenario); - - scenario.next_tx(test_constants::admin()); - let mut registry = scenario.take_shared(); - enable_deepbook_margin_on_pool( +#[test, expected_failure(abort_code = margin_registry::EPriceDeviationTooHigh)] +fun test_limit_order_price_too_low() { + // Test that an ask (sell) order with price < 5% below oracle price is rejected + // Asks only check lower bound - selling below oracle is bad + let ( + mut scenario, + clock, + _admin_cap, + _maintainer_cap, + base_pool_id, + quote_pool_id, pool_id, - &mut registry, - &admin_cap, - &clock, - &mut scenario, - ); - initialize_pool_price(pool_id, &mut registry, &clock, &mut scenario); - return_shared(registry); - - // Advance clock by 1 minute - clock.increment_for_testing(60_000); - - // Admin decreases max_price_age to 30 seconds - scenario.next_tx(test_constants::admin()); - let pool = scenario.take_shared_by_id>(pool_id); - let mut registry = scenario.take_shared(); - - registry.set_max_price_age( - &admin_cap, - &pool, - 30_000, // 30 seconds - &clock, - ); - return_shared_2!(registry, pool); + registry_id, + ) = setup_pool_proxy_test_env(); - // Create margin manager and deposit collateral scenario.next_tx(test_constants::user1()); - let pool = scenario.take_shared_by_id>(pool_id); + let mut pool = scenario.take_shared_by_id>(pool_id); let mut registry = scenario.take_shared(); let deepbook_registry = scenario.take_shared_by_id(registry_id); margin_manager::new( @@ -3613,15 +4192,13 @@ fun test_max_price_age_decrease_rejects_order_e2e() { scenario.ctx(), ); return_shared(deepbook_registry); - return_shared_2!(registry, pool); scenario.next_tx(test_constants::user1()); let mut mm = scenario.take_shared>(); - let mut pool = scenario.take_shared_by_id>(pool_id); - let registry = scenario.take_shared(); let usdc_price = build_demo_usdc_price_info_object(&mut scenario, &clock); let usdt_price = build_demo_usdt_price_info_object(&mut scenario, &clock); + // Deposit collateral mm.deposit( ®istry, &usdc_price, @@ -3630,9 +4207,13 @@ fun test_max_price_age_decrease_rejects_order_e2e() { &clock, scenario.ctx(), ); + destroy_2!(usdc_price, usdt_price); - // Try to place order - should FAIL because price is stale + // Try to place ask order at price 10% below oracle (900_000_000) + // Oracle price is 1_000_000_000 (1.0), tolerance is 5% + // 10% below = 900_000_000, which is below lower bound of 950_000_000 + // For asks, lower bound is checked - should fail let base_pool = scenario.take_shared_by_id>(base_pool_id); let quote_pool = scenario.take_shared_by_id>(quote_pool_id); test_helpers::place_limit_order_v2_for_test( @@ -3645,11 +4226,13 @@ fun test_max_price_age_decrease_rejects_order_e2e() { 1, constants::no_restriction(), constants::self_matching_allowed(), - 1_000_000_000, + 900_000_000, + // 10% below oracle - should fail for asks 100 * test_constants::usdc_multiplier(), - true, false, - 2000000, + // is_bid = false (ask/sell order) + false, + 18446744073709551615, &clock, ); return_shared(base_pool); @@ -3658,18 +4241,9 @@ fun test_max_price_age_decrease_rejects_order_e2e() { abort 0 } -// === Self-Matching Prevention Tests === -// Note: Margin trading forces CANCEL_TAKER self-matching option. -// This prevents users from matching against their own orders by canceling the taker side. -// Cross-wallet self-matching between different margin managers cannot be fully prevented -// at the protocol level - see security review for details. - -// === Additional Price Protection Boundary Tests === - -#[test, expected_failure(abort_code = margin_registry::EPriceDeviationTooHigh)] -fun test_limit_order_price_just_above_upper_bound_fails() { - // Test that a bid (buy) order at price 1 unit above upper bound fails - // Bids only check upper bound - buying above oracle is bad +#[test] +fun test_limit_order_price_at_upper_bound_ok() { + // Test that a limit order at exactly 5% above oracle is allowed let ( mut scenario, clock, @@ -3699,6 +4273,7 @@ fun test_limit_order_price_just_above_upper_bound_fails() { let usdc_price = build_demo_usdc_price_info_object(&mut scenario, &clock); let usdt_price = build_demo_usdt_price_info_object(&mut scenario, &clock); + // Deposit collateral mm.deposit( ®istry, &usdc_price, @@ -3710,11 +4285,12 @@ fun test_limit_order_price_just_above_upper_bound_fails() { destroy_2!(usdc_price, usdt_price); - // Oracle price is 1_000_000_000, upper bound = 1_050_000_000 - // Price 1_050_000_001 is just above upper bound - should fail for bids + // Place order at price exactly at 5% above oracle (1_050_000_000) + // Oracle price is 1_000_000_000 (1.0), upper bound = 1_050_000_000 + // Using is_bid=false (sell USDC) since we deposited USDC let base_pool = scenario.take_shared_by_id>(base_pool_id); let quote_pool = scenario.take_shared_by_id>(quote_pool_id); - test_helpers::place_limit_order_v2_for_test( + let order_info = test_helpers::place_limit_order_v2_for_test( &mut scenario, ®istry, &base_pool, @@ -3724,11 +4300,11 @@ fun test_limit_order_price_just_above_upper_bound_fails() { 1, constants::no_restriction(), constants::self_matching_allowed(), - 1_050_000_001, - // Just above upper bound + 1_050_000_000, + // Exactly at upper bound - should succeed 100 * test_constants::usdc_multiplier(), - true, - // is_bid = true (buy order) + false, + // sell USDC for USDT false, 18446744073709551615, &clock, @@ -3736,13 +4312,16 @@ fun test_limit_order_price_just_above_upper_bound_fails() { return_shared(base_pool); return_shared(quote_pool); - abort 0 + assert!(order_info.client_order_id() == 1); + destroy(order_info); + return_shared_2!(mm, pool); + cleanup_margin_test(registry, _admin_cap, _maintainer_cap, clock, scenario); } -#[test, expected_failure(abort_code = margin_registry::EPriceDeviationTooHigh)] -fun test_limit_order_price_just_below_lower_bound_fails() { - // Test that a limit order at price 1 unit below lower bound fails - let ( +#[test] +fun test_limit_order_price_at_lower_bound_ok() { + // Test that a limit order at exactly 5% below oracle is allowed + let ( mut scenario, clock, _admin_cap, @@ -3771,6 +4350,7 @@ fun test_limit_order_price_just_below_lower_bound_fails() { let usdc_price = build_demo_usdc_price_info_object(&mut scenario, &clock); let usdt_price = build_demo_usdt_price_info_object(&mut scenario, &clock); + // Deposit collateral mm.deposit( ®istry, &usdc_price, @@ -3782,11 +4362,12 @@ fun test_limit_order_price_just_below_lower_bound_fails() { destroy_2!(usdc_price, usdt_price); - // Oracle price is 1_000_000_000, lower bound = 950_000_000 - // Price 949_999_999 is just below lower bound - should fail + // Place order at price exactly at 5% below oracle (950_000_000) + // Oracle price is 1_000_000_000 (1.0), lower bound = 950_000_000 + // Using is_bid=false (sell USDC) since we deposited USDC let base_pool = scenario.take_shared_by_id>(base_pool_id); let quote_pool = scenario.take_shared_by_id>(quote_pool_id); - test_helpers::place_limit_order_v2_for_test( + let order_info = test_helpers::place_limit_order_v2_for_test( &mut scenario, ®istry, &base_pool, @@ -3796,10 +4377,11 @@ fun test_limit_order_price_just_below_lower_bound_fails() { 1, constants::no_restriction(), constants::self_matching_allowed(), - 949_999_999, - // Just below lower bound + 950_000_000, + // Exactly at lower bound - should succeed 100 * test_constants::usdc_multiplier(), false, + // sell USDC for USDT false, 18446744073709551615, &clock, @@ -3807,563 +4389,601 @@ fun test_limit_order_price_just_below_lower_bound_fails() { return_shared(base_pool); return_shared(quote_pool); - abort 0 + assert!(order_info.client_order_id() == 1); + destroy(order_info); + return_shared_2!(mm, pool); + cleanup_margin_test(registry, _admin_cap, _maintainer_cap, clock, scenario); } -#[test] -fun test_bid_order_allowed_at_any_price_below_oracle() { - // Test that bid (buy) orders can be placed at any price below oracle - // Bids only check upper bound - buying below oracle is always fine - let ( - mut scenario, - clock, - _admin_cap, - _maintainer_cap, - base_pool_id, - quote_pool_id, - pool_id, - registry_id, - ) = setup_pool_proxy_test_env(); +#[test, expected_failure(abort_code = margin_registry::EToleranceTooLow)] +fun test_set_tolerance_too_low() { + // Test that tolerance below 1% is rejected + let (mut scenario, clock, admin_cap, maintainer_cap) = setup_margin_registry(); - scenario.next_tx(test_constants::user1()); - let mut pool = scenario.take_shared_by_id>(pool_id); + // Create margin pools first (required for enabling DeepBook pool) + let _base_pool_id = create_margin_pool( + &mut scenario, + &maintainer_cap, + default_protocol_config(), + &clock, + ); + let _quote_pool_id = create_margin_pool( + &mut scenario, + &maintainer_cap, + default_protocol_config(), + &clock, + ); + + // Create and enable a pool + let (pool_id, _registry_id) = create_pool_for_testing(&mut scenario); + + scenario.next_tx(test_constants::admin()); let mut registry = scenario.take_shared(); - let deepbook_registry = scenario.take_shared_by_id(registry_id); - margin_manager::new( - &pool, - &deepbook_registry, + enable_deepbook_margin_on_pool( + pool_id, &mut registry, + &admin_cap, &clock, - scenario.ctx(), + &mut scenario, ); - return_shared(deepbook_registry); + return_shared(registry); - scenario.next_tx(test_constants::user1()); - let mut mm = scenario.take_shared>(); - let usdc_price = build_demo_usdc_price_info_object(&mut scenario, &clock); - let usdt_price = build_demo_usdt_price_info_object(&mut scenario, &clock); + scenario.next_tx(test_constants::admin()); + let pool = scenario.take_shared_by_id>(pool_id); + let mut registry = scenario.take_shared(); - // Deposit USDT collateral for buying USDC - mm.deposit( - ®istry, - &usdc_price, - &usdt_price, - mint_coin(10000 * test_constants::usdt_multiplier(), scenario.ctx()), + // Try to set tolerance below 1% (10_000_000) + registry.set_price_tolerance( + &admin_cap, + &pool, + 5_000_000, // 0.5% - too low &clock, - scenario.ctx(), ); - destroy_2!(usdc_price, usdt_price); + abort 0 +} - // Place bid at 50% below oracle (500_000_000) - // Oracle price is 1_000_000_000 (1.0), this is way below lower bound of 950_000_000 - // But bids don't check lower bound - should succeed - let base_pool = scenario.take_shared_by_id>(base_pool_id); - let quote_pool = scenario.take_shared_by_id>(quote_pool_id); - let order_info = test_helpers::place_limit_order_v2_for_test( +#[test, expected_failure(abort_code = margin_registry::EToleranceTooHigh)] +fun test_set_tolerance_too_high() { + // Test that tolerance above 50% is rejected + let (mut scenario, clock, admin_cap, maintainer_cap) = setup_margin_registry(); + + // Create margin pools first (required for enabling DeepBook pool) + let _base_pool_id = create_margin_pool( &mut scenario, - ®istry, - &base_pool, - "e_pool, - &mut mm, - &mut pool, - 1, - constants::no_restriction(), - constants::self_matching_allowed(), - 500_000_000, - // 50% below oracle - should succeed for bids - 100 * test_constants::usdc_multiplier(), - true, - // is_bid = true (buy order) - false, - 18446744073709551615, + &maintainer_cap, + default_protocol_config(), + &clock, + ); + let _quote_pool_id = create_margin_pool( + &mut scenario, + &maintainer_cap, + default_protocol_config(), &clock, ); - return_shared(base_pool); - return_shared(quote_pool); - - assert!(order_info.client_order_id() == 1); - destroy(order_info); - return_shared_2!(mm, pool); - cleanup_margin_test(registry, _admin_cap, _maintainer_cap, clock, scenario); -} -#[test] -fun test_ask_order_allowed_at_any_price_above_oracle() { - // Test that ask (sell) orders can be placed at any price above oracle - // Asks only check lower bound - selling above oracle is always fine - let ( - mut scenario, - clock, - _admin_cap, - _maintainer_cap, - base_pool_id, - quote_pool_id, - pool_id, - registry_id, - ) = setup_pool_proxy_test_env(); + // Create and enable a pool + let (pool_id, _registry_id) = create_pool_for_testing(&mut scenario); - scenario.next_tx(test_constants::user1()); - let mut pool = scenario.take_shared_by_id>(pool_id); + scenario.next_tx(test_constants::admin()); let mut registry = scenario.take_shared(); - let deepbook_registry = scenario.take_shared_by_id(registry_id); - margin_manager::new( - &pool, - &deepbook_registry, + enable_deepbook_margin_on_pool( + pool_id, &mut registry, + &admin_cap, &clock, - scenario.ctx(), + &mut scenario, ); - return_shared(deepbook_registry); + return_shared(registry); - scenario.next_tx(test_constants::user1()); - let mut mm = scenario.take_shared>(); - let usdc_price = build_demo_usdc_price_info_object(&mut scenario, &clock); - let usdt_price = build_demo_usdt_price_info_object(&mut scenario, &clock); + scenario.next_tx(test_constants::admin()); + let pool = scenario.take_shared_by_id>(pool_id); + let mut registry = scenario.take_shared(); - // Deposit USDC collateral for selling - mm.deposit( - ®istry, - &usdc_price, - &usdt_price, - mint_coin(10000 * test_constants::usdc_multiplier(), scenario.ctx()), + // Try to set tolerance above 50% (500_000_000) + registry.set_price_tolerance( + &admin_cap, + &pool, + 600_000_000, // 60% - too high &clock, - scenario.ctx(), ); - destroy_2!(usdc_price, usdt_price); + abort 0 +} - // Place ask at 50% above oracle (1_500_000_000) - // Oracle price is 1_000_000_000 (1.0), this is way above upper bound of 1_050_000_000 - // But asks don't check upper bound - should succeed - let base_pool = scenario.take_shared_by_id>(base_pool_id); - let quote_pool = scenario.take_shared_by_id>(quote_pool_id); - let order_info = test_helpers::place_limit_order_v2_for_test( +#[test] +fun test_set_tolerance_within_bounds_ok() { + // Test that setting tolerance within 1%-50% is allowed + let (mut scenario, clock, admin_cap, maintainer_cap) = setup_margin_registry(); + + // Create margin pools first (required for enabling DeepBook pool) + let _base_pool_id = create_margin_pool( &mut scenario, - ®istry, - &base_pool, - "e_pool, - &mut mm, - &mut pool, - 1, - constants::no_restriction(), - constants::self_matching_allowed(), - 1_500_000_000, - // 50% above oracle - should succeed for asks - 100 * test_constants::usdc_multiplier(), - false, - // is_bid = false (sell order) - false, - 18446744073709551615, + &maintainer_cap, + default_protocol_config(), + &clock, + ); + let _quote_pool_id = create_margin_pool( + &mut scenario, + &maintainer_cap, + default_protocol_config(), &clock, ); - return_shared(base_pool); - return_shared(quote_pool); - assert!(order_info.client_order_id() == 1); - destroy(order_info); - return_shared_2!(mm, pool); - cleanup_margin_test(registry, _admin_cap, _maintainer_cap, clock, scenario); -} + // Create and enable a pool + let (pool_id, _registry_id) = create_pool_for_testing(&mut scenario); -#[test] -fun test_market_buy_order_above_oracle_within_bounds() { - // Test that market buy orders work when execution price is above oracle - // but within the 5% upper bound tolerance - // Orderbook has asks at $1.01, oracle is $1.00, upper bound is $1.05 - let ( - mut scenario, - clock, - _admin_cap, - _maintainer_cap, - base_pool_id, - quote_pool_id, + scenario.next_tx(test_constants::admin()); + let mut registry = scenario.take_shared(); + enable_deepbook_margin_on_pool( pool_id, - registry_id, - ) = setup_pool_proxy_test_env(); + &mut registry, + &admin_cap, + &clock, + &mut scenario, + ); - // Set up orderbook with asks at $1.01 (above oracle but within bounds) - setup_orderbook_liquidity_stablecoin(&mut scenario, pool_id, &clock); + // Initialize price for the pool (required for set_price_tolerance) + initialize_pool_price(pool_id, &mut registry, &clock, &mut scenario); - scenario.next_tx(test_constants::user1()); - let mut pool = scenario.take_shared_by_id>(pool_id); + return_shared(registry); + + scenario.next_tx(test_constants::admin()); + let pool = scenario.take_shared_by_id>(pool_id); let mut registry = scenario.take_shared(); - let deepbook_registry = scenario.take_shared_by_id(registry_id); - margin_manager::new( + + // Set tolerance to 10% (100_000_000) - should succeed + registry.set_price_tolerance( + &admin_cap, &pool, - &deepbook_registry, - &mut registry, + 100_000_000, // 10% - within bounds &clock, - scenario.ctx(), ); - return_shared(deepbook_registry); - scenario.next_tx(test_constants::user1()); - let mut mm = scenario.take_shared>(); - let usdc_price = build_demo_usdc_price_info_object(&mut scenario, &clock); - let usdt_price = build_demo_usdt_price_info_object(&mut scenario, &clock); + return_shared_2!(registry, pool); + destroy(admin_cap); + destroy(maintainer_cap); + destroy(clock); + scenario.end(); +} - // Deposit USDT to buy USDC - mm.deposit( - ®istry, - &usdc_price, - &usdt_price, - mint_coin(10000 * test_constants::usdt_multiplier(), scenario.ctx()), +#[test, expected_failure(abort_code = margin_registry::EMaxPriceAgeTooLow)] +fun test_set_max_price_age_too_low() { + let (mut scenario, clock, admin_cap, maintainer_cap) = setup_margin_registry(); + + let _base_pool_id = create_margin_pool( + &mut scenario, + &maintainer_cap, + default_protocol_config(), &clock, - scenario.ctx(), ); - destroy_2!(usdc_price, usdt_price); - - // Market buy USDC - will execute at $1.01 (above oracle of $1.00) - // Bids only check upper bound ($1.05), so $1.01 should pass - let base_pool = scenario.take_shared_by_id>(base_pool_id); - let quote_pool = scenario.take_shared_by_id>(quote_pool_id); - let order_info = test_helpers::place_market_order_v2_for_test( + let _quote_pool_id = create_margin_pool( &mut scenario, - ®istry, - &base_pool, - "e_pool, - &mut mm, - &mut pool, - 1, - constants::self_matching_allowed(), - 100 * test_constants::usdc_multiplier(), - true, - // is_bid = true (buy) - false, + &maintainer_cap, + default_protocol_config(), &clock, ); - return_shared(base_pool); - return_shared(quote_pool); - assert!(order_info.client_order_id() == 1); - destroy(order_info); - return_shared_2!(mm, pool); - cleanup_margin_test(registry, _admin_cap, _maintainer_cap, clock, scenario); -} + let (pool_id, _registry_id) = create_pool_for_testing(&mut scenario); -#[test] -fun test_market_sell_order_below_oracle_within_bounds() { - // Test that market sell orders work when execution price is below oracle - // but within the 5% lower bound tolerance - // Orderbook has bids at $0.99, oracle is $1.00, lower bound is $0.95 - let ( - mut scenario, - clock, - _admin_cap, - _maintainer_cap, - base_pool_id, - quote_pool_id, + scenario.next_tx(test_constants::admin()); + let mut registry = scenario.take_shared(); + enable_deepbook_margin_on_pool( pool_id, - registry_id, - ) = setup_pool_proxy_test_env(); - - // Set up orderbook with bids at $0.99 (below oracle but within bounds) - setup_orderbook_liquidity_stablecoin(&mut scenario, pool_id, &clock); + &mut registry, + &admin_cap, + &clock, + &mut scenario, + ); + initialize_pool_price(pool_id, &mut registry, &clock, &mut scenario); + return_shared(registry); - scenario.next_tx(test_constants::user1()); - let mut pool = scenario.take_shared_by_id>(pool_id); + scenario.next_tx(test_constants::admin()); + let pool = scenario.take_shared_by_id>(pool_id); let mut registry = scenario.take_shared(); - let deepbook_registry = scenario.take_shared_by_id(registry_id); - margin_manager::new( + + registry.set_max_price_age( + &admin_cap, &pool, - &deepbook_registry, - &mut registry, + 10_000u64, // 10 seconds - too low &clock, - scenario.ctx(), ); - return_shared(deepbook_registry); - scenario.next_tx(test_constants::user1()); - let mut mm = scenario.take_shared>(); - let usdc_price = build_demo_usdc_price_info_object(&mut scenario, &clock); - let usdt_price = build_demo_usdt_price_info_object(&mut scenario, &clock); + abort 0 +} - // Deposit USDC to sell - mm.deposit( - ®istry, - &usdc_price, - &usdt_price, - mint_coin(10000 * test_constants::usdc_multiplier(), scenario.ctx()), +#[test, expected_failure(abort_code = margin_registry::EMaxPriceAgeTooHigh)] +fun test_set_max_price_age_too_high() { + let (mut scenario, clock, admin_cap, maintainer_cap) = setup_margin_registry(); + + let _base_pool_id = create_margin_pool( + &mut scenario, + &maintainer_cap, + default_protocol_config(), &clock, - scenario.ctx(), ); - destroy_2!(usdc_price, usdt_price); - - // Market sell USDC - will execute at $0.99 (below oracle of $1.00) - // Asks only check lower bound ($0.95), so $0.99 should pass - let base_pool = scenario.take_shared_by_id>(base_pool_id); - let quote_pool = scenario.take_shared_by_id>(quote_pool_id); - let order_info = test_helpers::place_market_order_v2_for_test( + let _quote_pool_id = create_margin_pool( &mut scenario, - ®istry, - &base_pool, - "e_pool, - &mut mm, - &mut pool, - 1, - constants::self_matching_allowed(), - 100 * test_constants::usdc_multiplier(), - false, - // is_bid = false (sell) - false, + &maintainer_cap, + default_protocol_config(), &clock, ); - return_shared(base_pool); - return_shared(quote_pool); - assert!(order_info.client_order_id() == 1); - destroy(order_info); - return_shared_2!(mm, pool); - cleanup_margin_test(registry, _admin_cap, _maintainer_cap, clock, scenario); -} + let (pool_id, _registry_id) = create_pool_for_testing(&mut scenario); -#[test, expected_failure(abort_code = margin_registry::EPriceDeviationTooHigh)] -fun test_market_buy_order_exceeds_upper_bound() { - // Test that market buy orders fail when execution price exceeds upper bound - // Orderbook has asks at $1.10 (10% above oracle), upper bound is $1.05 (5%) - let ( - mut scenario, - clock, - _admin_cap, - _maintainer_cap, - base_pool_id, - quote_pool_id, + scenario.next_tx(test_constants::admin()); + let mut registry = scenario.take_shared(); + enable_deepbook_margin_on_pool( pool_id, - registry_id, - ) = setup_pool_proxy_test_env(); - - // Set up orderbook with asks at $1.10 (exceeds 5% upper bound) - setup_orderbook_liquidity_out_of_bounds_stablecoin(&mut scenario, pool_id, &clock); + &mut registry, + &admin_cap, + &clock, + &mut scenario, + ); + initialize_pool_price(pool_id, &mut registry, &clock, &mut scenario); + return_shared(registry); - scenario.next_tx(test_constants::user1()); - let mut pool = scenario.take_shared_by_id>(pool_id); + scenario.next_tx(test_constants::admin()); + let pool = scenario.take_shared_by_id>(pool_id); let mut registry = scenario.take_shared(); - let deepbook_registry = scenario.take_shared_by_id(registry_id); - margin_manager::new( + + registry.set_max_price_age( + &admin_cap, &pool, - &deepbook_registry, - &mut registry, + 4_000_000u64, // 66+ minutes - too high &clock, - scenario.ctx(), ); - return_shared(deepbook_registry); - scenario.next_tx(test_constants::user1()); - let mut mm = scenario.take_shared>(); - let usdc_price = build_demo_usdc_price_info_object(&mut scenario, &clock); - let usdt_price = build_demo_usdt_price_info_object(&mut scenario, &clock); + abort 0 +} - // Deposit USDT to buy USDC - mm.deposit( - ®istry, - &usdc_price, - &usdt_price, - mint_coin(10000 * test_constants::usdt_multiplier(), scenario.ctx()), +// === Tolerance and Max Price Age Update Effect Tests === + +#[test] +fun test_tolerance_decrease_changes_bounds() { + // Verify that decreasing tolerance from 5% to 1% correctly changes price bounds + let (mut scenario, clock, admin_cap, maintainer_cap) = setup_margin_registry(); + + let _base_pool_id = create_margin_pool( + &mut scenario, + &maintainer_cap, + default_protocol_config(), &clock, - scenario.ctx(), ); - destroy_2!(usdc_price, usdt_price); - - // Market buy USDC - will try to execute at $1.10 (exceeds upper bound of $1.05) - // Should fail with EPriceDeviationTooHigh - let base_pool = scenario.take_shared_by_id>(base_pool_id); - let quote_pool = scenario.take_shared_by_id>(quote_pool_id); - test_helpers::place_market_order_v2_for_test( + let _quote_pool_id = create_margin_pool( &mut scenario, - ®istry, - &base_pool, - "e_pool, - &mut mm, - &mut pool, - 1, - constants::self_matching_allowed(), - 100 * test_constants::usdc_multiplier(), - true, - // is_bid = true (buy) - false, + &maintainer_cap, + default_protocol_config(), &clock, ); - return_shared(base_pool); - return_shared(quote_pool); - abort -} + let (pool_id, _registry_id) = create_pool_for_testing(&mut scenario); -#[test, expected_failure(abort_code = margin_registry::EPriceDeviationTooHigh)] -fun test_market_sell_order_below_lower_bound() { - // Test that market sell orders fail when execution price is below lower bound - // Orderbook has bids at $0.90 (10% below oracle), lower bound is $0.95 (5%) - let ( - mut scenario, - clock, - _admin_cap, - _maintainer_cap, - base_pool_id, - quote_pool_id, + scenario.next_tx(test_constants::admin()); + let mut registry = scenario.take_shared(); + enable_deepbook_margin_on_pool( pool_id, - registry_id, - ) = setup_pool_proxy_test_env(); - - // Set up orderbook with bids at $0.90 (below 5% lower bound) - setup_orderbook_liquidity_out_of_bounds_stablecoin(&mut scenario, pool_id, &clock); + &mut registry, + &admin_cap, + &clock, + &mut scenario, + ); + initialize_pool_price(pool_id, &mut registry, &clock, &mut scenario); + return_shared(registry); + // Verify default 5% tolerance bounds scenario.next_tx(test_constants::user1()); - let mut pool = scenario.take_shared_by_id>(pool_id); + let registry = scenario.take_shared(); + let (lower_bound, upper_bound) = registry.get_price_bounds(pool_id, &clock); + // Oracle price is 1_000_000_000 + // With 5% tolerance: lower = 950_000_000, upper = 1_050_000_000 + assert!(lower_bound == 950_000_000); + assert!(upper_bound == 1_050_000_000); + return_shared(registry); + + // Admin decreases tolerance to 1% + scenario.next_tx(test_constants::admin()); + let pool = scenario.take_shared_by_id>(pool_id); let mut registry = scenario.take_shared(); - let deepbook_registry = scenario.take_shared_by_id(registry_id); - margin_manager::new( + + registry.set_price_tolerance( + &admin_cap, &pool, - &deepbook_registry, - &mut registry, + 10_000_000, // 1% tolerance &clock, - scenario.ctx(), ); - return_shared(deepbook_registry); + return_shared_2!(registry, pool); + // Verify new 1% tolerance bounds scenario.next_tx(test_constants::user1()); - let mut mm = scenario.take_shared>(); - let usdc_price = build_demo_usdc_price_info_object(&mut scenario, &clock); - let usdt_price = build_demo_usdt_price_info_object(&mut scenario, &clock); + let registry = scenario.take_shared(); + let (lower_bound, upper_bound) = registry.get_price_bounds(pool_id, &clock); + // With 1% tolerance: lower = 990_000_000, upper = 1_010_000_000 + assert!(lower_bound == 990_000_000); + assert!(upper_bound == 1_010_000_000); - // Deposit USDC to sell - mm.deposit( - ®istry, - &usdc_price, - &usdt_price, - mint_coin(10000 * test_constants::usdc_multiplier(), scenario.ctx()), + return_shared(registry); + destroy(admin_cap); + destroy(maintainer_cap); + destroy(clock); + scenario.end(); +} + +#[test] +fun test_tolerance_increase_changes_bounds() { + // Verify that increasing tolerance from 5% to 10% correctly changes price bounds + let (mut scenario, clock, admin_cap, maintainer_cap) = setup_margin_registry(); + + let _base_pool_id = create_margin_pool( + &mut scenario, + &maintainer_cap, + default_protocol_config(), &clock, - scenario.ctx(), ); - destroy_2!(usdc_price, usdt_price); - - // Market sell USDC - will try to execute at $0.90 (below lower bound of $0.95) - // Should fail with EPriceDeviationTooHigh - let base_pool = scenario.take_shared_by_id>(base_pool_id); - let quote_pool = scenario.take_shared_by_id>(quote_pool_id); - test_helpers::place_market_order_v2_for_test( + let _quote_pool_id = create_margin_pool( &mut scenario, - ®istry, - &base_pool, - "e_pool, - &mut mm, - &mut pool, - 1, - constants::self_matching_allowed(), - 100 * test_constants::usdc_multiplier(), - false, - // is_bid = false (sell) - false, + &maintainer_cap, + default_protocol_config(), &clock, ); - return_shared(base_pool); - return_shared(quote_pool); - abort -} + let (pool_id, _registry_id) = create_pool_for_testing(&mut scenario); -#[test, expected_failure(abort_code = pool_proxy::ENoLiquidityInOrderbook)] -fun test_market_buy_order_no_liquidity() { - // Test that market buy orders fail when there's no liquidity (base_out == 0) - let ( - mut scenario, - clock, - _admin_cap, - _maintainer_cap, - base_pool_id, - quote_pool_id, + scenario.next_tx(test_constants::admin()); + let mut registry = scenario.take_shared(); + enable_deepbook_margin_on_pool( pool_id, - registry_id, - ) = setup_pool_proxy_test_env(); - - // Don't set up any orderbook liquidity + &mut registry, + &admin_cap, + &clock, + &mut scenario, + ); + initialize_pool_price(pool_id, &mut registry, &clock, &mut scenario); + return_shared(registry); + // Verify default 5% tolerance bounds scenario.next_tx(test_constants::user1()); - let mut pool = scenario.take_shared_by_id>(pool_id); + let registry = scenario.take_shared(); + let (lower_bound, upper_bound) = registry.get_price_bounds(pool_id, &clock); + assert!(lower_bound == 950_000_000); // 1_000_000_000 * 0.95 + assert!(upper_bound == 1_050_000_000); // 1_000_000_000 * 1.05 + return_shared(registry); + + // Admin increases tolerance to 10% + scenario.next_tx(test_constants::admin()); + let pool = scenario.take_shared_by_id>(pool_id); let mut registry = scenario.take_shared(); - let deepbook_registry = scenario.take_shared_by_id(registry_id); - margin_manager::new( + + registry.set_price_tolerance( + &admin_cap, &pool, - &deepbook_registry, - &mut registry, + 100_000_000, // 10% tolerance &clock, - scenario.ctx(), ); - return_shared(deepbook_registry); + return_shared_2!(registry, pool); + // Verify new 10% tolerance bounds scenario.next_tx(test_constants::user1()); - let mut mm = scenario.take_shared>(); - let usdc_price = build_demo_usdc_price_info_object(&mut scenario, &clock); - let usdt_price = build_demo_usdt_price_info_object(&mut scenario, &clock); + let registry = scenario.take_shared(); + let (lower_bound, upper_bound) = registry.get_price_bounds(pool_id, &clock); + // With 10% tolerance: + // lower_bound = 1_000_000_000 * 0.90 = 900_000_000 + // upper_bound = 1_000_000_000 * 1.10 = 1_100_000_000 + assert!(lower_bound == 900_000_000); + assert!(upper_bound == 1_100_000_000); - mm.deposit( - ®istry, - &usdc_price, - &usdt_price, - mint_coin(10000 * test_constants::usdt_multiplier(), scenario.ctx()), + // A price of 920_000_000 (8% below oracle) is now within bounds + // Previously with 5% tolerance, lower_bound was 950_000_000, so 920_000_000 would fail + // Now with 10% tolerance, lower_bound is 900_000_000, so 920_000_000 passes + + return_shared(registry); + destroy(admin_cap); + destroy(maintainer_cap); + destroy(clock); + scenario.end(); +} + +#[test, expected_failure(abort_code = margin_registry::EPriceUpdateRequired)] +fun test_max_price_age_decrease_makes_price_stale() { + // Price is fresh with 5 minute max age, becomes stale after decreasing to 30 seconds + let (mut scenario, mut clock, admin_cap, maintainer_cap) = setup_margin_registry(); + + clock.set_for_testing(1_000_000); // Start at 1 second + + let _base_pool_id = create_margin_pool( + &mut scenario, + &maintainer_cap, + default_protocol_config(), &clock, - scenario.ctx(), ); - destroy_2!(usdc_price, usdt_price); - - // Market buy with no liquidity - base_out will be 0 - // Should fail with ENoLiquidityInOrderbook - let base_pool = scenario.take_shared_by_id>(base_pool_id); - let quote_pool = scenario.take_shared_by_id>(quote_pool_id); - test_helpers::place_market_order_v2_for_test( + let _quote_pool_id = create_margin_pool( &mut scenario, - ®istry, - &base_pool, - "e_pool, - &mut mm, - &mut pool, - 1, - constants::self_matching_allowed(), - 100 * test_constants::usdc_multiplier(), - true, - // is_bid = true (buy) - false, + &maintainer_cap, + default_protocol_config(), &clock, ); - return_shared(base_pool); - return_shared(quote_pool); - - abort -} - -#[test, expected_failure(abort_code = pool_proxy::ENoLiquidityInOrderbook)] -fun test_market_sell_order_no_liquidity() { - // Test that market sell orders fail when there's no liquidity (base_used == 0) - let ( - mut scenario, - clock, - _admin_cap, - _maintainer_cap, - base_pool_id, - quote_pool_id, - pool_id, - registry_id, - ) = setup_pool_proxy_test_env(); - // Don't set up any orderbook liquidity + let (pool_id, registry_id) = create_pool_for_testing(&mut scenario); - scenario.next_tx(test_constants::user1()); - let mut pool = scenario.take_shared_by_id>(pool_id); + scenario.next_tx(test_constants::admin()); let mut registry = scenario.take_shared(); - let deepbook_registry = scenario.take_shared_by_id(registry_id); - margin_manager::new( - &pool, - &deepbook_registry, + enable_deepbook_margin_on_pool( + pool_id, &mut registry, + &admin_cap, &clock, - scenario.ctx(), + &mut scenario, ); - return_shared(deepbook_registry); + initialize_pool_price(pool_id, &mut registry, &clock, &mut scenario); + return_shared(registry); - scenario.next_tx(test_constants::user1()); - let mut mm = scenario.take_shared>(); - let usdc_price = build_demo_usdc_price_info_object(&mut scenario, &clock); - let usdt_price = build_demo_usdt_price_info_object(&mut scenario, &clock); + // Advance clock by 1 minute (60,000 ms) - still fresh with default 5 min max age + clock.increment_for_testing(60_000); + + // Verify price is still fresh + scenario.next_tx(test_constants::user1()); + let registry = scenario.take_shared(); + let (_lower_bound, _upper_bound) = registry.get_price_bounds(pool_id, &clock); + return_shared(registry); + + // Admin decreases max_price_age to 30 seconds + scenario.next_tx(test_constants::admin()); + let pool = scenario.take_shared_by_id>(pool_id); + let mut registry = scenario.take_shared(); + + registry.set_max_price_age( + &admin_cap, + &pool, + 30_000, // 30 seconds + &clock, + ); + return_shared_2!(registry, pool); + + // Now price should be stale (1 minute old > 30 second max age) + scenario.next_tx(test_constants::user1()); + let registry = scenario.take_shared(); + let (_lower_bound, _upper_bound) = registry.get_price_bounds(pool_id, &clock); // Should fail + + return_shared(registry); + destroy(admin_cap); + destroy(maintainer_cap); + destroy(registry_id); + destroy(clock); + scenario.end(); +} + +#[test] +fun test_max_price_age_increase_makes_price_fresh() { + // Price is stale with 5 minute max age, becomes fresh after increasing to 10 minutes + let (mut scenario, mut clock, admin_cap, maintainer_cap) = setup_margin_registry(); + + clock.set_for_testing(1_000_000); // Start at 1 second + + let _base_pool_id = create_margin_pool( + &mut scenario, + &maintainer_cap, + default_protocol_config(), + &clock, + ); + let _quote_pool_id = create_margin_pool( + &mut scenario, + &maintainer_cap, + default_protocol_config(), + &clock, + ); + + let (pool_id, _registry_id) = create_pool_for_testing(&mut scenario); + + scenario.next_tx(test_constants::admin()); + let mut registry = scenario.take_shared(); + enable_deepbook_margin_on_pool( + pool_id, + &mut registry, + &admin_cap, + &clock, + &mut scenario, + ); + initialize_pool_price(pool_id, &mut registry, &clock, &mut scenario); + return_shared(registry); + + // Advance clock by 6 minutes (360,000 ms) - stale with default 5 min max age + clock.increment_for_testing(360_000); + + // Admin increases max_price_age to 10 minutes before checking + scenario.next_tx(test_constants::admin()); + let pool = scenario.take_shared_by_id>(pool_id); + let mut registry = scenario.take_shared(); + + registry.set_max_price_age( + &admin_cap, + &pool, + 600_000, // 10 minutes + &clock, + ); + return_shared_2!(registry, pool); + + // Now price should be fresh (6 minutes old < 10 minute max age) + scenario.next_tx(test_constants::user1()); + let registry = scenario.take_shared(); + let (lower_bound, upper_bound) = registry.get_price_bounds(pool_id, &clock); + + // Verify we got valid bounds (price is fresh) + assert!(lower_bound > 0); + assert!(upper_bound > lower_bound); + + return_shared(registry); + destroy(admin_cap); + destroy(maintainer_cap); + destroy(clock); + scenario.end(); +} + +#[test, expected_failure(abort_code = margin_registry::EPriceDeviationTooHigh)] +fun test_tolerance_decrease_rejects_order_e2e() { + // End-to-end test: place order fails after tolerance is decreased + let (mut scenario, clock, admin_cap, maintainer_cap) = setup_margin_registry(); + + let base_pool_id = create_margin_pool( + &mut scenario, + &maintainer_cap, + default_protocol_config(), + &clock, + ); + let quote_pool_id = create_margin_pool( + &mut scenario, + &maintainer_cap, + default_protocol_config(), + &clock, + ); + + let (pool_id, registry_id) = create_pool_for_testing(&mut scenario); + + scenario.next_tx(test_constants::admin()); + let mut registry = scenario.take_shared(); + enable_deepbook_margin_on_pool( + pool_id, + &mut registry, + &admin_cap, + &clock, + &mut scenario, + ); + initialize_pool_price(pool_id, &mut registry, &clock, &mut scenario); + return_shared(registry); + + // Admin decreases tolerance to 1% + scenario.next_tx(test_constants::admin()); + let pool = scenario.take_shared_by_id>(pool_id); + let mut registry = scenario.take_shared(); + + registry.set_price_tolerance( + &admin_cap, + &pool, + 10_000_000, // 1% tolerance + &clock, + ); + return_shared_2!(registry, pool); + + // Create margin manager and deposit collateral + scenario.next_tx(test_constants::user1()); + let pool = scenario.take_shared_by_id>(pool_id); + let mut registry = scenario.take_shared(); + let deepbook_registry = scenario.take_shared_by_id(registry_id); + margin_manager::new( + &pool, + &deepbook_registry, + &mut registry, + &clock, + scenario.ctx(), + ); + return_shared(deepbook_registry); + return_shared_2!(registry, pool); + + scenario.next_tx(test_constants::user1()); + let mut mm = scenario.take_shared>(); + let mut pool = scenario.take_shared_by_id>(pool_id); + let registry = scenario.take_shared(); + let usdc_price = build_demo_usdc_price_info_object(&mut scenario, &clock); + let usdt_price = build_demo_usdt_price_info_object(&mut scenario, &clock); mm.deposit( ®istry, @@ -4375,11 +4995,10 @@ fun test_market_sell_order_no_liquidity() { ); destroy_2!(usdc_price, usdt_price); - // Market sell with no liquidity - base_used will be 0 - // Should fail with EPriceDeviationTooHigh + // Try to place bid at 4% above oracle - should FAIL with 1% tolerance let base_pool = scenario.take_shared_by_id>(base_pool_id); let quote_pool = scenario.take_shared_by_id>(quote_pool_id); - test_helpers::place_market_order_v2_for_test( + test_helpers::place_limit_order_v2_for_test( &mut scenario, ®istry, &base_pool, @@ -4387,42 +5006,1934 @@ fun test_market_sell_order_no_liquidity() { &mut mm, &mut pool, 1, + constants::no_restriction(), constants::self_matching_allowed(), + 1_040_000_000, + // 4% above oracle 100 * test_constants::usdc_multiplier(), + true, + // is_bid false, - // is_bid = false (sell) - false, + 2000000, + &clock, + ); + return_shared(base_pool); + return_shared(quote_pool); + + abort 0 +} + +#[test, expected_failure(abort_code = margin_registry::EPriceUpdateRequired)] +fun test_max_price_age_decrease_rejects_order_e2e() { + // End-to-end test: order fails after max_price_age is decreased and price becomes stale + let (mut scenario, mut clock, admin_cap, maintainer_cap) = setup_margin_registry(); + + clock.set_for_testing(1_000_000); + + let base_pool_id = create_margin_pool( + &mut scenario, + &maintainer_cap, + default_protocol_config(), + &clock, + ); + let quote_pool_id = create_margin_pool( + &mut scenario, + &maintainer_cap, + default_protocol_config(), + &clock, + ); + + let (pool_id, registry_id) = create_pool_for_testing(&mut scenario); + + scenario.next_tx(test_constants::admin()); + let mut registry = scenario.take_shared(); + enable_deepbook_margin_on_pool( + pool_id, + &mut registry, + &admin_cap, + &clock, + &mut scenario, + ); + initialize_pool_price(pool_id, &mut registry, &clock, &mut scenario); + return_shared(registry); + + // Advance clock by 1 minute + clock.increment_for_testing(60_000); + + // Admin decreases max_price_age to 30 seconds + scenario.next_tx(test_constants::admin()); + let pool = scenario.take_shared_by_id>(pool_id); + let mut registry = scenario.take_shared(); + + registry.set_max_price_age( + &admin_cap, + &pool, + 30_000, // 30 seconds + &clock, + ); + return_shared_2!(registry, pool); + + // Create margin manager and deposit collateral + scenario.next_tx(test_constants::user1()); + let pool = scenario.take_shared_by_id>(pool_id); + let mut registry = scenario.take_shared(); + let deepbook_registry = scenario.take_shared_by_id(registry_id); + margin_manager::new( + &pool, + &deepbook_registry, + &mut registry, + &clock, + scenario.ctx(), + ); + return_shared(deepbook_registry); + return_shared_2!(registry, pool); + + scenario.next_tx(test_constants::user1()); + let mut mm = scenario.take_shared>(); + let mut pool = scenario.take_shared_by_id>(pool_id); + let registry = scenario.take_shared(); + let usdc_price = build_demo_usdc_price_info_object(&mut scenario, &clock); + let usdt_price = build_demo_usdt_price_info_object(&mut scenario, &clock); + + mm.deposit( + ®istry, + &usdc_price, + &usdt_price, + mint_coin(10000 * test_constants::usdc_multiplier(), scenario.ctx()), + &clock, + scenario.ctx(), + ); + destroy_2!(usdc_price, usdt_price); + + // Try to place order - should FAIL because price is stale + let base_pool = scenario.take_shared_by_id>(base_pool_id); + let quote_pool = scenario.take_shared_by_id>(quote_pool_id); + test_helpers::place_limit_order_v2_for_test( + &mut scenario, + ®istry, + &base_pool, + "e_pool, + &mut mm, + &mut pool, + 1, + constants::no_restriction(), + constants::self_matching_allowed(), + 1_000_000_000, + 100 * test_constants::usdc_multiplier(), + true, + false, + 2000000, + &clock, + ); + return_shared(base_pool); + return_shared(quote_pool); + + abort 0 +} + +// === Self-Matching Prevention Tests === +// Note: Margin trading forces CANCEL_TAKER self-matching option. +// This prevents users from matching against their own orders by canceling the taker side. +// Cross-wallet self-matching between different margin managers cannot be fully prevented +// at the protocol level - see security review for details. + +// === Additional Price Protection Boundary Tests === + +#[test, expected_failure(abort_code = margin_registry::EPriceDeviationTooHigh)] +fun test_limit_order_price_just_above_upper_bound_fails() { + // Test that a bid (buy) order at price 1 unit above upper bound fails + // Bids only check upper bound - buying above oracle is bad + let ( + mut scenario, + clock, + _admin_cap, + _maintainer_cap, + base_pool_id, + quote_pool_id, + pool_id, + registry_id, + ) = setup_pool_proxy_test_env(); + + scenario.next_tx(test_constants::user1()); + let mut pool = scenario.take_shared_by_id>(pool_id); + let mut registry = scenario.take_shared(); + let deepbook_registry = scenario.take_shared_by_id(registry_id); + margin_manager::new( + &pool, + &deepbook_registry, + &mut registry, + &clock, + scenario.ctx(), + ); + return_shared(deepbook_registry); + + scenario.next_tx(test_constants::user1()); + let mut mm = scenario.take_shared>(); + let usdc_price = build_demo_usdc_price_info_object(&mut scenario, &clock); + let usdt_price = build_demo_usdt_price_info_object(&mut scenario, &clock); + + mm.deposit( + ®istry, + &usdc_price, + &usdt_price, + mint_coin(10000 * test_constants::usdc_multiplier(), scenario.ctx()), + &clock, + scenario.ctx(), + ); + + destroy_2!(usdc_price, usdt_price); + + // Oracle price is 1_000_000_000, upper bound = 1_050_000_000 + // Price 1_050_000_001 is just above upper bound - should fail for bids + let base_pool = scenario.take_shared_by_id>(base_pool_id); + let quote_pool = scenario.take_shared_by_id>(quote_pool_id); + test_helpers::place_limit_order_v2_for_test( + &mut scenario, + ®istry, + &base_pool, + "e_pool, + &mut mm, + &mut pool, + 1, + constants::no_restriction(), + constants::self_matching_allowed(), + 1_050_000_001, + // Just above upper bound + 100 * test_constants::usdc_multiplier(), + true, + // is_bid = true (buy order) + false, + 18446744073709551615, + &clock, + ); + return_shared(base_pool); + return_shared(quote_pool); + + abort 0 +} + +#[test, expected_failure(abort_code = margin_registry::EPriceDeviationTooHigh)] +fun test_limit_order_price_just_below_lower_bound_fails() { + // Test that a limit order at price 1 unit below lower bound fails + let ( + mut scenario, + clock, + _admin_cap, + _maintainer_cap, + base_pool_id, + quote_pool_id, + pool_id, + registry_id, + ) = setup_pool_proxy_test_env(); + + scenario.next_tx(test_constants::user1()); + let mut pool = scenario.take_shared_by_id>(pool_id); + let mut registry = scenario.take_shared(); + let deepbook_registry = scenario.take_shared_by_id(registry_id); + margin_manager::new( + &pool, + &deepbook_registry, + &mut registry, + &clock, + scenario.ctx(), + ); + return_shared(deepbook_registry); + + scenario.next_tx(test_constants::user1()); + let mut mm = scenario.take_shared>(); + let usdc_price = build_demo_usdc_price_info_object(&mut scenario, &clock); + let usdt_price = build_demo_usdt_price_info_object(&mut scenario, &clock); + + mm.deposit( + ®istry, + &usdc_price, + &usdt_price, + mint_coin(10000 * test_constants::usdc_multiplier(), scenario.ctx()), + &clock, + scenario.ctx(), + ); + + destroy_2!(usdc_price, usdt_price); + + // Oracle price is 1_000_000_000, lower bound = 950_000_000 + // Price 949_999_999 is just below lower bound - should fail + let base_pool = scenario.take_shared_by_id>(base_pool_id); + let quote_pool = scenario.take_shared_by_id>(quote_pool_id); + test_helpers::place_limit_order_v2_for_test( + &mut scenario, + ®istry, + &base_pool, + "e_pool, + &mut mm, + &mut pool, + 1, + constants::no_restriction(), + constants::self_matching_allowed(), + 949_999_999, + // Just below lower bound + 100 * test_constants::usdc_multiplier(), + false, + false, + 18446744073709551615, + &clock, + ); + return_shared(base_pool); + return_shared(quote_pool); + + abort 0 +} + +#[test] +fun test_bid_order_allowed_at_any_price_below_oracle() { + // Test that bid (buy) orders can be placed at any price below oracle + // Bids only check upper bound - buying below oracle is always fine + let ( + mut scenario, + clock, + _admin_cap, + _maintainer_cap, + base_pool_id, + quote_pool_id, + pool_id, + registry_id, + ) = setup_pool_proxy_test_env(); + + scenario.next_tx(test_constants::user1()); + let mut pool = scenario.take_shared_by_id>(pool_id); + let mut registry = scenario.take_shared(); + let deepbook_registry = scenario.take_shared_by_id(registry_id); + margin_manager::new( + &pool, + &deepbook_registry, + &mut registry, + &clock, + scenario.ctx(), + ); + return_shared(deepbook_registry); + + scenario.next_tx(test_constants::user1()); + let mut mm = scenario.take_shared>(); + let usdc_price = build_demo_usdc_price_info_object(&mut scenario, &clock); + let usdt_price = build_demo_usdt_price_info_object(&mut scenario, &clock); + + // Deposit USDT collateral for buying USDC + mm.deposit( + ®istry, + &usdc_price, + &usdt_price, + mint_coin(10000 * test_constants::usdt_multiplier(), scenario.ctx()), + &clock, + scenario.ctx(), + ); + + destroy_2!(usdc_price, usdt_price); + + // Place bid at 50% below oracle (500_000_000) + // Oracle price is 1_000_000_000 (1.0), this is way below lower bound of 950_000_000 + // But bids don't check lower bound - should succeed + let base_pool = scenario.take_shared_by_id>(base_pool_id); + let quote_pool = scenario.take_shared_by_id>(quote_pool_id); + let order_info = test_helpers::place_limit_order_v2_for_test( + &mut scenario, + ®istry, + &base_pool, + "e_pool, + &mut mm, + &mut pool, + 1, + constants::no_restriction(), + constants::self_matching_allowed(), + 500_000_000, + // 50% below oracle - should succeed for bids + 100 * test_constants::usdc_multiplier(), + true, + // is_bid = true (buy order) + false, + 18446744073709551615, + &clock, + ); + return_shared(base_pool); + return_shared(quote_pool); + + assert!(order_info.client_order_id() == 1); + destroy(order_info); + return_shared_2!(mm, pool); + cleanup_margin_test(registry, _admin_cap, _maintainer_cap, clock, scenario); +} + +#[test] +fun test_ask_order_allowed_at_any_price_above_oracle() { + // Test that ask (sell) orders can be placed at any price above oracle + // Asks only check lower bound - selling above oracle is always fine + let ( + mut scenario, + clock, + _admin_cap, + _maintainer_cap, + base_pool_id, + quote_pool_id, + pool_id, + registry_id, + ) = setup_pool_proxy_test_env(); + + scenario.next_tx(test_constants::user1()); + let mut pool = scenario.take_shared_by_id>(pool_id); + let mut registry = scenario.take_shared(); + let deepbook_registry = scenario.take_shared_by_id(registry_id); + margin_manager::new( + &pool, + &deepbook_registry, + &mut registry, + &clock, + scenario.ctx(), + ); + return_shared(deepbook_registry); + + scenario.next_tx(test_constants::user1()); + let mut mm = scenario.take_shared>(); + let usdc_price = build_demo_usdc_price_info_object(&mut scenario, &clock); + let usdt_price = build_demo_usdt_price_info_object(&mut scenario, &clock); + + // Deposit USDC collateral for selling + mm.deposit( + ®istry, + &usdc_price, + &usdt_price, + mint_coin(10000 * test_constants::usdc_multiplier(), scenario.ctx()), + &clock, + scenario.ctx(), + ); + + destroy_2!(usdc_price, usdt_price); + + // Place ask at 50% above oracle (1_500_000_000) + // Oracle price is 1_000_000_000 (1.0), this is way above upper bound of 1_050_000_000 + // But asks don't check upper bound - should succeed + let base_pool = scenario.take_shared_by_id>(base_pool_id); + let quote_pool = scenario.take_shared_by_id>(quote_pool_id); + let order_info = test_helpers::place_limit_order_v2_for_test( + &mut scenario, + ®istry, + &base_pool, + "e_pool, + &mut mm, + &mut pool, + 1, + constants::no_restriction(), + constants::self_matching_allowed(), + 1_500_000_000, + // 50% above oracle - should succeed for asks + 100 * test_constants::usdc_multiplier(), + false, + // is_bid = false (sell order) + false, + 18446744073709551615, + &clock, + ); + return_shared(base_pool); + return_shared(quote_pool); + + assert!(order_info.client_order_id() == 1); + destroy(order_info); + return_shared_2!(mm, pool); + cleanup_margin_test(registry, _admin_cap, _maintainer_cap, clock, scenario); +} + +#[test] +fun test_market_buy_order_above_oracle_within_bounds() { + // Test that market buy orders work when execution price is above oracle + // but within the 5% upper bound tolerance + // Orderbook has asks at $1.01, oracle is $1.00, upper bound is $1.05 + let ( + mut scenario, + clock, + _admin_cap, + _maintainer_cap, + base_pool_id, + quote_pool_id, + pool_id, + registry_id, + ) = setup_pool_proxy_test_env(); + + // Set up orderbook with asks at $1.01 (above oracle but within bounds) + setup_orderbook_liquidity_stablecoin(&mut scenario, pool_id, &clock); + + scenario.next_tx(test_constants::user1()); + let mut pool = scenario.take_shared_by_id>(pool_id); + let mut registry = scenario.take_shared(); + let deepbook_registry = scenario.take_shared_by_id(registry_id); + margin_manager::new( + &pool, + &deepbook_registry, + &mut registry, + &clock, + scenario.ctx(), + ); + return_shared(deepbook_registry); + + scenario.next_tx(test_constants::user1()); + let mut mm = scenario.take_shared>(); + let usdc_price = build_demo_usdc_price_info_object(&mut scenario, &clock); + let usdt_price = build_demo_usdt_price_info_object(&mut scenario, &clock); + + // Deposit USDT to buy USDC + mm.deposit( + ®istry, + &usdc_price, + &usdt_price, + mint_coin(10000 * test_constants::usdt_multiplier(), scenario.ctx()), + &clock, + scenario.ctx(), + ); + destroy_2!(usdc_price, usdt_price); + + // Market buy USDC - will execute at $1.01 (above oracle of $1.00) + // Bids only check upper bound ($1.05), so $1.01 should pass + let base_pool = scenario.take_shared_by_id>(base_pool_id); + let quote_pool = scenario.take_shared_by_id>(quote_pool_id); + let order_info = test_helpers::place_market_order_v2_for_test( + &mut scenario, + ®istry, + &base_pool, + "e_pool, + &mut mm, + &mut pool, + 1, + constants::self_matching_allowed(), + 100 * test_constants::usdc_multiplier(), + true, + // is_bid = true (buy) + false, + &clock, + ); + return_shared(base_pool); + return_shared(quote_pool); + + assert!(order_info.client_order_id() == 1); + destroy(order_info); + return_shared_2!(mm, pool); + cleanup_margin_test(registry, _admin_cap, _maintainer_cap, clock, scenario); +} + +#[test] +fun test_market_sell_order_below_oracle_within_bounds() { + // Test that market sell orders work when execution price is below oracle + // but within the 5% lower bound tolerance + // Orderbook has bids at $0.99, oracle is $1.00, lower bound is $0.95 + let ( + mut scenario, + clock, + _admin_cap, + _maintainer_cap, + base_pool_id, + quote_pool_id, + pool_id, + registry_id, + ) = setup_pool_proxy_test_env(); + + // Set up orderbook with bids at $0.99 (below oracle but within bounds) + setup_orderbook_liquidity_stablecoin(&mut scenario, pool_id, &clock); + + scenario.next_tx(test_constants::user1()); + let mut pool = scenario.take_shared_by_id>(pool_id); + let mut registry = scenario.take_shared(); + let deepbook_registry = scenario.take_shared_by_id(registry_id); + margin_manager::new( + &pool, + &deepbook_registry, + &mut registry, + &clock, + scenario.ctx(), + ); + return_shared(deepbook_registry); + + scenario.next_tx(test_constants::user1()); + let mut mm = scenario.take_shared>(); + let usdc_price = build_demo_usdc_price_info_object(&mut scenario, &clock); + let usdt_price = build_demo_usdt_price_info_object(&mut scenario, &clock); + + // Deposit USDC to sell + mm.deposit( + ®istry, + &usdc_price, + &usdt_price, + mint_coin(10000 * test_constants::usdc_multiplier(), scenario.ctx()), + &clock, + scenario.ctx(), + ); + destroy_2!(usdc_price, usdt_price); + + // Market sell USDC - will execute at $0.99 (below oracle of $1.00) + // Asks only check lower bound ($0.95), so $0.99 should pass + let base_pool = scenario.take_shared_by_id>(base_pool_id); + let quote_pool = scenario.take_shared_by_id>(quote_pool_id); + let order_info = test_helpers::place_market_order_v2_for_test( + &mut scenario, + ®istry, + &base_pool, + "e_pool, + &mut mm, + &mut pool, + 1, + constants::self_matching_allowed(), + 100 * test_constants::usdc_multiplier(), + false, + // is_bid = false (sell) + false, + &clock, + ); + return_shared(base_pool); + return_shared(quote_pool); + + assert!(order_info.client_order_id() == 1); + destroy(order_info); + return_shared_2!(mm, pool); + cleanup_margin_test(registry, _admin_cap, _maintainer_cap, clock, scenario); +} + +#[test, expected_failure(abort_code = margin_registry::EPriceDeviationTooHigh)] +fun test_market_buy_order_exceeds_upper_bound() { + // Test that market buy orders fail when execution price exceeds upper bound + // Orderbook has asks at $1.10 (10% above oracle), upper bound is $1.05 (5%) + let ( + mut scenario, + clock, + _admin_cap, + _maintainer_cap, + base_pool_id, + quote_pool_id, + pool_id, + registry_id, + ) = setup_pool_proxy_test_env(); + + // Set up orderbook with asks at $1.10 (exceeds 5% upper bound) + setup_orderbook_liquidity_out_of_bounds_stablecoin(&mut scenario, pool_id, &clock); + + scenario.next_tx(test_constants::user1()); + let mut pool = scenario.take_shared_by_id>(pool_id); + let mut registry = scenario.take_shared(); + let deepbook_registry = scenario.take_shared_by_id(registry_id); + margin_manager::new( + &pool, + &deepbook_registry, + &mut registry, + &clock, + scenario.ctx(), + ); + return_shared(deepbook_registry); + + scenario.next_tx(test_constants::user1()); + let mut mm = scenario.take_shared>(); + let usdc_price = build_demo_usdc_price_info_object(&mut scenario, &clock); + let usdt_price = build_demo_usdt_price_info_object(&mut scenario, &clock); + + // Deposit USDT to buy USDC + mm.deposit( + ®istry, + &usdc_price, + &usdt_price, + mint_coin(10000 * test_constants::usdt_multiplier(), scenario.ctx()), + &clock, + scenario.ctx(), + ); + destroy_2!(usdc_price, usdt_price); + + // Market buy USDC - will try to execute at $1.10 (exceeds upper bound of $1.05) + // Should fail with EPriceDeviationTooHigh + let base_pool = scenario.take_shared_by_id>(base_pool_id); + let quote_pool = scenario.take_shared_by_id>(quote_pool_id); + test_helpers::place_market_order_v2_for_test( + &mut scenario, + ®istry, + &base_pool, + "e_pool, + &mut mm, + &mut pool, + 1, + constants::self_matching_allowed(), + 100 * test_constants::usdc_multiplier(), + true, + // is_bid = true (buy) + false, + &clock, + ); + return_shared(base_pool); + return_shared(quote_pool); + + abort +} + +#[test, expected_failure(abort_code = margin_registry::EPriceDeviationTooHigh)] +fun test_market_sell_order_below_lower_bound() { + // Test that market sell orders fail when execution price is below lower bound + // Orderbook has bids at $0.90 (10% below oracle), lower bound is $0.95 (5%) + let ( + mut scenario, + clock, + _admin_cap, + _maintainer_cap, + base_pool_id, + quote_pool_id, + pool_id, + registry_id, + ) = setup_pool_proxy_test_env(); + + // Set up orderbook with bids at $0.90 (below 5% lower bound) + setup_orderbook_liquidity_out_of_bounds_stablecoin(&mut scenario, pool_id, &clock); + + scenario.next_tx(test_constants::user1()); + let mut pool = scenario.take_shared_by_id>(pool_id); + let mut registry = scenario.take_shared(); + let deepbook_registry = scenario.take_shared_by_id(registry_id); + margin_manager::new( + &pool, + &deepbook_registry, + &mut registry, + &clock, + scenario.ctx(), + ); + return_shared(deepbook_registry); + + scenario.next_tx(test_constants::user1()); + let mut mm = scenario.take_shared>(); + let usdc_price = build_demo_usdc_price_info_object(&mut scenario, &clock); + let usdt_price = build_demo_usdt_price_info_object(&mut scenario, &clock); + + // Deposit USDC to sell + mm.deposit( + ®istry, + &usdc_price, + &usdt_price, + mint_coin(10000 * test_constants::usdc_multiplier(), scenario.ctx()), + &clock, + scenario.ctx(), + ); + destroy_2!(usdc_price, usdt_price); + + // Market sell USDC - will try to execute at $0.90 (below lower bound of $0.95) + // Should fail with EPriceDeviationTooHigh + let base_pool = scenario.take_shared_by_id>(base_pool_id); + let quote_pool = scenario.take_shared_by_id>(quote_pool_id); + test_helpers::place_market_order_v2_for_test( + &mut scenario, + ®istry, + &base_pool, + "e_pool, + &mut mm, + &mut pool, + 1, + constants::self_matching_allowed(), + 100 * test_constants::usdc_multiplier(), + false, + // is_bid = false (sell) + false, + &clock, + ); + return_shared(base_pool); + return_shared(quote_pool); + + abort +} + +#[test, expected_failure(abort_code = pool_proxy::ENoLiquidityInOrderbook)] +fun test_market_buy_order_no_liquidity() { + // Test that market buy orders fail when there's no liquidity (base_out == 0) + let ( + mut scenario, + clock, + _admin_cap, + _maintainer_cap, + base_pool_id, + quote_pool_id, + pool_id, + registry_id, + ) = setup_pool_proxy_test_env(); + + // Don't set up any orderbook liquidity + + scenario.next_tx(test_constants::user1()); + let mut pool = scenario.take_shared_by_id>(pool_id); + let mut registry = scenario.take_shared(); + let deepbook_registry = scenario.take_shared_by_id(registry_id); + margin_manager::new( + &pool, + &deepbook_registry, + &mut registry, + &clock, + scenario.ctx(), + ); + return_shared(deepbook_registry); + + scenario.next_tx(test_constants::user1()); + let mut mm = scenario.take_shared>(); + let usdc_price = build_demo_usdc_price_info_object(&mut scenario, &clock); + let usdt_price = build_demo_usdt_price_info_object(&mut scenario, &clock); + + mm.deposit( + ®istry, + &usdc_price, + &usdt_price, + mint_coin(10000 * test_constants::usdt_multiplier(), scenario.ctx()), + &clock, + scenario.ctx(), + ); + destroy_2!(usdc_price, usdt_price); + + // Market buy with no liquidity - base_out will be 0 + // Should fail with ENoLiquidityInOrderbook + let base_pool = scenario.take_shared_by_id>(base_pool_id); + let quote_pool = scenario.take_shared_by_id>(quote_pool_id); + test_helpers::place_market_order_v2_for_test( + &mut scenario, + ®istry, + &base_pool, + "e_pool, + &mut mm, + &mut pool, + 1, + constants::self_matching_allowed(), + 100 * test_constants::usdc_multiplier(), + true, + // is_bid = true (buy) + false, + &clock, + ); + return_shared(base_pool); + return_shared(quote_pool); + + abort +} + +#[test, expected_failure(abort_code = pool_proxy::ENoLiquidityInOrderbook)] +fun test_market_sell_order_no_liquidity() { + // Test that market sell orders fail when there's no liquidity (base_used == 0) + let ( + mut scenario, + clock, + _admin_cap, + _maintainer_cap, + base_pool_id, + quote_pool_id, + pool_id, + registry_id, + ) = setup_pool_proxy_test_env(); + + // Don't set up any orderbook liquidity + + scenario.next_tx(test_constants::user1()); + let mut pool = scenario.take_shared_by_id>(pool_id); + let mut registry = scenario.take_shared(); + let deepbook_registry = scenario.take_shared_by_id(registry_id); + margin_manager::new( + &pool, + &deepbook_registry, + &mut registry, + &clock, + scenario.ctx(), + ); + return_shared(deepbook_registry); + + scenario.next_tx(test_constants::user1()); + let mut mm = scenario.take_shared>(); + let usdc_price = build_demo_usdc_price_info_object(&mut scenario, &clock); + let usdt_price = build_demo_usdt_price_info_object(&mut scenario, &clock); + + mm.deposit( + ®istry, + &usdc_price, + &usdt_price, + mint_coin(10000 * test_constants::usdc_multiplier(), scenario.ctx()), + &clock, + scenario.ctx(), + ); + destroy_2!(usdc_price, usdt_price); + + // Market sell with no liquidity - base_used will be 0 + // Should fail with EPriceDeviationTooHigh + let base_pool = scenario.take_shared_by_id>(base_pool_id); + let quote_pool = scenario.take_shared_by_id>(quote_pool_id); + test_helpers::place_market_order_v2_for_test( + &mut scenario, + ®istry, + &base_pool, + "e_pool, + &mut mm, + &mut pool, + 1, + constants::self_matching_allowed(), + 100 * test_constants::usdc_multiplier(), + false, + // is_bid = false (sell) + false, + &clock, + ); + return_shared(base_pool); + return_shared(quote_pool); + + abort +} + +#[test, expected_failure(abort_code = margin_registry::EPriceNotInitialized)] +fun test_limit_order_price_not_initialized() { + // Test that placing an order fails if current price has never been updated + let (mut scenario, clock, admin_cap, maintainer_cap) = setup_margin_registry(); + + let base_pool_id = create_margin_pool( + &mut scenario, + &maintainer_cap, + default_protocol_config(), + &clock, + ); + let quote_pool_id = create_margin_pool( + &mut scenario, + &maintainer_cap, + default_protocol_config(), + &clock, + ); + + let (base_pool_cap, quote_pool_cap) = get_margin_pool_caps(&mut scenario, base_pool_id); + + let (pool_id, registry_id) = create_pool_for_testing(&mut scenario); + + // Enable pool but DON'T initialize price (skip initialize_pool_price call) + scenario.next_tx(test_constants::admin()); + let mut registry = scenario.take_shared(); + enable_deepbook_margin_on_pool( + pool_id, + &mut registry, + &admin_cap, + &clock, + &mut scenario, + ); + return_shared(registry); + + // Setup liquidity for margin pools (so margin manager can be created) + scenario.next_tx(test_constants::admin()); + let mut base_pool = scenario.take_shared_by_id>(base_pool_id); + let mut quote_pool = scenario.take_shared_by_id>(quote_pool_id); + let registry = scenario.take_shared(); + let supplier_cap = margin_pool::mint_supplier_cap(®istry, &clock, scenario.ctx()); + + base_pool.supply( + ®istry, + &supplier_cap, + mint_coin(1_000_000 * test_constants::usdc_multiplier(), scenario.ctx()), + option::none(), + &clock, + ); + quote_pool.supply( + ®istry, + &supplier_cap, + mint_coin(1_000_000 * test_constants::usdt_multiplier(), scenario.ctx()), + option::none(), + &clock, + ); + + base_pool.enable_deepbook_pool_for_loan(®istry, pool_id, &base_pool_cap, &clock); + quote_pool.enable_deepbook_pool_for_loan(®istry, pool_id, "e_pool_cap, &clock); + + return_shared_2!(base_pool, quote_pool); + return_shared(registry); + destroy_2!(base_pool_cap, quote_pool_cap); + destroy(supplier_cap); + + scenario.next_tx(test_constants::user1()); + let mut pool = scenario.take_shared_by_id>(pool_id); + let mut registry = scenario.take_shared(); + let deepbook_registry = scenario.take_shared_by_id(registry_id); + margin_manager::new( + &pool, + &deepbook_registry, + &mut registry, + &clock, + scenario.ctx(), + ); + return_shared(deepbook_registry); + + scenario.next_tx(test_constants::user1()); + let mut mm = scenario.take_shared>(); + let usdc_price = build_demo_usdc_price_info_object(&mut scenario, &clock); + let usdt_price = build_demo_usdt_price_info_object(&mut scenario, &clock); + + mm.deposit( + ®istry, + &usdc_price, + &usdt_price, + mint_coin(10000 * test_constants::usdc_multiplier(), scenario.ctx()), + &clock, + scenario.ctx(), + ); + destroy_2!(usdc_price, usdt_price); + + // Try to place limit order without initializing price + // Should fail with EPriceNotInitialized + let base_pool = scenario.take_shared_by_id>(base_pool_id); + let quote_pool = scenario.take_shared_by_id>(quote_pool_id); + test_helpers::place_limit_order_v2_for_test( + &mut scenario, + ®istry, + &base_pool, + "e_pool, + &mut mm, + &mut pool, + 1, + constants::no_restriction(), + constants::self_matching_allowed(), + 1_000_000_000, + // $1.00 + 100 * test_constants::usdc_multiplier(), + false, + false, + 18446744073709551615, + &clock, + ); + return_shared(base_pool); + return_shared(quote_pool); + + abort +} + +#[test, expected_failure(abort_code = margin_registry::EPriceUpdateRequired)] +fun test_limit_order_price_stale() { + // Test that placing an order fails if current price is stale + let ( + mut scenario, + mut clock, + _admin_cap, + _maintainer_cap, + base_pool_id, + quote_pool_id, + pool_id, + registry_id, + ) = setup_pool_proxy_test_env(); + + scenario.next_tx(test_constants::user1()); + let mut pool = scenario.take_shared_by_id>(pool_id); + let mut registry = scenario.take_shared(); + let deepbook_registry = scenario.take_shared_by_id(registry_id); + margin_manager::new( + &pool, + &deepbook_registry, + &mut registry, + &clock, + scenario.ctx(), + ); + return_shared(deepbook_registry); + + scenario.next_tx(test_constants::user1()); + let mut mm = scenario.take_shared>(); + let usdc_price = build_demo_usdc_price_info_object(&mut scenario, &clock); + let usdt_price = build_demo_usdt_price_info_object(&mut scenario, &clock); + + mm.deposit( + ®istry, + &usdc_price, + &usdt_price, + mint_coin(10000 * test_constants::usdc_multiplier(), scenario.ctx()), + &clock, + scenario.ctx(), + ); + destroy_2!(usdc_price, usdt_price); + + // Advance clock past max_price_age (default is 5 minutes, advance 6 minutes) + clock.increment_for_testing(6 * 60 * 1000); + + // Try to place limit order with stale price + // Should fail with EPriceUpdateRequired + let base_pool = scenario.take_shared_by_id>(base_pool_id); + let quote_pool = scenario.take_shared_by_id>(quote_pool_id); + test_helpers::place_limit_order_v2_for_test( + &mut scenario, + ®istry, + &base_pool, + "e_pool, + &mut mm, + &mut pool, + 1, + constants::no_restriction(), + constants::self_matching_allowed(), + 1_000_000_000, + // $1.00 + 100 * test_constants::usdc_multiplier(), + false, + false, + 18446744073709551615, + &clock, + ); + return_shared(base_pool); + return_shared(quote_pool); + + abort +} + +#[test] +/// Test that market sell order at min_size works with input fee (no DEEP in manager). +/// This verifies the fix for get_quote_quantity_out_input_fee min_size issue. +fun test_market_sell_min_size_input_fee_ok() { + let ( + mut scenario, + clock, + _admin_cap, + _maintainer_cap, + base_pool_id, + quote_pool_id, + pool_id, + registry_id, + ) = setup_pool_proxy_test_env(); + + // Set up orderbook with bids to match against + setup_orderbook_liquidity_stablecoin(&mut scenario, pool_id, &clock); + + scenario.next_tx(test_constants::user1()); + let pool = scenario.take_shared_by_id>(pool_id); + let mut registry = scenario.take_shared(); + let deepbook_registry = scenario.take_shared_by_id(registry_id); + margin_manager::new( + &pool, + &deepbook_registry, + &mut registry, + &clock, + scenario.ctx(), + ); + return_shared(deepbook_registry); + return_shared(registry); + return_shared(pool); + + scenario.next_tx(test_constants::user1()); + let mut pool = scenario.take_shared_by_id>(pool_id); + let registry = scenario.take_shared(); + let mut mm = scenario.take_shared>(); + let usdc_price = build_demo_usdc_price_info_object(&mut scenario, &clock); + let usdt_price = build_demo_usdt_price_info_object(&mut scenario, &clock); + + // Deposit enough USDC to sell min_size (no DEEP deposited - will use input fee). + // When using input fee, fee is deducted from the sell amount, so we need slightly more. + // Deposit min_size + 10% to cover the taker fee. + mm.deposit( + ®istry, + &usdc_price, + &usdt_price, + mint_coin(constants::min_size() + constants::min_size() / 10, scenario.ctx()), + &clock, + scenario.ctx(), + ); + destroy_2!(usdc_price, usdt_price); + + // Market sell exactly min_size USDC with input fee (pay_with_deep = false) + let base_pool = scenario.take_shared_by_id>(base_pool_id); + let quote_pool = scenario.take_shared_by_id>(quote_pool_id); + let order_info = test_helpers::place_market_order_v2_for_test( + &mut scenario, + ®istry, + &base_pool, + "e_pool, + &mut mm, + &mut pool, + 1, + constants::self_matching_allowed(), + constants::min_size(), + // exactly min_size + false, + // is_bid = false (sell) + false, + // pay_with_deep = false (use input fee) + &clock, + ); + return_shared(base_pool); + return_shared(quote_pool); + + // Verify order executed + assert!(order_info.status() == constants::filled()); + + return_shared_2!(mm, pool); + cleanup_margin_test(registry, _admin_cap, _maintainer_cap, clock, scenario); +} + +#[test] +/// Test that market buy order at min_size works with input fee (no DEEP in manager). +/// For bids, input fee is taken from quote, not base, so this should work. +fun test_market_buy_min_size_input_fee_ok() { + let ( + mut scenario, + clock, + _admin_cap, + _maintainer_cap, + base_pool_id, + quote_pool_id, + pool_id, + registry_id, + ) = setup_pool_proxy_test_env(); + + // Set up orderbook with asks to match against + setup_orderbook_liquidity_stablecoin(&mut scenario, pool_id, &clock); + + scenario.next_tx(test_constants::user1()); + let pool = scenario.take_shared_by_id>(pool_id); + let mut registry = scenario.take_shared(); + let deepbook_registry = scenario.take_shared_by_id(registry_id); + margin_manager::new( + &pool, + &deepbook_registry, + &mut registry, + &clock, + scenario.ctx(), + ); + return_shared(deepbook_registry); + return_shared(registry); + return_shared(pool); + + scenario.next_tx(test_constants::user1()); + let mut pool = scenario.take_shared_by_id>(pool_id); + let registry = scenario.take_shared(); + let mut mm = scenario.take_shared>(); + let usdc_price = build_demo_usdc_price_info_object(&mut scenario, &clock); + let usdt_price = build_demo_usdt_price_info_object(&mut scenario, &clock); + + // Deposit quote (USDT) to buy base (USDC) - no DEEP deposited (will use input fee) + // Need enough quote to cover min_size base + fees + // At $1.01 price, min_size base costs ~10100 quote + fees + mm.deposit( + ®istry, + &usdc_price, + &usdt_price, + mint_coin(100000, scenario.ctx()), // enough to cover min_size + fees + &clock, + scenario.ctx(), + ); + destroy_2!(usdc_price, usdt_price); + + // Market buy exactly min_size USDC with input fee (pay_with_deep = false) + // For bids, fee is taken from quote input, not base output + // So this should work regardless of the fix + let base_pool = scenario.take_shared_by_id>(base_pool_id); + let quote_pool = scenario.take_shared_by_id>(quote_pool_id); + let order_info = test_helpers::place_market_order_v2_for_test( + &mut scenario, + ®istry, + &base_pool, + "e_pool, + &mut mm, + &mut pool, + 1, + constants::self_matching_allowed(), + constants::min_size(), + // exactly min_size base to buy + true, + // is_bid = true (buy) + false, + // pay_with_deep = false (use input fee) + &clock, + ); + return_shared(base_pool); + return_shared(quote_pool); + + // Verify order executed + assert!(order_info.status() == constants::filled()); + + return_shared_2!(mm, pool); + cleanup_margin_test(registry, _admin_cap, _maintainer_cap, clock, scenario); +} + +// === V1 deprecation regression tests === +// +// Each v1 trading entry preserves its on-chain ABI for upgrade compatibility +// but aborts immediately. These tests assert the abort fires with the +// expected named error so a future refactor cannot silently restore a v1 +// path. + +#[test, expected_failure(abort_code = pool_proxy::EDeprecatedUseV2)] +fun place_limit_order_v1_aborts() { + let ( + mut scenario, + clock, + _admin_cap, + _maintainer_cap, + _b, + _q, + pool_id, + registry_id, + ) = setup_pool_proxy_test_env(); + + scenario.next_tx(test_constants::user1()); + let mut pool = scenario.take_shared_by_id>(pool_id); + let mut registry = scenario.take_shared(); + let deepbook_registry = scenario.take_shared_by_id(registry_id); + margin_manager::new( + &pool, + &deepbook_registry, + &mut registry, + &clock, + scenario.ctx(), + ); + return_shared(deepbook_registry); + + scenario.next_tx(test_constants::user1()); + let mut mm = scenario.take_shared>(); + + let _ = pool_proxy::place_limit_order( + ®istry, + &mut mm, + &mut pool, + 1, + constants::no_restriction(), + constants::self_matching_allowed(), + 1_000_000_000, + 100 * test_constants::usdc_multiplier(), + false, + false, + 2_000_000, + &clock, + scenario.ctx(), + ); + + abort 999 +} + +#[test, expected_failure(abort_code = pool_proxy::EDeprecatedUseV2)] +fun place_market_order_v1_aborts() { + let ( + mut scenario, + clock, + _admin_cap, + _maintainer_cap, + _b, + _q, + pool_id, + registry_id, + ) = setup_pool_proxy_test_env(); + + scenario.next_tx(test_constants::user1()); + let mut pool = scenario.take_shared_by_id>(pool_id); + let mut registry = scenario.take_shared(); + let deepbook_registry = scenario.take_shared_by_id(registry_id); + margin_manager::new( + &pool, + &deepbook_registry, + &mut registry, + &clock, + scenario.ctx(), + ); + return_shared(deepbook_registry); + + scenario.next_tx(test_constants::user1()); + let mut mm = scenario.take_shared>(); + + let _ = pool_proxy::place_market_order( + ®istry, + &mut mm, + &mut pool, + 1, + constants::self_matching_allowed(), + 100 * test_constants::usdc_multiplier(), + false, + false, + &clock, + scenario.ctx(), + ); + + abort 999 +} + +#[test, expected_failure(abort_code = pool_proxy::EDeprecatedUseV2)] +fun place_reduce_only_limit_order_v1_aborts() { + let ( + mut scenario, + clock, + _admin_cap, + _maintainer_cap, + base_pool_id, + _q, + pool_id, + registry_id, + ) = setup_pool_proxy_test_env(); + + scenario.next_tx(test_constants::user1()); + let mut pool = scenario.take_shared_by_id>(pool_id); + let mut registry = scenario.take_shared(); + let base_pool = scenario.take_shared_by_id>(base_pool_id); + let deepbook_registry = scenario.take_shared_by_id(registry_id); + margin_manager::new( + &pool, + &deepbook_registry, + &mut registry, + &clock, + scenario.ctx(), + ); + return_shared(deepbook_registry); + + scenario.next_tx(test_constants::user1()); + let mut mm = scenario.take_shared>(); + + let _ = pool_proxy::place_reduce_only_limit_order( + ®istry, + &mut mm, + &mut pool, + &base_pool, + 1, + constants::no_restriction(), + constants::self_matching_allowed(), + 1_000_000_000, + 100 * test_constants::usdc_multiplier(), + true, + false, + 2_000_000, + &clock, + scenario.ctx(), + ); + + abort 999 +} + +#[test, expected_failure(abort_code = pool_proxy::EDeprecatedUseV2)] +fun place_reduce_only_market_order_v1_aborts() { + let ( + mut scenario, + clock, + _admin_cap, + _maintainer_cap, + base_pool_id, + _q, + pool_id, + registry_id, + ) = setup_pool_proxy_test_env(); + + scenario.next_tx(test_constants::user1()); + let mut pool = scenario.take_shared_by_id>(pool_id); + let mut registry = scenario.take_shared(); + let base_pool = scenario.take_shared_by_id>(base_pool_id); + let deepbook_registry = scenario.take_shared_by_id(registry_id); + margin_manager::new( + &pool, + &deepbook_registry, + &mut registry, + &clock, + scenario.ctx(), + ); + return_shared(deepbook_registry); + + scenario.next_tx(test_constants::user1()); + let mut mm = scenario.take_shared>(); + + let _ = pool_proxy::place_reduce_only_market_order( + ®istry, + &mut mm, + &mut pool, + &base_pool, + 1, + constants::self_matching_allowed(), + 100 * test_constants::usdc_multiplier(), + true, + false, + &clock, + scenario.ctx(), + ); + + abort 999 +} + +// === Post-trade open-floor (min_open_risk_ratio) tests === +// +// Borrow USDC against USDT collateral right at `min_borrow_risk_ratio` (1.25, +// the borrow floor in test config), then sell the borrowed USDC against the +// standard orderbook bid (0.99). The 1% adverse fill drops `risk_ratio` to +// 1.2475 — below the borrow floor but above the default `min_open_risk_ratio` +// (midpoint of liquidation 1.10 and min_borrow 1.25 = 1.175, in test config). +// The opening trade is allowed, so a max-leverage open can absorb its own spread. +#[test] +fun place_limit_order_v2_borrow_at_floor_then_adverse_fill_ok() { + let ( + mut scenario, + clock, + _admin_cap, + _maintainer_cap, + base_pool_id, + quote_pool_id, + pool_id, + registry_id, + ) = setup_pool_proxy_test_env(); + setup_orderbook_liquidity_stablecoin(&mut scenario, pool_id, &clock); + + scenario.next_tx(test_constants::user1()); + let mut pool = scenario.take_shared_by_id>(pool_id); + let mut registry = scenario.take_shared(); + let mut base_pool = scenario.take_shared_by_id>(base_pool_id); + let quote_pool = scenario.take_shared_by_id>(quote_pool_id); + let deepbook_registry = scenario.take_shared_by_id(registry_id); + margin_manager::new( + &pool, + &deepbook_registry, + &mut registry, + &clock, + scenario.ctx(), + ); + return_shared(deepbook_registry); + + scenario.next_tx(test_constants::user1()); + let mut mm = scenario.take_shared>(); + let usdc_price = build_demo_usdc_price_info_object(&mut scenario, &clock); + let usdt_price = build_demo_usdt_price_info_object(&mut scenario, &clock); + + // Deposit 100 USDT collateral + DEEP for fees, borrow 400 USDC. + // Post-borrow: 100 USDT + 400 USDC = 500 USDC-equiv, debt 400 USDC, + // risk_ratio = 500/400 = 1.25 (exactly at borrow floor). DEEP isn't summed + // by `calculate_assets`, so it doesn't affect risk_ratio. + mm.deposit( + ®istry, + &usdc_price, + &usdt_price, + mint_coin(100 * test_constants::usdt_multiplier(), scenario.ctx()), + &clock, + scenario.ctx(), + ); + mm.deposit( + ®istry, + &usdc_price, + &usdt_price, + mint_coin(100 * test_constants::deep_multiplier(), scenario.ctx()), + &clock, + scenario.ctx(), + ); + mm.borrow_base( + ®istry, + &mut base_pool, + &usdc_price, + &usdt_price, + &pool, + 400 * test_constants::usdc_multiplier(), + &clock, + scenario.ctx(), + ); + destroy_2!(usdc_price, usdt_price); + + // Sell 100 USDC at 0.99 — fills against resting bid at 0.99 (pay_with_deep + // covers fees from the DEEP balance, not the trade output). + // Pre-trade asset: 400 USDC + 100 USDT = 500 USDC-equiv, debt 400. + // Post-trade: 300 USDC + 199 USDT = 499 USDC-equiv, debt 400, + // risk_ratio = 499/400 = 1.2475 < 1.25. Aborts. + let _ = test_helpers::place_limit_order_v2_for_test( + &mut scenario, + ®istry, + &base_pool, + "e_pool, + &mut mm, + &mut pool, + 1, + constants::no_restriction(), + constants::self_matching_allowed(), + 990_000_000, + 100 * test_constants::usdc_multiplier(), + false, // is_bid = false (sell) + true, // pay_with_deep + 2_000_000, + &clock, + ); + + // Post-trade risk_ratio = 499/400 = 1.2475: below the 1.25 borrow floor, + // above the 1.175 open floor, so the opening order is accepted. + let usdc_price = build_demo_usdc_price_info_object(&mut scenario, &clock); + let usdt_price = build_demo_usdt_price_info_object(&mut scenario, &clock); + let rr = mm.risk_ratio( + ®istry, + &usdc_price, + &usdt_price, + &pool, + &base_pool, + "e_pool, + &clock, + ); + assert_eq!(rr, 1_247_500_000); + assert!(rr < registry.min_borrow_risk_ratio(pool_id)); + assert!(rr >= registry.min_open_risk_ratio(pool_id)); + + return_shared_3!(mm, pool, quote_pool); + return_shared(base_pool); + destroy_2!(usdc_price, usdt_price); + cleanup_margin_test(registry, _admin_cap, _maintainer_cap, clock, scenario); +} + +// Same scenario, but the admin sets `min_open_risk_ratio` to the borrow floor +// (the strict opt-out), so the 1.2475 fill is now below the open floor and the +// opening order aborts. Exercises the override setter and the abort path. +#[test, expected_failure(abort_code = pool_proxy::EInsufficientRiskRatioAfterTrade)] +fun place_limit_order_v2_adverse_fill_aborts_with_strict_open_floor() { + let ( + mut scenario, + clock, + _admin_cap, + _maintainer_cap, + base_pool_id, + quote_pool_id, + pool_id, + registry_id, + ) = setup_pool_proxy_test_env(); + setup_orderbook_liquidity_stablecoin(&mut scenario, pool_id, &clock); + + scenario.next_tx(test_constants::user1()); + let mut pool = scenario.take_shared_by_id>(pool_id); + let mut registry = scenario.take_shared(); + let mut base_pool = scenario.take_shared_by_id>(base_pool_id); + let quote_pool = scenario.take_shared_by_id>(quote_pool_id); + let deepbook_registry = scenario.take_shared_by_id(registry_id); + margin_manager::new( + &pool, + &deepbook_registry, + &mut registry, + &clock, + scenario.ctx(), + ); + return_shared(deepbook_registry); + + // Raise the open floor to the borrow floor: no slippage headroom. + let borrow_floor = registry.min_borrow_risk_ratio(pool_id); + registry.set_min_open_risk_ratio( + &_admin_cap, + &pool, + borrow_floor, + &clock, + ); + + scenario.next_tx(test_constants::user1()); + let mut mm = scenario.take_shared>(); + let usdc_price = build_demo_usdc_price_info_object(&mut scenario, &clock); + let usdt_price = build_demo_usdt_price_info_object(&mut scenario, &clock); + + mm.deposit( + ®istry, + &usdc_price, + &usdt_price, + mint_coin(100 * test_constants::usdt_multiplier(), scenario.ctx()), + &clock, + scenario.ctx(), + ); + mm.deposit( + ®istry, + &usdc_price, + &usdt_price, + mint_coin(100 * test_constants::deep_multiplier(), scenario.ctx()), + &clock, + scenario.ctx(), + ); + mm.borrow_base( + ®istry, + &mut base_pool, + &usdc_price, + &usdt_price, + &pool, + 400 * test_constants::usdc_multiplier(), + &clock, + scenario.ctx(), + ); + destroy_2!(usdc_price, usdt_price); + + let _ = test_helpers::place_limit_order_v2_for_test( + &mut scenario, + ®istry, + &base_pool, + "e_pool, + &mut mm, + &mut pool, + 1, + constants::no_restriction(), + constants::self_matching_allowed(), + 990_000_000, + 100 * test_constants::usdc_multiplier(), + false, // is_bid = false (sell) + true, // pay_with_deep + 2_000_000, + &clock, + ); + + abort 999 +} + +// === Max Order TTL Tests === +// +// `clamp_expire_timestamp` bounds margin limit orders to at most +// `now + max_order_ttl_ms`. Default is 3 days; admin can tune in [1h, 30d]. +// Test clock is set to 1_000_000 ms in the test scaffolding (see +// `test_helpers::setup_margin_registry`). + +#[test, expected_failure(abort_code = margin_registry::EInvalidOrderTtl)] +fun test_set_max_order_ttl_too_low() { + let (mut scenario, clock, admin_cap, maintainer_cap) = setup_margin_registry(); + + let _base_pool_id = create_margin_pool( + &mut scenario, + &maintainer_cap, + default_protocol_config(), + &clock, + ); + let _quote_pool_id = create_margin_pool( + &mut scenario, + &maintainer_cap, + default_protocol_config(), + &clock, + ); + + let (pool_id, _registry_id) = create_pool_for_testing(&mut scenario); + + scenario.next_tx(test_constants::admin()); + let mut registry = scenario.take_shared(); + enable_deepbook_margin_on_pool( + pool_id, + &mut registry, + &admin_cap, + &clock, + &mut scenario, + ); + return_shared(registry); + + scenario.next_tx(test_constants::admin()); + let pool = scenario.take_shared_by_id>(pool_id); + let mut registry = scenario.take_shared(); + + registry.set_max_order_ttl( + &admin_cap, + &pool, + 30 * 60 * 1000, // 30 minutes — below 1h minimum + &clock, + ); + + abort 0 +} + +#[test, expected_failure(abort_code = margin_registry::EInvalidOrderTtl)] +fun test_set_max_order_ttl_too_high() { + let (mut scenario, clock, admin_cap, maintainer_cap) = setup_margin_registry(); + + let _base_pool_id = create_margin_pool( + &mut scenario, + &maintainer_cap, + default_protocol_config(), + &clock, + ); + let _quote_pool_id = create_margin_pool( + &mut scenario, + &maintainer_cap, + default_protocol_config(), + &clock, + ); + + let (pool_id, _registry_id) = create_pool_for_testing(&mut scenario); + + scenario.next_tx(test_constants::admin()); + let mut registry = scenario.take_shared(); + enable_deepbook_margin_on_pool( + pool_id, + &mut registry, + &admin_cap, + &clock, + &mut scenario, + ); + return_shared(registry); + + scenario.next_tx(test_constants::admin()); + let pool = scenario.take_shared_by_id>(pool_id); + let mut registry = scenario.take_shared(); + + registry.set_max_order_ttl( + &admin_cap, + &pool, + 31 * 24 * 60 * 60 * 1000, // 31 days — above 30d maximum + &clock, + ); + + abort 0 +} + +#[test] +fun set_max_order_ttl_within_bounds_ok() { + let (mut scenario, clock, admin_cap, maintainer_cap) = setup_margin_registry(); + + let _base_pool_id = create_margin_pool( + &mut scenario, + &maintainer_cap, + default_protocol_config(), + &clock, + ); + let _quote_pool_id = create_margin_pool( + &mut scenario, + &maintainer_cap, + default_protocol_config(), + &clock, + ); + + let (pool_id, _registry_id) = create_pool_for_testing(&mut scenario); + + scenario.next_tx(test_constants::admin()); + let mut registry = scenario.take_shared(); + enable_deepbook_margin_on_pool( + pool_id, + &mut registry, + &admin_cap, + &clock, + &mut scenario, + ); + return_shared(registry); + + scenario.next_tx(test_constants::admin()); + let pool = scenario.take_shared_by_id>(pool_id); + let mut registry = scenario.take_shared(); + + let one_day_ms = 24 * 60 * 60 * 1000; + registry.set_max_order_ttl(&admin_cap, &pool, one_day_ms, &clock); + + assert!(registry.max_order_ttl_ms(pool_id) == one_day_ms); + + // Update again to exercise the mutable path, not just first-insert. + let two_days_ms = 2 * one_day_ms; + registry.set_max_order_ttl(&admin_cap, &pool, two_days_ms, &clock); + assert!(registry.max_order_ttl_ms(pool_id) == two_days_ms); + + return_shared_2!(registry, pool); + destroy(admin_cap); + destroy(maintainer_cap); + destroy(clock); + scenario.end(); +} + +#[test] +fun min_open_risk_ratio_defaults_to_midpoint() { + let (mut scenario, clock, admin_cap, maintainer_cap) = setup_margin_registry(); + let _base_pool_id = create_margin_pool( + &mut scenario, + &maintainer_cap, + default_protocol_config(), + &clock, + ); + let _quote_pool_id = create_margin_pool( + &mut scenario, + &maintainer_cap, + default_protocol_config(), + &clock, + ); + let (pool_id, _registry_id) = create_pool_for_testing(&mut scenario); + + scenario.next_tx(test_constants::admin()); + let mut registry = scenario.take_shared(); + enable_deepbook_margin_on_pool( + pool_id, + &mut registry, + &admin_cap, + &clock, + &mut scenario, + ); + return_shared(registry); + + scenario.next_tx(test_constants::admin()); + let registry = scenario.take_shared(); + // Default = midpoint of liquidation (1.10) and min_borrow (1.25) = 1.175. + assert_eq!(registry.min_open_risk_ratio(pool_id), 1_175_000_000); + + return_shared(registry); + destroy(admin_cap); + destroy(maintainer_cap); + destroy(clock); + scenario.end(); +} + +#[test] +fun set_min_open_risk_ratio_within_band_ok() { + let (mut scenario, clock, admin_cap, maintainer_cap) = setup_margin_registry(); + let _base_pool_id = create_margin_pool( + &mut scenario, + &maintainer_cap, + default_protocol_config(), + &clock, + ); + let _quote_pool_id = create_margin_pool( + &mut scenario, + &maintainer_cap, + default_protocol_config(), + &clock, + ); + let (pool_id, _registry_id) = create_pool_for_testing(&mut scenario); + + scenario.next_tx(test_constants::admin()); + let mut registry = scenario.take_shared(); + enable_deepbook_margin_on_pool( + pool_id, + &mut registry, + &admin_cap, + &clock, + &mut scenario, + ); + return_shared(registry); + + scenario.next_tx(test_constants::admin()); + let pool = scenario.take_shared_by_id>(pool_id); + let mut registry = scenario.take_shared(); + + // 1.20 is within (liquidation 1.10, min_borrow 1.25]. + registry.set_min_open_risk_ratio(&admin_cap, &pool, 1_200_000_000, &clock); + assert_eq!(registry.min_open_risk_ratio(pool_id), 1_200_000_000); + + // Update again to exercise the mutable path, not just first-insert. + registry.set_min_open_risk_ratio(&admin_cap, &pool, 1_150_000_000, &clock); + assert_eq!(registry.min_open_risk_ratio(pool_id), 1_150_000_000); + + return_shared_2!(registry, pool); + destroy(admin_cap); + destroy(maintainer_cap); + destroy(clock); + scenario.end(); +} + +#[test, expected_failure(abort_code = margin_registry::EInvalidRiskParam)] +fun set_min_open_risk_ratio_too_low_aborts() { + let (mut scenario, clock, admin_cap, maintainer_cap) = setup_margin_registry(); + let _base_pool_id = create_margin_pool( + &mut scenario, + &maintainer_cap, + default_protocol_config(), + &clock, + ); + let _quote_pool_id = create_margin_pool( + &mut scenario, + &maintainer_cap, + default_protocol_config(), &clock, ); - return_shared(base_pool); - return_shared(quote_pool); + let (pool_id, _registry_id) = create_pool_for_testing(&mut scenario); - abort + scenario.next_tx(test_constants::admin()); + let mut registry = scenario.take_shared(); + enable_deepbook_margin_on_pool( + pool_id, + &mut registry, + &admin_cap, + &clock, + &mut scenario, + ); + return_shared(registry); + + scenario.next_tx(test_constants::admin()); + let pool = scenario.take_shared_by_id>(pool_id); + let mut registry = scenario.take_shared(); + + // Equal to liquidation (1.10) is not strictly above it: out of band. + registry.set_min_open_risk_ratio(&admin_cap, &pool, 1_100_000_000, &clock); + + abort 999 } -#[test, expected_failure(abort_code = margin_registry::EPriceNotInitialized)] -fun test_limit_order_price_not_initialized() { - // Test that placing an order fails if current price has never been updated +#[test, expected_failure(abort_code = margin_registry::EInvalidRiskParam)] +fun set_min_open_risk_ratio_too_high_aborts() { let (mut scenario, clock, admin_cap, maintainer_cap) = setup_margin_registry(); - - let base_pool_id = create_margin_pool( + let _base_pool_id = create_margin_pool( &mut scenario, &maintainer_cap, default_protocol_config(), &clock, ); - let quote_pool_id = create_margin_pool( + let _quote_pool_id = create_margin_pool( &mut scenario, &maintainer_cap, default_protocol_config(), &clock, ); + let (pool_id, _registry_id) = create_pool_for_testing(&mut scenario); - let (base_pool_cap, quote_pool_cap) = get_margin_pool_caps(&mut scenario, base_pool_id); - - let (pool_id, registry_id) = create_pool_for_testing(&mut scenario); - - // Enable pool but DON'T initialize price (skip initialize_pool_price call) scenario.next_tx(test_constants::admin()); let mut registry = scenario.take_shared(); enable_deepbook_margin_on_pool( @@ -4434,98 +6945,156 @@ fun test_limit_order_price_not_initialized() { ); return_shared(registry); - // Setup liquidity for margin pools (so margin manager can be created) scenario.next_tx(test_constants::admin()); - let mut base_pool = scenario.take_shared_by_id>(base_pool_id); - let mut quote_pool = scenario.take_shared_by_id>(quote_pool_id); - let registry = scenario.take_shared(); - let supplier_cap = margin_pool::mint_supplier_cap(®istry, &clock, scenario.ctx()); + let pool = scenario.take_shared_by_id>(pool_id); + let mut registry = scenario.take_shared(); - base_pool.supply( - ®istry, - &supplier_cap, - mint_coin(1_000_000 * test_constants::usdc_multiplier(), scenario.ctx()), - option::none(), + // Above the borrow floor (1.25): out of band. + registry.set_min_open_risk_ratio(&admin_cap, &pool, 1_250_000_001, &clock); + + abort 999 +} + +#[test] +fun max_order_ttl_lazy_default() { + // Pools that have never had `set_max_order_ttl` called still get the + // 3-day default (lazy-default read path). + let (mut scenario, clock, admin_cap, maintainer_cap) = setup_margin_registry(); + + let _base_pool_id = create_margin_pool( + &mut scenario, + &maintainer_cap, + default_protocol_config(), &clock, ); - quote_pool.supply( - ®istry, - &supplier_cap, - mint_coin(1_000_000 * test_constants::usdt_multiplier(), scenario.ctx()), - option::none(), + let _quote_pool_id = create_margin_pool( + &mut scenario, + &maintainer_cap, + default_protocol_config(), &clock, ); - base_pool.enable_deepbook_pool_for_loan(®istry, pool_id, &base_pool_cap, &clock); - quote_pool.enable_deepbook_pool_for_loan(®istry, pool_id, "e_pool_cap, &clock); + let (pool_id, _registry_id) = create_pool_for_testing(&mut scenario); + + scenario.next_tx(test_constants::admin()); + let registry = scenario.take_shared(); + + assert!(registry.max_order_ttl_ms(pool_id) == margin_constants::default_max_order_ttl_ms()); + // 3 days = 259_200_000 ms (sanity-check the literal hasn't drifted). + assert!(registry.max_order_ttl_ms(pool_id) == 3 * 24 * 60 * 60 * 1000); - return_shared_2!(base_pool, quote_pool); return_shared(registry); - destroy_2!(base_pool_cap, quote_pool_cap); - destroy(supplier_cap); + destroy(admin_cap); + destroy(maintainer_cap); + destroy(clock); + scenario.end(); +} - scenario.next_tx(test_constants::user1()); - let mut pool = scenario.take_shared_by_id>(pool_id); - let mut registry = scenario.take_shared(); - let deepbook_registry = scenario.take_shared_by_id(registry_id); - margin_manager::new( - &pool, - &deepbook_registry, - &mut registry, +#[test] +fun clamp_expire_timestamp_passthrough_and_clamp() { + // Direct exercise of the clamp helper: small inputs pass, large inputs cap. + let (mut scenario, clock, admin_cap, maintainer_cap) = setup_margin_registry(); + + let _base_pool_id = create_margin_pool( + &mut scenario, + &maintainer_cap, + default_protocol_config(), + &clock, + ); + let _quote_pool_id = create_margin_pool( + &mut scenario, + &maintainer_cap, + default_protocol_config(), &clock, - scenario.ctx(), ); - return_shared(deepbook_registry); - scenario.next_tx(test_constants::user1()); - let mut mm = scenario.take_shared>(); - let usdc_price = build_demo_usdc_price_info_object(&mut scenario, &clock); - let usdt_price = build_demo_usdt_price_info_object(&mut scenario, &clock); + let (pool_id, _registry_id) = create_pool_for_testing(&mut scenario); - mm.deposit( - ®istry, - &usdc_price, - &usdt_price, - mint_coin(10000 * test_constants::usdc_multiplier(), scenario.ctx()), + scenario.next_tx(test_constants::admin()); + let registry = scenario.take_shared(); + + // Test clock is at 1_000_000 ms. Default TTL is 3 days = 259_200_000 ms. + // Cap = 1_000_000 + 259_200_000 = 260_200_000. + let now = 1_000_000u64; + let cap = now + margin_constants::default_max_order_ttl_ms(); + + // Small expire_timestamp passes through unchanged. + assert!(registry.clamp_expire_timestamp(pool_id, 2_000_000, &clock) == 2_000_000); + // Boundary: exact cap passes through. + assert!(registry.clamp_expire_timestamp(pool_id, cap, &clock) == cap); + // One past cap clamps. + assert!(registry.clamp_expire_timestamp(pool_id, cap + 1, &clock) == cap); + // u64::MAX-ish clamps. + assert!(registry.clamp_expire_timestamp(pool_id, 1_000_000_000_000_000, &clock) == cap); + // expire_timestamp = 0 passes through. + assert!(registry.clamp_expire_timestamp(pool_id, 0, &clock) == 0); + + return_shared(registry); + destroy(admin_cap); + destroy(maintainer_cap); + destroy(clock); + scenario.end(); +} + +#[test] +fun clamp_uses_admin_set_ttl_after_update() { + // After admin tightens per-pool TTL, the clamp uses the new value. + let (mut scenario, clock, admin_cap, maintainer_cap) = setup_margin_registry(); + + let _base_pool_id = create_margin_pool( + &mut scenario, + &maintainer_cap, + default_protocol_config(), &clock, - scenario.ctx(), ); - destroy_2!(usdc_price, usdt_price); - - // Try to place limit order without initializing price - // Should fail with EPriceNotInitialized - let base_pool = scenario.take_shared_by_id>(base_pool_id); - let quote_pool = scenario.take_shared_by_id>(quote_pool_id); - test_helpers::place_limit_order_v2_for_test( + let _quote_pool_id = create_margin_pool( &mut scenario, - ®istry, - &base_pool, - "e_pool, - &mut mm, - &mut pool, - 1, - constants::no_restriction(), - constants::self_matching_allowed(), - 1_000_000_000, - // $1.00 - 100 * test_constants::usdc_multiplier(), - false, - false, - 18446744073709551615, + &maintainer_cap, + default_protocol_config(), &clock, ); - return_shared(base_pool); - return_shared(quote_pool); - abort + let (pool_id, _registry_id) = create_pool_for_testing(&mut scenario); + + scenario.next_tx(test_constants::admin()); + let mut registry = scenario.take_shared(); + enable_deepbook_margin_on_pool( + pool_id, + &mut registry, + &admin_cap, + &clock, + &mut scenario, + ); + return_shared(registry); + + scenario.next_tx(test_constants::admin()); + let pool = scenario.take_shared_by_id>(pool_id); + let mut registry = scenario.take_shared(); + + // Tighten TTL to 1 hour (the minimum). + let one_hour_ms = 60 * 60 * 1000; + registry.set_max_order_ttl(&admin_cap, &pool, one_hour_ms, &clock); + + // Clock = 1_000_000 ms, TTL = 3_600_000 ms → cap = 4_600_000. + let now = 1_000_000u64; + let expected_cap = now + one_hour_ms; + assert!(registry.clamp_expire_timestamp(pool_id, 999_999_999, &clock) == expected_cap); + assert!(registry.clamp_expire_timestamp(pool_id, 2_000_000, &clock) == 2_000_000); + + return_shared_2!(registry, pool); + destroy(admin_cap); + destroy(maintainer_cap); + destroy(clock); + scenario.end(); } -#[test, expected_failure(abort_code = margin_registry::EPriceUpdateRequired)] -fun test_limit_order_price_stale() { - // Test that placing an order fails if current price is stale +#[test] +fun place_limit_order_clamps_expire_timestamp() { + // End-to-end: a huge expire_timestamp ends up at the 3-day cap on the + // resting order returned by the proxy entry. let ( mut scenario, - mut clock, + clock, _admin_cap, _maintainer_cap, base_pool_id, @@ -4551,7 +7120,6 @@ fun test_limit_order_price_stale() { let mut mm = scenario.take_shared>(); let usdc_price = build_demo_usdc_price_info_object(&mut scenario, &clock); let usdt_price = build_demo_usdt_price_info_object(&mut scenario, &clock); - mm.deposit( ®istry, &usdc_price, @@ -4562,14 +7130,11 @@ fun test_limit_order_price_stale() { ); destroy_2!(usdc_price, usdt_price); - // Advance clock past max_price_age (default is 5 minutes, advance 6 minutes) - clock.increment_for_testing(6 * 60 * 1000); - - // Try to place limit order with stale price - // Should fail with EPriceUpdateRequired let base_pool = scenario.take_shared_by_id>(base_pool_id); let quote_pool = scenario.take_shared_by_id>(quote_pool_id); - test_helpers::place_limit_order_v2_for_test( + + let huge_expire_ts = 1_000_000_000_000_000u64; + let order_info = test_helpers::place_limit_order_v2_for_test( &mut scenario, ®istry, &base_pool, @@ -4580,23 +7145,29 @@ fun test_limit_order_price_stale() { constants::no_restriction(), constants::self_matching_allowed(), 1_000_000_000, - // $1.00 100 * test_constants::usdc_multiplier(), + false, // is_bid (sell) false, - false, - 18446744073709551615, + huge_expire_ts, &clock, ); + + let now = 1_000_000u64; + let expected = now + margin_constants::default_max_order_ttl_ms(); + assert!(order_info.expire_timestamp() == expected); + return_shared(base_pool); return_shared(quote_pool); + destroy(order_info); - abort + return_shared_2!(mm, pool); + cleanup_margin_test(registry, _admin_cap, _maintainer_cap, clock, scenario); } -#[test] -/// Test that market sell order at min_size works with input fee (no DEEP in manager). -/// This verifies the fix for get_quote_quantity_out_input_fee min_size issue. -fun test_market_sell_min_size_input_fee_ok() { +#[test] +fun place_limit_order_v2_no_debt_at_oracle_price_ok() { + // Sanity: with no debt the post-trade invariant short-circuits, so + // `place_limit_order_v2` succeeds normally. let ( mut scenario, clock, @@ -4608,11 +7179,8 @@ fun test_market_sell_min_size_input_fee_ok() { registry_id, ) = setup_pool_proxy_test_env(); - // Set up orderbook with bids to match against - setup_orderbook_liquidity_stablecoin(&mut scenario, pool_id, &clock); - scenario.next_tx(test_constants::user1()); - let pool = scenario.take_shared_by_id>(pool_id); + let mut pool = scenario.take_shared_by_id>(pool_id); let mut registry = scenario.take_shared(); let deepbook_registry = scenario.take_shared_by_id(registry_id); margin_manager::new( @@ -4623,33 +7191,24 @@ fun test_market_sell_min_size_input_fee_ok() { scenario.ctx(), ); return_shared(deepbook_registry); - return_shared(registry); - return_shared(pool); scenario.next_tx(test_constants::user1()); - let mut pool = scenario.take_shared_by_id>(pool_id); - let registry = scenario.take_shared(); let mut mm = scenario.take_shared>(); let usdc_price = build_demo_usdc_price_info_object(&mut scenario, &clock); let usdt_price = build_demo_usdt_price_info_object(&mut scenario, &clock); - - // Deposit enough USDC to sell min_size (no DEEP deposited - will use input fee). - // When using input fee, fee is deducted from the sell amount, so we need slightly more. - // Deposit min_size + 10% to cover the taker fee. mm.deposit( ®istry, &usdc_price, &usdt_price, - mint_coin(constants::min_size() + constants::min_size() / 10, scenario.ctx()), + mint_coin(1000 * test_constants::usdc_multiplier(), scenario.ctx()), &clock, scenario.ctx(), ); destroy_2!(usdc_price, usdt_price); - // Market sell exactly min_size USDC with input fee (pay_with_deep = false) let base_pool = scenario.take_shared_by_id>(base_pool_id); let quote_pool = scenario.take_shared_by_id>(quote_pool_id); - let order_info = test_helpers::place_market_order_v2_for_test( + let order_info = test_helpers::place_limit_order_v2_for_test( &mut scenario, ®istry, &base_pool, @@ -4657,29 +7216,32 @@ fun test_market_sell_min_size_input_fee_ok() { &mut mm, &mut pool, 1, + constants::no_restriction(), constants::self_matching_allowed(), - constants::min_size(), - // exactly min_size + 1_000_000_000, + 100 * test_constants::usdc_multiplier(), false, - // is_bid = false (sell) false, - // pay_with_deep = false (use input fee) + 2_000_000, &clock, ); return_shared(base_pool); return_shared(quote_pool); - - // Verify order executed - assert!(order_info.status() == constants::filled()); + destroy(order_info); return_shared_2!(mm, pool); cleanup_margin_test(registry, _admin_cap, _maintainer_cap, clock, scenario); } +// #3: a manager with already-borrowed *free* funds, sitting in the +// [min_open, min_borrow) band, can still place a normal order — the post-trade +// floor is `min_open`, not `min_borrow`. Deposit 100 USDC, borrow 400 USDT +// (ratio 1.25 at $1), then use the free USDT to market-buy 100 USDC. The buy +// pays the $1.01 ask, landing risk_ratio at 1.2475 — below the borrow floor but +// above the open floor — so it is accepted. Under the old min_borrow gate this +// aborted (the #3 regression). #[test] -/// Test that market buy order at min_size works with input fee (no DEEP in manager). -/// For bids, input fee is taken from quote, not base, so this should work. -fun test_market_buy_min_size_input_fee_ok() { +fun place_market_order_v2_with_free_borrowed_funds_in_band_ok() { let ( mut scenario, clock, @@ -4690,13 +7252,13 @@ fun test_market_buy_min_size_input_fee_ok() { pool_id, registry_id, ) = setup_pool_proxy_test_env(); - - // Set up orderbook with asks to match against setup_orderbook_liquidity_stablecoin(&mut scenario, pool_id, &clock); scenario.next_tx(test_constants::user1()); - let pool = scenario.take_shared_by_id>(pool_id); + let mut pool = scenario.take_shared_by_id>(pool_id); let mut registry = scenario.take_shared(); + let base_pool = scenario.take_shared_by_id>(base_pool_id); + let mut quote_pool = scenario.take_shared_by_id>(quote_pool_id); let deepbook_registry = scenario.take_shared_by_id(registry_id); margin_manager::new( &pool, @@ -4706,34 +7268,42 @@ fun test_market_buy_min_size_input_fee_ok() { scenario.ctx(), ); return_shared(deepbook_registry); - return_shared(registry); - return_shared(pool); scenario.next_tx(test_constants::user1()); - let mut pool = scenario.take_shared_by_id>(pool_id); - let registry = scenario.take_shared(); let mut mm = scenario.take_shared>(); let usdc_price = build_demo_usdc_price_info_object(&mut scenario, &clock); let usdt_price = build_demo_usdt_price_info_object(&mut scenario, &clock); - // Deposit quote (USDT) to buy base (USDC) - no DEEP deposited (will use input fee) - // Need enough quote to cover min_size base + fees - // At $1.01 price, min_size base costs ~10100 quote + fees - mm.deposit( + mm.deposit( ®istry, &usdc_price, &usdt_price, - mint_coin(100000, scenario.ctx()), // enough to cover min_size + fees + mint_coin(100 * test_constants::usdc_multiplier(), scenario.ctx()), + &clock, + scenario.ctx(), + ); + mm.deposit( + ®istry, + &usdc_price, + &usdt_price, + mint_coin(100 * test_constants::deep_multiplier(), scenario.ctx()), + &clock, + scenario.ctx(), + ); + // Borrow 400 USDT: ratio = (100 + 400) / 400 = 1.25 at $1. + mm.borrow_quote( + ®istry, + &mut quote_pool, + &usdc_price, + &usdt_price, + &pool, + 400 * test_constants::usdt_multiplier(), &clock, scenario.ctx(), ); destroy_2!(usdc_price, usdt_price); - // Market buy exactly min_size USDC with input fee (pay_with_deep = false) - // For bids, fee is taken from quote input, not base output - // So this should work regardless of the fix - let base_pool = scenario.take_shared_by_id>(base_pool_id); - let quote_pool = scenario.take_shared_by_id>(quote_pool_id); + // Use the free borrowed USDT to market-buy 100 USDC — no new borrow. let order_info = test_helpers::place_market_order_v2_for_test( &mut scenario, ®istry, @@ -4743,47 +7313,60 @@ fun test_market_buy_min_size_input_fee_ok() { &mut pool, 1, constants::self_matching_allowed(), - constants::min_size(), - // exactly min_size base to buy - true, - // is_bid = true (buy) - false, - // pay_with_deep = false (use input fee) + 100 * test_constants::usdc_multiplier(), + true, // is_bid = true (buy) + true, // pay_with_deep &clock, ); - return_shared(base_pool); - return_shared(quote_pool); + destroy(order_info); - // Verify order executed - assert!(order_info.status() == constants::filled()); + // Post-trade risk_ratio = (200 + 299) / 400 = 1.2475: in the band, accepted. + let usdc_price = build_demo_usdc_price_info_object(&mut scenario, &clock); + let usdt_price = build_demo_usdt_price_info_object(&mut scenario, &clock); + let rr = mm.risk_ratio( + ®istry, + &usdc_price, + &usdt_price, + &pool, + &base_pool, + "e_pool, + &clock, + ); + assert_eq!(rr, 1_247_500_000); + assert!(rr < registry.min_borrow_risk_ratio(pool_id)); + assert!(rr >= registry.min_open_risk_ratio(pool_id)); - return_shared_2!(mm, pool); + destroy_2!(usdc_price, usdt_price); + return_shared_3!(mm, pool, quote_pool); + return_shared(base_pool); cleanup_margin_test(registry, _admin_cap, _maintainer_cap, clock, scenario); } -// === V1 deprecation regression tests === -// -// Each v1 trading entry preserves its on-chain ABI for upgrade compatibility -// but aborts immediately. These tests assert the abort fires with the -// expected named error so a future refactor cannot silently restore a v1 -// path. +// === place_market_order_and_repay_loan (non-reduce-only close) === -#[test, expected_failure(abort_code = pool_proxy::EDeprecatedUseV2)] -fun place_limit_order_v1_aborts() { +#[test] +fun place_market_order_and_repay_loan_fully_closes_long() { + // A long fully closes via the non-reduce-only and-repay: sell base, repay the + // quote loan, debt -> 0 (risk_ratio MAX), which clears the min_open gate. + // Deposit 10000 USDC, borrow 500 USDT, withdraw 300 USDT; sell 600 USDC, + // whose proceeds repay the entire 500 USDT debt. let ( mut scenario, clock, _admin_cap, _maintainer_cap, - _b, - _q, + base_pool_id, + quote_pool_id, pool_id, registry_id, ) = setup_pool_proxy_test_env(); + setup_orderbook_liquidity_stablecoin(&mut scenario, pool_id, &clock); scenario.next_tx(test_constants::user1()); let mut pool = scenario.take_shared_by_id>(pool_id); let mut registry = scenario.take_shared(); + let mut base_pool = scenario.take_shared_by_id>(base_pool_id); + let mut quote_pool = scenario.take_shared_by_id>(quote_pool_id); let deepbook_registry = scenario.take_shared_by_id(registry_id); margin_manager::new( &pool, @@ -4796,42 +7379,89 @@ fun place_limit_order_v1_aborts() { scenario.next_tx(test_constants::user1()); let mut mm = scenario.take_shared>(); + let usdc_price = build_demo_usdc_price_info_object(&mut scenario, &clock); + let usdt_price = build_demo_usdt_price_info_object(&mut scenario, &clock); - let _ = pool_proxy::place_limit_order( + mm.deposit( + ®istry, + &usdc_price, + &usdt_price, + mint_coin(10000 * test_constants::usdc_multiplier(), scenario.ctx()), + &clock, + scenario.ctx(), + ); + mm.borrow_quote( + ®istry, + &mut quote_pool, + &usdc_price, + &usdt_price, + &pool, + 500 * test_constants::usdt_multiplier(), + &clock, + scenario.ctx(), + ); + let withdrawn = mm.withdraw( + ®istry, + &base_pool, + "e_pool, + &usdc_price, + &usdt_price, + &pool, + 300 * test_constants::usdt_multiplier(), + &clock, + scenario.ctx(), + ); + destroy(withdrawn); + destroy_2!(usdc_price, usdt_price); + + let order_info = test_helpers::place_market_order_and_repay_loan_for_test( + &mut scenario, ®istry, &mut mm, &mut pool, + &mut base_pool, + &mut quote_pool, 1, - constants::no_restriction(), constants::self_matching_allowed(), - 1_000_000_000, - 100 * test_constants::usdc_multiplier(), - false, + 600 * test_constants::usdc_multiplier(), + false, // is_bid = false (sell base to close the long) false, - 2_000_000, &clock, - scenario.ctx(), ); + destroy(order_info); - abort 999 + // Loan fully repaid -> position closed. + assert_eq!(mm.borrowed_quote_shares(), 0); + + return_shared_3!(mm, pool, quote_pool); + return_shared(base_pool); + cleanup_margin_test(registry, _admin_cap, _maintainer_cap, clock, scenario); } -#[test, expected_failure(abort_code = pool_proxy::EDeprecatedUseV2)] -fun place_market_order_v1_aborts() { +#[test] +fun place_market_order_and_repay_loan_overbuys_short_past_debt() { + // A short (USDC debt, holding USDT) closes with a bid that buys well *past* + // the debt — there is no reduce-only quantity cap, so the overshoot is + // allowed. Owe 100 USDC, buy 150, repay 100, debt -> 0 with a 50 USDC surplus + // that lands as the manager's own holding (not an abort, not lost). The + // reduce-only bid (capped at the net short rounded to a lot) would reject 150. let ( mut scenario, clock, _admin_cap, _maintainer_cap, - _b, - _q, + base_pool_id, + quote_pool_id, pool_id, registry_id, ) = setup_pool_proxy_test_env(); + setup_orderbook_liquidity_stablecoin(&mut scenario, pool_id, &clock); scenario.next_tx(test_constants::user1()); let mut pool = scenario.take_shared_by_id>(pool_id); let mut registry = scenario.take_shared(); + let mut base_pool = scenario.take_shared_by_id>(base_pool_id); + let mut quote_pool = scenario.take_shared_by_id>(quote_pool_id); let deepbook_registry = scenario.take_shared_by_id(registry_id); margin_manager::new( &pool, @@ -4844,40 +7474,101 @@ fun place_market_order_v1_aborts() { scenario.next_tx(test_constants::user1()); let mut mm = scenario.take_shared>(); + let usdc_price = build_demo_usdc_price_info_object(&mut scenario, &clock); + let usdt_price = build_demo_usdt_price_info_object(&mut scenario, &clock); - let _ = pool_proxy::place_market_order( + mm.deposit( + ®istry, + &usdc_price, + &usdt_price, + mint_coin(250 * test_constants::usdt_multiplier(), scenario.ctx()), + &clock, + scenario.ctx(), + ); + mm.borrow_base( + ®istry, + &mut base_pool, + &usdc_price, + &usdt_price, + &pool, + 100 * test_constants::usdc_multiplier(), + &clock, + scenario.ctx(), + ); + // Withdraw the borrowed USDC so the manager is a real short: owe 100 USDC, + // hold 250 USDT (ratio 2.5, above min_withdraw 2.0). + let withdrawn = mm.withdraw( + ®istry, + &base_pool, + "e_pool, + &usdc_price, + &usdt_price, + &pool, + 100 * test_constants::usdc_multiplier(), + &clock, + scenario.ctx(), + ); + destroy(withdrawn); + destroy_2!(usdc_price, usdt_price); + + let order_info = test_helpers::place_market_order_and_repay_loan_for_test( + &mut scenario, ®istry, &mut mm, &mut pool, + &mut base_pool, + &mut quote_pool, 1, constants::self_matching_allowed(), - 100 * test_constants::usdc_multiplier(), - false, + 150 * test_constants::usdc_multiplier(), // buy 150 to cover a 100 debt + true, // is_bid = true (buy base to cover the short) false, &clock, - scenario.ctx(), ); + destroy(order_info); - abort 999 + // Overbought well past the debt and fully closed; the 50 USDC overshoot is + // retained as the manager's own base balance, not lost or aborted. + assert_eq!(mm.borrowed_base_shares(), 0); + assert_eq!(mm.base_balance(), 50 * test_constants::usdc_multiplier()); + + return_shared_3!(mm, pool, base_pool); + return_shared(quote_pool); + cleanup_margin_test(registry, _admin_cap, _maintainer_cap, clock, scenario); } -#[test, expected_failure(abort_code = pool_proxy::EDeprecatedUseV2)] -fun place_reduce_only_limit_order_v1_aborts() { +#[test, expected_failure(abort_code = pool_proxy::ERiskRatioMustNotWorsen)] +fun place_market_order_and_repay_loan_worsening_aborts() { + // The monotonic gate fires on this entry too (the after-repay check, distinct + // from the v2 swap-only check). A long that spends almost all its quote buying + // base at a +4% premium increases exposure with too little leftover quote to + // repay, so the net ratio drops -> abort. Confirms this entry cannot be used to + // worsen a position. let ( mut scenario, clock, _admin_cap, _maintainer_cap, base_pool_id, - _q, + quote_pool_id, pool_id, registry_id, ) = setup_pool_proxy_test_env(); + // Ask at $1.04 (within the 5% band) for the manager's bid to overpay into. + setup_orderbook_liquidity_at_prices( + &mut scenario, + pool_id, + 950_000_000, + 1_040_000_000, + &clock, + ); + scenario.next_tx(test_constants::user1()); let mut pool = scenario.take_shared_by_id>(pool_id); let mut registry = scenario.take_shared(); - let base_pool = scenario.take_shared_by_id>(base_pool_id); + let mut base_pool = scenario.take_shared_by_id>(base_pool_id); + let mut quote_pool = scenario.take_shared_by_id>(quote_pool_id); let deepbook_registry = scenario.take_shared_by_id(registry_id); margin_manager::new( &pool, @@ -4886,40 +7577,69 @@ fun place_reduce_only_limit_order_v1_aborts() { &clock, scenario.ctx(), ); - return_shared(deepbook_registry); - - scenario.next_tx(test_constants::user1()); - let mut mm = scenario.take_shared>(); + return_shared(deepbook_registry); + + scenario.next_tx(test_constants::user1()); + let mut mm = scenario.take_shared>(); + let usdc_price = build_demo_usdc_price_info_object(&mut scenario, &clock); + let usdt_price = build_demo_usdt_price_info_object(&mut scenario, &clock); + + // M1 long: 1100 USDC collateral + 1000 USDT (quote) debt, holding the borrowed + // USDT (ratio ~2.1). + mm.deposit( + ®istry, + &usdc_price, + &usdt_price, + mint_coin(1100 * test_constants::usdc_multiplier(), scenario.ctx()), + &clock, + scenario.ctx(), + ); + mm.borrow_quote( + ®istry, + &mut quote_pool, + &usdc_price, + &usdt_price, + &pool, + 1000 * test_constants::usdt_multiplier(), + &clock, + scenario.ctx(), + ); + destroy_2!(usdc_price, usdt_price); - let _ = pool_proxy::place_reduce_only_limit_order( + // Spend almost all the USDT buying USDC at the $1.04 premium -> exposure up, + // tiny repay, net ratio drops -> ERiskRatioMustNotWorsen. + let order_info = test_helpers::place_market_order_and_repay_loan_for_test( + &mut scenario, ®istry, &mut mm, &mut pool, - &base_pool, + &mut base_pool, + &mut quote_pool, 1, - constants::no_restriction(), constants::self_matching_allowed(), - 1_000_000_000, - 100 * test_constants::usdc_multiplier(), + 950 * test_constants::usdc_multiplier(), true, false, - 2_000_000, &clock, - scenario.ctx(), ); + destroy(order_info); abort 999 } -#[test, expected_failure(abort_code = pool_proxy::EDeprecatedUseV2)] -fun place_reduce_only_market_order_v1_aborts() { +#[test, expected_failure(abort_code = pool_proxy::EPoolNotEnabledForMarginTrading)] +fun place_market_order_and_repay_loan_aborts_when_pool_disabled() { + // Unlike the reduce-only and-repay siblings, the non-reduce-only and-repay + // requires margin trading enabled — in reduce-only mode a user must close via + // place_reduce_only_market_order_and_repay_loan. Disabling the pool makes this + // endpoint abort on its pool_enabled gate (the guard that distinguishes it). let ( mut scenario, clock, _admin_cap, _maintainer_cap, base_pool_id, - _q, + quote_pool_id, pool_id, registry_id, ) = setup_pool_proxy_test_env(); @@ -4927,7 +7647,8 @@ fun place_reduce_only_market_order_v1_aborts() { scenario.next_tx(test_constants::user1()); let mut pool = scenario.take_shared_by_id>(pool_id); let mut registry = scenario.take_shared(); - let base_pool = scenario.take_shared_by_id>(base_pool_id); + let mut base_pool = scenario.take_shared_by_id>(base_pool_id); + let mut quote_pool = scenario.take_shared_by_id>(quote_pool_id); let deepbook_registry = scenario.take_shared_by_id(registry_id); margin_manager::new( &pool, @@ -4940,32 +7661,56 @@ fun place_reduce_only_market_order_v1_aborts() { scenario.next_tx(test_constants::user1()); let mut mm = scenario.take_shared>(); + let usdc_price = build_demo_usdc_price_info_object(&mut scenario, &clock); + let usdt_price = build_demo_usdt_price_info_object(&mut scenario, &clock); - let _ = pool_proxy::place_reduce_only_market_order( + mm.deposit( + ®istry, + &usdc_price, + &usdt_price, + mint_coin(250 * test_constants::usdt_multiplier(), scenario.ctx()), + &clock, + scenario.ctx(), + ); + mm.borrow_base( + ®istry, + &mut base_pool, + &usdc_price, + &usdt_price, + &pool, + 100 * test_constants::usdc_multiplier(), + &clock, + scenario.ctx(), + ); + destroy_2!(usdc_price, usdt_price); + + // Drop the pool out of margin trading; the and-repay close must now refuse. + registry.disable_deepbook_pool(&_admin_cap, &mut pool, &clock); + + let _ = test_helpers::place_market_order_and_repay_loan_for_test( + &mut scenario, ®istry, &mut mm, &mut pool, - &base_pool, + &mut base_pool, + &mut quote_pool, 1, constants::self_matching_allowed(), - 100 * test_constants::usdc_multiplier(), + 101 * test_constants::usdc_multiplier(), true, false, &clock, - scenario.ctx(), ); abort 999 } -// === Post-trade solvency regression test === -// -// Borrow USDC against USDT collateral right at `min_borrow_risk_ratio` (1.25, -// the borrow floor in test config), then sell the borrowed USDC against the -// standard orderbook bid (0.99). The 1% adverse fill drops `risk_ratio` from -// 1.25 to ~1.24, breaching the floor; the v2 invariant aborts. -#[test, expected_failure(abort_code = pool_proxy::EInsufficientRiskRatioAfterTrade)] -fun place_limit_order_v2_borrow_at_floor_then_adverse_fill_aborts() { +#[test] +fun reduce_only_bid_covers_sub_min_size_net_debt() { + // Dust coverage: a sub-`min_size` net short can still be covered. With no size + // cap, the bid just has to clear the orderbook `min_size`. Borrow 100 USDC, + // withdraw min_size/2 (5000), leaving a net short of 5000 — below the 10000 + // `min_size`. A reduce-only limit bid for `min_size` is accepted. let ( mut scenario, clock, @@ -4998,44 +7743,41 @@ fun place_limit_order_v2_borrow_at_floor_then_adverse_fill_aborts() { let usdc_price = build_demo_usdc_price_info_object(&mut scenario, &clock); let usdt_price = build_demo_usdt_price_info_object(&mut scenario, &clock); - // Deposit 100 USDT collateral + DEEP for fees, borrow 400 USDC. - // Post-borrow: 100 USDT + 400 USDC = 500 USDC-equiv, debt 400 USDC, - // risk_ratio = 500/400 = 1.25 (exactly at borrow floor). DEEP isn't summed - // by `calculate_assets`, so it doesn't affect risk_ratio. mm.deposit( ®istry, &usdc_price, &usdt_price, - mint_coin(100 * test_constants::usdt_multiplier(), scenario.ctx()), + mint_coin(200 * test_constants::usdt_multiplier(), scenario.ctx()), &clock, scenario.ctx(), ); - mm.deposit( + mm.borrow_base( ®istry, + &mut base_pool, &usdc_price, &usdt_price, - mint_coin(100 * test_constants::deep_multiplier(), scenario.ctx()), + &pool, + 100 * test_constants::usdc_multiplier(), &clock, scenario.ctx(), ); - mm.borrow_base( + // Withdraw only half a min_size of the borrowed USDC, leaving net short 5000. + let withdrawn = mm.withdraw( ®istry, - &mut base_pool, + &base_pool, + "e_pool, &usdc_price, &usdt_price, &pool, - 400 * test_constants::usdc_multiplier(), + constants::min_size() / 2, &clock, scenario.ctx(), ); + destroy(withdrawn); destroy_2!(usdc_price, usdt_price); - // Sell 100 USDC at 0.99 — fills against resting bid at 0.99 (pay_with_deep - // covers fees from the DEEP balance, not the trade output). - // Pre-trade asset: 400 USDC + 100 USDT = 500 USDC-equiv, debt 400. - // Post-trade: 300 USDC + 199 USDT = 499 USDC-equiv, debt 400, - // risk_ratio = 499/400 = 1.2475 < 1.25. Aborts. - let _ = test_helpers::place_limit_order_v2_for_test( + // Reduce-only limit bid for exactly one min_size, resting at $0.99. + let order_info = test_helpers::place_reduce_only_limit_order_v2_for_test( &mut scenario, ®istry, &base_pool, @@ -5045,300 +7787,134 @@ fun place_limit_order_v2_borrow_at_floor_then_adverse_fill_aborts() { 1, constants::no_restriction(), constants::self_matching_allowed(), - 990_000_000, - 100 * test_constants::usdc_multiplier(), - false, // is_bid = false (sell) - true, // pay_with_deep - 2_000_000, - &clock, - ); - - abort 999 -} - -// === Max Order TTL Tests === -// -// `clamp_expire_timestamp` bounds margin limit orders to at most -// `now + max_order_ttl_ms`. Default is 3 days; admin can tune in [1h, 30d]. -// Test clock is set to 1_000_000 ms in the test scaffolding (see -// `test_helpers::setup_margin_registry`). - -#[test, expected_failure(abort_code = margin_registry::EInvalidOrderTtl)] -fun test_set_max_order_ttl_too_low() { - let (mut scenario, clock, admin_cap, maintainer_cap) = setup_margin_registry(); - - let _base_pool_id = create_margin_pool( - &mut scenario, - &maintainer_cap, - default_protocol_config(), - &clock, - ); - let _quote_pool_id = create_margin_pool( - &mut scenario, - &maintainer_cap, - default_protocol_config(), - &clock, - ); - - let (pool_id, _registry_id) = create_pool_for_testing(&mut scenario); - - scenario.next_tx(test_constants::admin()); - let mut registry = scenario.take_shared(); - enable_deepbook_margin_on_pool( - pool_id, - &mut registry, - &admin_cap, - &clock, - &mut scenario, - ); - return_shared(registry); - - scenario.next_tx(test_constants::admin()); - let pool = scenario.take_shared_by_id>(pool_id); - let mut registry = scenario.take_shared(); - - registry.set_max_order_ttl( - &admin_cap, - &pool, - 30 * 60 * 1000, // 30 minutes — below 1h minimum - &clock, - ); - - abort 0 -} - -#[test, expected_failure(abort_code = margin_registry::EInvalidOrderTtl)] -fun test_set_max_order_ttl_too_high() { - let (mut scenario, clock, admin_cap, maintainer_cap) = setup_margin_registry(); - - let _base_pool_id = create_margin_pool( - &mut scenario, - &maintainer_cap, - default_protocol_config(), - &clock, - ); - let _quote_pool_id = create_margin_pool( - &mut scenario, - &maintainer_cap, - default_protocol_config(), - &clock, - ); - - let (pool_id, _registry_id) = create_pool_for_testing(&mut scenario); - - scenario.next_tx(test_constants::admin()); - let mut registry = scenario.take_shared(); - enable_deepbook_margin_on_pool( - pool_id, - &mut registry, - &admin_cap, - &clock, - &mut scenario, - ); - return_shared(registry); - - scenario.next_tx(test_constants::admin()); - let pool = scenario.take_shared_by_id>(pool_id); - let mut registry = scenario.take_shared(); - - registry.set_max_order_ttl( - &admin_cap, - &pool, - 31 * 24 * 60 * 60 * 1000, // 31 days — above 30d maximum - &clock, - ); - - abort 0 -} - -#[test] -fun set_max_order_ttl_within_bounds_ok() { - let (mut scenario, clock, admin_cap, maintainer_cap) = setup_margin_registry(); - - let _base_pool_id = create_margin_pool( - &mut scenario, - &maintainer_cap, - default_protocol_config(), - &clock, - ); - let _quote_pool_id = create_margin_pool( - &mut scenario, - &maintainer_cap, - default_protocol_config(), - &clock, - ); - - let (pool_id, _registry_id) = create_pool_for_testing(&mut scenario); - - scenario.next_tx(test_constants::admin()); - let mut registry = scenario.take_shared(); - enable_deepbook_margin_on_pool( - pool_id, - &mut registry, - &admin_cap, - &clock, - &mut scenario, - ); - return_shared(registry); - - scenario.next_tx(test_constants::admin()); - let pool = scenario.take_shared_by_id>(pool_id); - let mut registry = scenario.take_shared(); - - let one_day_ms = 24 * 60 * 60 * 1000; - registry.set_max_order_ttl(&admin_cap, &pool, one_day_ms, &clock); - - assert!(registry.max_order_ttl_ms(pool_id) == one_day_ms); - - // Update again to exercise the mutable path, not just first-insert. - let two_days_ms = 2 * one_day_ms; - registry.set_max_order_ttl(&admin_cap, &pool, two_days_ms, &clock); - assert!(registry.max_order_ttl_ms(pool_id) == two_days_ms); - - return_shared_2!(registry, pool); - destroy(admin_cap); - destroy(maintainer_cap); - destroy(clock); - scenario.end(); -} - -#[test] -fun max_order_ttl_lazy_default() { - // Pools that have never had `set_max_order_ttl` called still get the - // 3-day default (lazy-default read path). - let (mut scenario, clock, admin_cap, maintainer_cap) = setup_margin_registry(); - - let _base_pool_id = create_margin_pool( - &mut scenario, - &maintainer_cap, - default_protocol_config(), - &clock, - ); - let _quote_pool_id = create_margin_pool( - &mut scenario, - &maintainer_cap, - default_protocol_config(), + 990_000_000, // $0.99 (rests below the $1.01 ask) + constants::min_size(), + true, // is_bid = true + false, + 2_000_000, &clock, ); - let (pool_id, _registry_id) = create_pool_for_testing(&mut scenario); - - scenario.next_tx(test_constants::admin()); - let registry = scenario.take_shared(); - - assert!(registry.max_order_ttl_ms(pool_id) == margin_constants::default_max_order_ttl_ms()); - // 3 days = 259_200_000 ms (sanity-check the literal hasn't drifted). - assert!(registry.max_order_ttl_ms(pool_id) == 3 * 24 * 60 * 60 * 1000); + assert_eq!(order_info.original_quantity(), constants::min_size()); + assert_eq!(order_info.executed_quantity(), 0); - return_shared(registry); - destroy(admin_cap); - destroy(maintainer_cap); - destroy(clock); - scenario.end(); + destroy(order_info); + return_shared_3!(mm, pool, base_pool); + return_shared(quote_pool); + cleanup_margin_test(registry, _admin_cap, _maintainer_cap, clock, scenario); } #[test] -fun clamp_expire_timestamp_passthrough_and_clamp() { - // Direct exercise of the clamp helper: small inputs pass, large inputs cap. - let (mut scenario, clock, admin_cap, maintainer_cap) = setup_margin_registry(); +fun reduce_only_and_repay_closes_non_lot_aligned_debt() { + // A non-lot-aligned net short can be fully closed. With no size cap, the bid + // can be any valid lot-aligned quantity at or past the debt. Borrow 30 USDC, + // withdraw all but 500 raw, leaving a net short of ~29_999_500 (not lot- + // aligned, lot = 1000). A reduce-only and-repay bid for 30_000_000 (the next + // lot up) is accepted and clears the loan. + let ( + mut scenario, + clock, + _admin_cap, + _maintainer_cap, + base_pool_id, + quote_pool_id, + pool_id, + registry_id, + ) = setup_pool_proxy_test_env(); + setup_orderbook_liquidity_stablecoin(&mut scenario, pool_id, &clock); - let _base_pool_id = create_margin_pool( - &mut scenario, - &maintainer_cap, - default_protocol_config(), - &clock, - ); - let _quote_pool_id = create_margin_pool( - &mut scenario, - &maintainer_cap, - default_protocol_config(), + scenario.next_tx(test_constants::user1()); + let mut pool = scenario.take_shared_by_id>(pool_id); + let mut registry = scenario.take_shared(); + let mut base_pool = scenario.take_shared_by_id>(base_pool_id); + let mut quote_pool = scenario.take_shared_by_id>(quote_pool_id); + let deepbook_registry = scenario.take_shared_by_id(registry_id); + margin_manager::new( + &pool, + &deepbook_registry, + &mut registry, &clock, + scenario.ctx(), ); + return_shared(deepbook_registry); - let (pool_id, _registry_id) = create_pool_for_testing(&mut scenario); - - scenario.next_tx(test_constants::admin()); - let registry = scenario.take_shared(); - - // Test clock is at 1_000_000 ms. Default TTL is 3 days = 259_200_000 ms. - // Cap = 1_000_000 + 259_200_000 = 260_200_000. - let now = 1_000_000u64; - let cap = now + margin_constants::default_max_order_ttl_ms(); - - // Small expire_timestamp passes through unchanged. - assert!(registry.clamp_expire_timestamp(pool_id, 2_000_000, &clock) == 2_000_000); - // Boundary: exact cap passes through. - assert!(registry.clamp_expire_timestamp(pool_id, cap, &clock) == cap); - // One past cap clamps. - assert!(registry.clamp_expire_timestamp(pool_id, cap + 1, &clock) == cap); - // u64::MAX-ish clamps. - assert!(registry.clamp_expire_timestamp(pool_id, 1_000_000_000_000_000, &clock) == cap); - // expire_timestamp = 0 passes through. - assert!(registry.clamp_expire_timestamp(pool_id, 0, &clock) == 0); - - return_shared(registry); - destroy(admin_cap); - destroy(maintainer_cap); - destroy(clock); - scenario.end(); -} - -#[test] -fun clamp_uses_admin_set_ttl_after_update() { - // After admin tightens per-pool TTL, the clamp uses the new value. - let (mut scenario, clock, admin_cap, maintainer_cap) = setup_margin_registry(); + scenario.next_tx(test_constants::user1()); + let mut mm = scenario.take_shared>(); + let usdc_price = build_demo_usdc_price_info_object(&mut scenario, &clock); + let usdt_price = build_demo_usdt_price_info_object(&mut scenario, &clock); - let _base_pool_id = create_margin_pool( - &mut scenario, - &maintainer_cap, - default_protocol_config(), + mm.deposit( + ®istry, + &usdc_price, + &usdt_price, + mint_coin(100 * test_constants::usdt_multiplier(), scenario.ctx()), &clock, + scenario.ctx(), ); - let _quote_pool_id = create_margin_pool( - &mut scenario, - &maintainer_cap, - default_protocol_config(), + mm.borrow_base( + ®istry, + &mut base_pool, + &usdc_price, + &usdt_price, + &pool, + 30 * test_constants::usdc_multiplier(), // 30 USDC = 30_000_000 (lot-aligned) &clock, + scenario.ctx(), ); - - let (pool_id, _registry_id) = create_pool_for_testing(&mut scenario); - - scenario.next_tx(test_constants::admin()); - let mut registry = scenario.take_shared(); - enable_deepbook_margin_on_pool( - pool_id, - &mut registry, - &admin_cap, + // Withdraw all but 500 raw, leaving net short = 30_000_000 - 500 = 29_999_500 + // (not a multiple of lot_size 1000). + let withdrawn = mm.withdraw( + ®istry, + &base_pool, + "e_pool, + &usdc_price, + &usdt_price, + &pool, + 30 * test_constants::usdc_multiplier() - 500, &clock, - &mut scenario, + scenario.ctx(), ); - return_shared(registry); - - scenario.next_tx(test_constants::admin()); - let pool = scenario.take_shared_by_id>(pool_id); - let mut registry = scenario.take_shared(); + destroy(withdrawn); + destroy_2!(usdc_price, usdt_price); - // Tighten TTL to 1 hour (the minimum). - let one_hour_ms = 60 * 60 * 1000; - registry.set_max_order_ttl(&admin_cap, &pool, one_hour_ms, &clock); + // Buy 30_000_000 — the net short (~29_999_500) rounded up to the next lot. + let order_info = test_helpers::place_reduce_only_market_order_and_repay_loan_for_test< + USDC, + USDT, + >( + &mut scenario, + ®istry, + &mut mm, + &mut pool, + &mut base_pool, + &mut quote_pool, + 1, + constants::self_matching_allowed(), + 30 * test_constants::usdc_multiplier(), + true, // is_bid = true (cover the short) + false, + &clock, + ); + destroy(order_info); - // Clock = 1_000_000 ms, TTL = 3_600_000 ms → cap = 4_600_000. - let now = 1_000_000u64; - let expected_cap = now + one_hour_ms; - assert!(registry.clamp_expire_timestamp(pool_id, 999_999_999, &clock) == expected_cap); - assert!(registry.clamp_expire_timestamp(pool_id, 2_000_000, &clock) == 2_000_000); + // Rounding up to the lot let the dust debt be fully cleared. + assert_eq!(mm.borrowed_base_shares(), 0); - return_shared_2!(registry, pool); - destroy(admin_cap); - destroy(maintainer_cap); - destroy(clock); - scenario.end(); + return_shared_3!(mm, pool, base_pool); + return_shared(quote_pool); + cleanup_margin_test(registry, _admin_cap, _maintainer_cap, clock, scenario); } +// === place_reduce_only_limit_order_and_repay_loan === + #[test] -fun place_limit_order_clamps_expire_timestamp() { - // End-to-end: a huge expire_timestamp ends up at the 3-day cap on the - // resting order returned by the proxy entry. +fun reduce_only_limit_and_repay_partial_taker_fill_repays() { + // A reduce-only limit order that crosses the book partially: the taker + // portion fills and settles, the remainder rests as a maker, and the taker + // proceeds repay the loan before the net-monotonic check. Long: hold 15000 + // USDC, 500 USDT debt. Ask 12000 USDC at $0.99 taker-fills the 10000-USDC bid + // depth (~9900 USDT, repays the whole 500 debt) and rests 2000. The non-repay + // place_reduce_only_limit_order_v2 aborts on this — see the paired test. let ( mut scenario, clock, @@ -5349,10 +7925,13 @@ fun place_limit_order_clamps_expire_timestamp() { pool_id, registry_id, ) = setup_pool_proxy_test_env(); + setup_orderbook_liquidity_stablecoin(&mut scenario, pool_id, &clock); scenario.next_tx(test_constants::user1()); let mut pool = scenario.take_shared_by_id>(pool_id); let mut registry = scenario.take_shared(); + let mut base_pool = scenario.take_shared_by_id>(base_pool_id); + let mut quote_pool = scenario.take_shared_by_id>(quote_pool_id); let deepbook_registry = scenario.take_shared_by_id(registry_id); margin_manager::new( &pool, @@ -5367,54 +7946,77 @@ fun place_limit_order_clamps_expire_timestamp() { let mut mm = scenario.take_shared>(); let usdc_price = build_demo_usdc_price_info_object(&mut scenario, &clock); let usdt_price = build_demo_usdt_price_info_object(&mut scenario, &clock); + mm.deposit( ®istry, &usdc_price, &usdt_price, - mint_coin(10000 * test_constants::usdc_multiplier(), scenario.ctx()), + mint_coin(15000 * test_constants::usdc_multiplier(), scenario.ctx()), + &clock, + scenario.ctx(), + ); + mm.borrow_quote( + ®istry, + &mut quote_pool, + &usdc_price, + &usdt_price, + &pool, + 500 * test_constants::usdt_multiplier(), + &clock, + scenario.ctx(), + ); + let withdrawn = mm.withdraw( + ®istry, + &base_pool, + "e_pool, + &usdc_price, + &usdt_price, + &pool, + 300 * test_constants::usdt_multiplier(), &clock, scenario.ctx(), ); + destroy(withdrawn); destroy_2!(usdc_price, usdt_price); - let base_pool = scenario.take_shared_by_id>(base_pool_id); - let quote_pool = scenario.take_shared_by_id>(quote_pool_id); - - let huge_expire_ts = 1_000_000_000_000_000u64; - let order_info = test_helpers::place_limit_order_v2_for_test( + let order_info = test_helpers::place_reduce_only_limit_order_and_repay_loan_for_test< + USDC, + USDT, + >( &mut scenario, ®istry, - &base_pool, - "e_pool, &mut mm, &mut pool, + &mut base_pool, + &mut quote_pool, 1, constants::no_restriction(), constants::self_matching_allowed(), - 1_000_000_000, - 100 * test_constants::usdc_multiplier(), - false, // is_bid (sell) + 990_000_000, // $0.99 — crosses the $0.99 bid + 12000 * test_constants::usdc_multiplier(), + false, // is_bid = false (sell to close the long) false, - huge_expire_ts, + 2_000_000, &clock, ); - let now = 1_000_000u64; - let expected = now + margin_constants::default_max_order_ttl_ms(); - assert!(order_info.expire_timestamp() == expected); + // The taker portion filled the 10000-USDC bid depth; its proceeds cleared the + // loan; the remaining 2000 rests as a maker. + assert_eq!(order_info.executed_quantity(), 10000 * test_constants::usdc_multiplier()); + assert_eq!(mm.borrowed_quote_shares(), 0); - return_shared(base_pool); - return_shared(quote_pool); destroy(order_info); - - return_shared_2!(mm, pool); + return_shared_3!(mm, pool, quote_pool); + return_shared(base_pool); cleanup_margin_test(registry, _admin_cap, _maintainer_cap, clock, scenario); } -#[test] -fun place_limit_order_v2_no_debt_at_oracle_price_ok() { - // Sanity: with no debt the post-trade invariant short-circuits, so - // `place_limit_order_v2` succeeds normally. +#[test, expected_failure(abort_code = pool_proxy::ERiskRatioMustNotWorsen)] +fun reduce_only_limit_v2_taker_fill_aborts() { + // The same crossing reduce-only ask, but via the non-repay + // place_reduce_only_limit_order_v2: the taker fill pays the spread, lowering + // the swap-only ratio, so the monotonic check aborts (the gap the and-repay + // variant closes). let ( mut scenario, clock, @@ -5425,10 +8027,13 @@ fun place_limit_order_v2_no_debt_at_oracle_price_ok() { pool_id, registry_id, ) = setup_pool_proxy_test_env(); + setup_orderbook_liquidity_stablecoin(&mut scenario, pool_id, &clock); scenario.next_tx(test_constants::user1()); let mut pool = scenario.take_shared_by_id>(pool_id); let mut registry = scenario.take_shared(); + let base_pool = scenario.take_shared_by_id>(base_pool_id); + let mut quote_pool = scenario.take_shared_by_id>(quote_pool_id); let deepbook_registry = scenario.take_shared_by_id(registry_id); margin_manager::new( &pool, @@ -5443,19 +8048,40 @@ fun place_limit_order_v2_no_debt_at_oracle_price_ok() { let mut mm = scenario.take_shared>(); let usdc_price = build_demo_usdc_price_info_object(&mut scenario, &clock); let usdt_price = build_demo_usdt_price_info_object(&mut scenario, &clock); + mm.deposit( ®istry, &usdc_price, &usdt_price, - mint_coin(1000 * test_constants::usdc_multiplier(), scenario.ctx()), + mint_coin(15000 * test_constants::usdc_multiplier(), scenario.ctx()), &clock, scenario.ctx(), ); + mm.borrow_quote( + ®istry, + &mut quote_pool, + &usdc_price, + &usdt_price, + &pool, + 500 * test_constants::usdt_multiplier(), + &clock, + scenario.ctx(), + ); + let withdrawn = mm.withdraw( + ®istry, + &base_pool, + "e_pool, + &usdc_price, + &usdt_price, + &pool, + 300 * test_constants::usdt_multiplier(), + &clock, + scenario.ctx(), + ); + destroy(withdrawn); destroy_2!(usdc_price, usdt_price); - let base_pool = scenario.take_shared_by_id>(base_pool_id); - let quote_pool = scenario.take_shared_by_id>(quote_pool_id); - let order_info = test_helpers::place_limit_order_v2_for_test( + let _ = test_helpers::place_reduce_only_limit_order_v2_for_test( &mut scenario, ®istry, &base_pool, @@ -5465,17 +8091,13 @@ fun place_limit_order_v2_no_debt_at_oracle_price_ok() { 1, constants::no_restriction(), constants::self_matching_allowed(), - 1_000_000_000, - 100 * test_constants::usdc_multiplier(), + 990_000_000, + 12000 * test_constants::usdc_multiplier(), false, false, 2_000_000, &clock, ); - return_shared(base_pool); - return_shared(quote_pool); - destroy(order_info); - return_shared_2!(mm, pool); - cleanup_margin_test(registry, _admin_cap, _maintainer_cap, clock, scenario); + abort 999 } diff --git a/packages/deepbook_margin/tests/tpsl_tests.move b/packages/deepbook_margin/tests/tpsl_tests.move index b1fd05cbd..38a25f05b 100644 --- a/packages/deepbook_margin/tests/tpsl_tests.move +++ b/packages/deepbook_margin/tests/tpsl_tests.move @@ -3440,6 +3440,206 @@ fun setup_orderbook_liquidity_out_of_bounds( return_shared(pool); } +// Far-below counterparty liquidity: a single far-below bid at $1.00 (no ask, so the +// manager's own $1.80 bid won't cross it). +fun setup_far_below_bid( + scenario: &mut test_scenario::Scenario, + pool_id: ID, + clock: &sui::clock::Clock, +) { + use deepbook::balance_manager; + use token::deep::DEEP; + + scenario.next_tx(test_constants::user2()); + let mut pool = scenario.take_shared_by_id>(pool_id); + let mut bm = balance_manager::new(scenario.ctx()); + bm.deposit( + mint_coin(1000 * test_constants::usdc_multiplier(), scenario.ctx()), + scenario.ctx(), + ); + bm.deposit( + mint_coin(10000 * test_constants::deep_multiplier(), scenario.ctx()), + scenario.ctx(), + ); + let proof = bm.generate_proof_as_owner(scenario.ctx()); + pool.place_limit_order( + &mut bm, + &proof, + 1, + constants::no_restriction(), + constants::self_matching_allowed(), + 1_000_000, // $1.00, below the $1.71 fresh lower bound + 300 * test_constants::sui_multiplier(), + true, // is_bid + false, + constants::max_u64(), + clock, + scenario.ctx(), + ); + transfer::public_share_object(bm); + return_shared(pool); +} + +#[test, expected_failure(abort_code = margin_manager::EFillOutsidePriceBounds)] +fun tpsl_cancel_maker_self_match_cannot_bypass_price_bounds() { + // Regression for the self-match price-bound check. The manager's + // TPSL market sell uses `cancel_maker`. Its own safe bid at the fresh oracle + // price ($1.80) makes the pre-check (which simulates against the *full* book) + // see an in-bounds fill, but `cancel_maker` removes that bid during execution + // and the sell fills the far-below $1.00 bid. The post-execution + // actual-fill check catches the out-of-bounds settlement and aborts. + let ( + mut scenario, + clock, + admin_cap, + maintainer_cap, + usdc_pool_id, + sui_pool_id, + pool_id, + registry_id, + ) = setup_sui_usdc_deepbook_margin(); + + setup_far_below_bid(&mut scenario, pool_id, &clock); + + // Add-time registry price $2.00 (so the $1.90 stop-loss trigger sits below it; + // the price is dropped to a fresh $1.80 at execution). Create the manager. + scenario.next_tx(test_constants::user1()); + let mut margin_registry = scenario.take_shared(); + let pool = scenario.take_shared>(); + let sui_price = build_sui_price_info_object_with_price(&mut scenario, 200, &clock); + let usdc_price = build_usdc_price_info_object(&mut scenario, &clock); + pool_proxy::update_current_price( + &mut margin_registry, + &pool, + &sui_price, + &usdc_price, + &clock, + ); + let deepbook_registry = scenario.take_shared_by_id(registry_id); + margin_manager::new( + &pool, + &deepbook_registry, + &mut margin_registry, + &clock, + scenario.ctx(), + ); + return_shared(deepbook_registry); + destroy_2!(sui_price, usdc_price); + return_shared(pool); + return_shared(margin_registry); + + // Manager deposits SUI + USDC, places its own safe bid at $1.80, and adds a + // cancel_maker stop-loss market sell triggering below $1.90. + scenario.next_tx(test_constants::user1()); + let mut mm = scenario.take_shared>(); + let mut pool = scenario.take_shared>(); + let margin_registry = scenario.take_shared(); + let sui_pool = scenario.take_shared_by_id>(sui_pool_id); + let usdc_pool = scenario.take_shared_by_id>(usdc_pool_id); + let sui_price = build_sui_price_info_object_with_price(&mut scenario, 200, &clock); + let usdc_price = build_usdc_price_info_object(&mut scenario, &clock); + + mm.deposit( + &margin_registry, + &sui_price, + &usdc_price, + mint_coin(10000 * test_constants::sui_multiplier(), scenario.ctx()), + &clock, + scenario.ctx(), + ); + mm.deposit( + &margin_registry, + &sui_price, + &usdc_price, + mint_coin(1000 * test_constants::usdc_multiplier(), scenario.ctx()), + &clock, + scenario.ctx(), + ); + + let bid_info = test_helpers::place_limit_order_v2_for_test( + &mut scenario, + &margin_registry, + &sui_pool, + &usdc_pool, + &mut mm, + &mut pool, + 1, + constants::no_restriction(), + constants::self_matching_allowed(), + 1_800_000, // $1.80, within bounds, rests as the best bid + 150 * test_constants::sui_multiplier(), + true, + false, + constants::max_u64(), + &clock, + ); + destroy(bid_info); + + let condition = tpsl::new_condition(true, 1_900_000); // stop-loss: trigger below $1.90 + let pending_order = tpsl::new_pending_market_order( + 1, + constants::cancel_maker(), + 150 * test_constants::sui_multiplier(), + false, // market sell + false, + ); + mm.add_conditional_order( + &pool, + &sui_price, + &usdc_price, + &margin_registry, + 1, + condition, + pending_order, + &clock, + scenario.ctx(), + ); + + destroy_2!(sui_price, usdc_price); + return_shared(sui_pool); + return_shared(usdc_pool); + return_shared_2!(mm, pool); + return_shared(margin_registry); + + // Permissionless keeper triggers at fresh $1.80 (< $1.90). cancel_maker cancels + // the manager's $1.80 bid; the sell would settle at the far-below $1.00 bid -> + // the post-execution bound check aborts EFillOutsidePriceBounds. + scenario.next_tx(test_constants::user2()); + let sui_price_trigger = build_sui_price_info_object_with_price(&mut scenario, 180, &clock); + let usdc_price = build_usdc_price_info_object(&mut scenario, &clock); + let mut pool = scenario.take_shared>(); + let mut margin_registry = scenario.take_shared(); + let mut mm = scenario.take_shared>(); + let sui_pool = scenario.take_shared_by_id>(sui_pool_id); + let usdc_pool = scenario.take_shared_by_id>(usdc_pool_id); + + // Drop the registry price to a fresh $1.80 (aligned with the trigger): bounds + // become $1.71-$1.89 so the manager's own $1.80 bid reads as in-bounds. + pool_proxy::update_current_price( + &mut margin_registry, + &pool, + &sui_price_trigger, + &usdc_price, + &clock, + ); + + let order_infos = test_helpers::execute_conditional_orders_v2_for_test( + &mut scenario, + &sui_pool, + &usdc_pool, + &mut mm, + &mut pool, + &sui_price_trigger, + &usdc_price, + &margin_registry, + 10, + &clock, + ); + destroy(order_infos); + + abort 999 +} + #[test] fun test_tpsl_limit_order_price_below_lower_bound_cancelled() { // Test that a limit order with price below the lower bound gets cancelled @@ -4227,3 +4427,392 @@ fun test_tpsl_mixed_orders_some_cancelled_some_executed() { return_shared(deepbook_registry); cleanup_margin_test(margin_registry, admin_cap, maintainer_cap, clock, scenario); } + +// === v3 conditional execution: deleverage so stops fire in the danger band === + +#[test] +fun execute_conditional_orders_v3_stop_loss_deleverages_in_danger_band() { + // Alice opens a leveraged position at SUI=$2: 1000 SUI collateral + 8000 USDC + // borrowed, risk_ratio = 1.25 (the borrow floor). SUI falls to $0.95, + // dropping the ratio to ~1.119 — the danger band (above liquidation 1.10, + // below borrow 1.25). Her market stop-loss fires: v3 sells SUI and repays the + // loan, so the deleveraged ratio clears the gate and the order executes and is + // removed. The v2 path aborts on this exact setup (paired test below). + let ( + mut scenario, + clock, + admin_cap, + maintainer_cap, + usdc_pool_id, + sui_pool_id, + pool_id, + registry_id, + ) = setup_sui_usdc_deepbook_margin(); + + setup_orderbook_liquidity_low_bids(&mut scenario, pool_id, &clock); + + scenario.next_tx(test_constants::user1()); + let mut margin_registry = scenario.take_shared(); + let pool = scenario.take_shared>(); + let deepbook_registry = scenario.take_shared_by_id(registry_id); + margin_manager::new( + &pool, + &deepbook_registry, + &mut margin_registry, + &clock, + scenario.ctx(), + ); + return_shared(deepbook_registry); + return_shared(pool); + + scenario.next_tx(test_constants::user1()); + let mut mm = scenario.take_shared>(); + let pool = scenario.take_shared>(); + let mut usdc_pool = scenario.take_shared_by_id>(usdc_pool_id); + let sui_price_high = build_sui_price_info_object_with_price(&mut scenario, 200, &clock); // $2.00 + let usdc_price = build_usdc_price_info_object(&mut scenario, &clock); + + mm.deposit( + &margin_registry, + &sui_price_high, + &usdc_price, + mint_coin(1000 * test_constants::sui_multiplier(), scenario.ctx()), + &clock, + scenario.ctx(), + ); + // Borrow 8000 USDC: ratio = (1000*2 + 8000) / 8000 = 1.25 at SUI=$2. + mm.borrow_quote( + &margin_registry, + &mut usdc_pool, + &sui_price_high, + &usdc_price, + &pool, + 8000 * test_constants::usdc_multiplier(), + &clock, + scenario.ctx(), + ); + + let condition = tpsl::new_condition(true, 1_500_000); // trigger_below $1.50 + let pending_order = tpsl::new_pending_market_order( + 1, + constants::self_matching_allowed(), + 150 * test_constants::sui_multiplier(), // sell 150 SUI (< 200 bid liquidity) + false, // is_bid = false (sell) + false, // pay_with_deep + ); + mm.add_conditional_order( + &pool, + &sui_price_high, + &usdc_price, + &margin_registry, + 1, + condition, + pending_order, + &clock, + scenario.ctx(), + ); + + destroy_2!(sui_price_high, usdc_price); + return_shared_2!(mm, pool); + return_shared(usdc_pool); + return_shared(margin_registry); + + scenario.next_tx(test_constants::user2()); + let sui_price_low = build_sui_price_info_object_with_price(&mut scenario, 95, &clock); // $0.95 + let usdc_price = build_usdc_price_info_object(&mut scenario, &clock); + let mut pool = scenario.take_shared>(); + let mut margin_registry = scenario.take_shared(); + let mut mm = scenario.take_shared>(); + pool_proxy::update_current_price( + &mut margin_registry, + &pool, + &sui_price_low, + &usdc_price, + &clock, + ); + + let mut sui_pool = scenario.take_shared_by_id>(sui_pool_id); + let mut usdc_pool = scenario.take_shared_by_id>(usdc_pool_id); + + let debt_before = mm.borrowed_quote_shares(); + assert!(debt_before > 0); + + let order_infos = test_helpers::execute_conditional_orders_v3_for_test( + &mut scenario, + &mut sui_pool, + &mut usdc_pool, + &mut mm, + &mut pool, + &sui_price_low, + &usdc_price, + &margin_registry, + 10, + &clock, + ); + + // The stop-loss fired, v3 repaid the loan with the proceeds (debt reduced), + // and the order was removed from the queue — no stuck retry (#8, #10). + assert!(order_infos.length() == 1); + assert!(mm.borrowed_quote_shares() < debt_before); + assert!(mm.conditional_order_ids().length() == 0); + + destroy(order_infos); + return_shared(sui_pool); + return_shared(usdc_pool); + destroy_2!(sui_price_low, usdc_price); + return_shared_2!(mm, pool); + cleanup_margin_test(margin_registry, admin_cap, maintainer_cap, clock, scenario); +} + +#[test, expected_failure(abort_code = margin_manager::EInsufficientRiskRatioAfterTrade)] +fun execute_conditional_orders_v2_stop_loss_aborts_in_danger_band() { + // Same danger-band setup as the v3 test above, but executed through v2: the + // stop-loss sells without repaying, so the post-fill ratio (~1.119) stays + // below the borrow floor and the v2 gate aborts the whole txn. + let ( + mut scenario, + clock, + _admin_cap, + _maintainer_cap, + usdc_pool_id, + sui_pool_id, + pool_id, + registry_id, + ) = setup_sui_usdc_deepbook_margin(); + + setup_orderbook_liquidity_low_bids(&mut scenario, pool_id, &clock); + + scenario.next_tx(test_constants::user1()); + let mut margin_registry = scenario.take_shared(); + let pool = scenario.take_shared>(); + let deepbook_registry = scenario.take_shared_by_id(registry_id); + margin_manager::new( + &pool, + &deepbook_registry, + &mut margin_registry, + &clock, + scenario.ctx(), + ); + return_shared(deepbook_registry); + return_shared(pool); + + scenario.next_tx(test_constants::user1()); + let mut mm = scenario.take_shared>(); + let pool = scenario.take_shared>(); + let mut usdc_pool = scenario.take_shared_by_id>(usdc_pool_id); + let sui_price_high = build_sui_price_info_object_with_price(&mut scenario, 200, &clock); + let usdc_price = build_usdc_price_info_object(&mut scenario, &clock); + + mm.deposit( + &margin_registry, + &sui_price_high, + &usdc_price, + mint_coin(1000 * test_constants::sui_multiplier(), scenario.ctx()), + &clock, + scenario.ctx(), + ); + mm.borrow_quote( + &margin_registry, + &mut usdc_pool, + &sui_price_high, + &usdc_price, + &pool, + 8000 * test_constants::usdc_multiplier(), + &clock, + scenario.ctx(), + ); + + let condition = tpsl::new_condition(true, 1_500_000); + let pending_order = tpsl::new_pending_market_order( + 1, + constants::self_matching_allowed(), + 150 * test_constants::sui_multiplier(), + false, + false, + ); + mm.add_conditional_order( + &pool, + &sui_price_high, + &usdc_price, + &margin_registry, + 1, + condition, + pending_order, + &clock, + scenario.ctx(), + ); + + destroy_2!(sui_price_high, usdc_price); + return_shared_2!(mm, pool); + return_shared(usdc_pool); + return_shared(margin_registry); + + scenario.next_tx(test_constants::user2()); + let sui_price_low = build_sui_price_info_object_with_price(&mut scenario, 95, &clock); + let usdc_price = build_usdc_price_info_object(&mut scenario, &clock); + let mut pool = scenario.take_shared>(); + let mut margin_registry = scenario.take_shared(); + let mut mm = scenario.take_shared>(); + pool_proxy::update_current_price( + &mut margin_registry, + &pool, + &sui_price_low, + &usdc_price, + &clock, + ); + + let sui_pool = scenario.take_shared_by_id>(sui_pool_id); + let usdc_pool = scenario.take_shared_by_id>(usdc_pool_id); + + let order_infos = test_helpers::execute_conditional_orders_v2_for_test( + &mut scenario, + &sui_pool, + &usdc_pool, + &mut mm, + &mut pool, + &sui_price_low, + &usdc_price, + &margin_registry, + 10, + &clock, + ); + destroy(order_infos); + + abort 999 +} + +#[test] +fun execute_conditional_orders_v3_limit_stop_loss_rests_in_danger_band() { + // A *limit*-type stop-loss in the danger band: v3 places it (it rests as a + // maker, leaving the ratio unchanged, so the monotonic gate passes) and + // removes it from the queue — it is not aborted like the v2 path. No fill, + // so no repay. Same 1.119 danger-band setup as the market v3 test. + let ( + mut scenario, + clock, + admin_cap, + maintainer_cap, + usdc_pool_id, + sui_pool_id, + pool_id, + registry_id, + ) = setup_sui_usdc_deepbook_margin(); + + setup_orderbook_liquidity_low_bids(&mut scenario, pool_id, &clock); + + scenario.next_tx(test_constants::user1()); + let mut margin_registry = scenario.take_shared(); + let pool = scenario.take_shared>(); + let deepbook_registry = scenario.take_shared_by_id(registry_id); + margin_manager::new( + &pool, + &deepbook_registry, + &mut margin_registry, + &clock, + scenario.ctx(), + ); + return_shared(deepbook_registry); + return_shared(pool); + + scenario.next_tx(test_constants::user1()); + let mut mm = scenario.take_shared>(); + let pool = scenario.take_shared>(); + let mut usdc_pool = scenario.take_shared_by_id>(usdc_pool_id); + let sui_price_high = build_sui_price_info_object_with_price(&mut scenario, 200, &clock); + let usdc_price = build_usdc_price_info_object(&mut scenario, &clock); + + mm.deposit( + &margin_registry, + &sui_price_high, + &usdc_price, + mint_coin(1000 * test_constants::sui_multiplier(), scenario.ctx()), + &clock, + scenario.ctx(), + ); + mm.borrow_quote( + &margin_registry, + &mut usdc_pool, + &sui_price_high, + &usdc_price, + &pool, + 8000 * test_constants::usdc_multiplier(), + &clock, + scenario.ctx(), + ); + + // Limit SL: trigger_below $1.50, sell 150 SUI at $0.96 — above the $0.95 best + // bid, so it rests (maker) rather than crossing. + let condition = tpsl::new_condition(true, 1_500_000); + let pending_order = tpsl::new_pending_limit_order( + 1, + constants::no_restriction(), + constants::self_matching_allowed(), + 960_000, // $0.96 (rests above the $0.95 bid) + 150 * test_constants::sui_multiplier(), + false, // is_bid = false (sell) + false, // pay_with_deep + constants::max_u64(), + ); + mm.add_conditional_order( + &pool, + &sui_price_high, + &usdc_price, + &margin_registry, + 1, + condition, + pending_order, + &clock, + scenario.ctx(), + ); + + destroy_2!(sui_price_high, usdc_price); + return_shared_2!(mm, pool); + return_shared(usdc_pool); + return_shared(margin_registry); + + scenario.next_tx(test_constants::user2()); + let sui_price_low = build_sui_price_info_object_with_price(&mut scenario, 95, &clock); // $0.95 + let usdc_price = build_usdc_price_info_object(&mut scenario, &clock); + let mut pool = scenario.take_shared>(); + let mut margin_registry = scenario.take_shared(); + let mut mm = scenario.take_shared>(); + pool_proxy::update_current_price( + &mut margin_registry, + &pool, + &sui_price_low, + &usdc_price, + &clock, + ); + + let mut sui_pool = scenario.take_shared_by_id>(sui_pool_id); + let mut usdc_pool = scenario.take_shared_by_id>(usdc_pool_id); + + let debt_before = mm.borrowed_quote_shares(); + + let order_infos = test_helpers::execute_conditional_orders_v3_for_test( + &mut scenario, + &mut sui_pool, + &mut usdc_pool, + &mut mm, + &mut pool, + &sui_price_low, + &usdc_price, + &margin_registry, + 10, + &clock, + ); + + // The limit SL was placed (rested, no fill → no repay) and removed from the + // queue; v2 would have aborted on this danger-band setup. + assert!(order_infos.length() == 1); + assert!(order_infos[0].executed_quantity() == 0); + assert!(mm.borrowed_quote_shares() == debt_before); + assert!(mm.conditional_order_ids().length() == 0); + + destroy(order_infos); + return_shared(sui_pool); + return_shared(usdc_pool); + destroy_2!(sui_price_low, usdc_price); + return_shared_2!(mm, pool); + cleanup_margin_test(margin_registry, admin_cap, maintainer_cap, clock, scenario); +}