Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
351cd45
margin: add place_reduce_only_market_order_and_repay_loan
tonylee08 May 22, 2026
60e9673
margin: address review - keep repay in place, drop redundant settle
tonylee08 May 22, 2026
bdf0c28
margin: take both pools in close-and-repay; no margin_manager changes
tonylee08 May 22, 2026
0213a67
margin: gross-holdings cap for reduce-only exit (asymmetric ask/bid)
tonylee08 Jun 22, 2026
23ee9b9
docs(move rules): asymmetric reduce-only cap (gross sell / net buy)
tonylee08 Jun 22, 2026
bb83ae2
margin: min_open_risk_ratio — separate opening floor from borrow floo…
tonylee08 Jun 22, 2026
413a13a
docs(move rules): open floor (min_open_risk_ratio) distinct from borr…
tonylee08 Jun 22, 2026
57dac08
margin: v3 conditional execution — deleverage so stops fire in danger…
tonylee08 Jun 22, 2026
af8d0eb
docs(move rules): v3 conditional deleverage + upgrade-compat _vN rule
tonylee08 Jun 22, 2026
56f1414
margin: document TP/SL lifetime semantics (#9)
tonylee08 Jun 22, 2026
2d06f24
Merge remote-tracking branch 'origin/main' into tlee/margin-close-and…
tonylee08 Jun 22, 2026
55146a4
margin: tests for free-borrowed-funds order (#3) and limit-type SL vi…
tonylee08 Jun 22, 2026
5188bb2
margin: add place_market_order_and_repay_loan + reduce-only min_size …
tonylee08 Jun 24, 2026
815d79d
margin: reduce-only bid cap rounds net short up to the next lot
tonylee08 Jun 24, 2026
7ead5f1
margin: add place_reduce_only_limit_order_and_repay_loan
tonylee08 Jun 24, 2026
527f9de
margin: make reduce-only market v2 ask cap gross; fix div_ceil CI build
tonylee08 Jun 24, 2026
1519920
margin: test place_market_order_and_repay_loan pool-disabled guard; f…
tonylee08 Jun 24, 2026
e8d42f9
margin: address review — owner-gate test, surplus assert, comment fixes
tonylee08 Jun 24, 2026
f23f6e5
margin: gate place_market_order_and_repay_loan on monotonic, not min_…
tonylee08 Jun 24, 2026
b70b281
docs(move.md): sync DeepBook Margin notes with the monotonic gate change
tonylee08 Jun 24, 2026
0d0235f
margin: relax reduce-only bid to direction-only (drop the net-debt si…
tonylee08 Jun 25, 2026
63ca8cc
margin: PoC tests for the empty-book resting-fill self-trade concern
tonylee08 Jun 25, 2026
f6ea961
margin: adversarial suite — manager can't be driven under ratio 1.0 w…
tonylee08 Jun 25, 2026
414bccd
margin: test the monotonic gate fires through place_market_order_and_…
tonylee08 Jun 25, 2026
6d164f3
docs(move.md): record the audited self-trade/empty-book safety model
tonylee08 Jun 25, 2026
240fe7c
margin: enforce TPSL market-order price bounds on the actual executed…
tonylee08 Jun 25, 2026
ed8793d
margin: rename adversarial tests to neutral names
tonylee08 Jun 25, 2026
714b397
docs(move.md): record the self-match pre-check vs realized-fill gotcha
tonylee08 Jun 25, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions .claude/rules/move.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
Loading
Loading