margin: add place_reduce_only_market_order_and_repay_loan#1035
Open
tonylee08 wants to merge 28 commits into
Open
margin: add place_reduce_only_market_order_and_repay_loan#1035tonylee08 wants to merge 28 commits into
tonylee08 wants to merge 28 commits into
Conversation
Bundles a reduce-only market order with a loan repayment so a leveraged position can be wound down atomically. The standalone reduce-only entry checks risk_ratio on the swap-only state, before any repay — a market close pays the spread, which strictly lowers the oracle-valued ratio and aborts. Repaying first deleverages the manager, lifting the ratio and absorbing the slippage; the monotonic check then passes on the net state. Slippage stays bounded by the price-tolerance band, so deferring the solvency check past the repay does not weaken security. This is also the only wind-down path for a manager whose ratio has drifted into the liquidation..min_borrow danger zone: it cannot reach the borrow floor in a single swap, but it can climb out by deleveraging. Exposes margin_manager::risk_ratio_int and ::repay as public(package) so pool_proxy can compute the single-debt-pool risk ratio and repay; both were already the internal impls (no new wrappers). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- Keep `repay` at its original location, only widening it to public(package) (minimal in-place visibility change rather than a relocation). - Drop the post-trade `withdraw_settled_amounts` call: `place_market_order` already settles the taker fill into the manager's balance via `vault.settle_balance_manager`, so the proceeds are drawable by `repay`. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Rework the entry to take both margin pools (distinct objects, so no &mut aliasing) and use the existing public `risk_ratio` (two-pool) and `repay_base`/`repay_quote`, mirroring `place_reduce_only_market_order_v2`. This reverts margin_manager.move entirely: `risk_ratio_int` and `repay` stay private — no public(package) widening was needed. Also tightens the entry's doc comment. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This comment was marked as outdated.
This comment was marked as outdated.
Move the reduce-only quantity cap from net debt to gross holdings so a position can be fully unwound: the ask (closing a long) may sell up to the full base the manager holds, while the bid (covering a short) stays capped at the net short (base_debt - base_asset) so it cannot flip into a fresh long. Applied to place_reduce_only_limit_order_v2 and the close-and-repay entry. Document place_reduce_only_market_order_v2 as superseded. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
169d6ca to
0213a67
Compare
Capture the flip-into-fresh-long bug and why the unwind cap must be gross on the selling side but net on the buying side. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
#2) Opening near-max leverage aborted because the post-trade solvency check reused min_borrow (1.25), leaving no room for the opening trade's spread. Add a per-pool min_open_risk_ratio floor between liquidation and min_borrow: assert_post_trade_solvent now checks against it instead. Default is the midpoint of liquidation and min_borrow, stored as an optional dynamic-field override (mirrors MaxOrderTtlKey) that self-heals if a later risk-param change strands it. Admin-set via set_min_open_risk_ratio. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ow floor Capture why the post-trade open floor must differ from the borrow gate and the self-healing derived-default override pattern. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… band (#8, #10) Add execute_conditional_orders_v3: takes the margin pools as &mut and, after each market-type conditional fill, repays the loan with the proceeds, then gates on net (post-repay) risk_ratio >= the pre-fill ratio. This lets a stop-loss fire in the liquidation..min_borrow band — a swap alone only lowers the oracle-valued ratio, which the v2 borrow-floor gate rejects — and the order executes and is removed instead of looping the bot (#10). v2 is left unchanged for upgrade compatibility; integrators migrate to v3. Move the repay owner check from the internal repay into the public repay_base/ repay_quote wrappers so the permissionless executor can deleverage (safe: repay only moves the manager's own funds against its own debt). Share the collect/place/finalize helpers between the v2 and v3 executors. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Capture why danger-band stops must deleverage (not just swap), the permissionless-repay owner-gating move, and the new-_vN-entry rule for &mut/signature changes on the deployed margin package. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Clarify on add_conditional_order that the conditional itself never expires; a market pending order is the "until cancelled" stop (fires + deleverages via v3), while a limit pending order's placed order is clamped to max_order_ttl_ms as the stale-price guard. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…-repay # Conflicts: # .claude/rules/move.md
…a v3 (#8) Close the two coverage gaps from the audit: a normal order placed with free borrowed funds in the [min_open, min_borrow) band succeeds (#3), and a limit-type stop-loss rests (not aborts) and is removed via v3 in the danger band (#8). Test-only — no source changes. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
avernikoz
reviewed
Jun 24, 2026
bathord
reviewed
Jun 24, 2026
bathord
reviewed
Jun 24, 2026
…floor Add a non-reduce-only place_market_order_and_repay_loan: place a market order, repay the debt side, then gate on min_open post-repay. It's the clean close/deleverage tool — a full close drives debt to 0 (risk_ratio MAX) so it passes even in the danger band where place_market_order_v2 aborts, and it has no quantity cap so a non-lot-aligned (accrued-interest) debt can be cleared by overbuying past it. Safe: the repay caps the debt reduction, zero debt has no bad-debt risk, and assert_price bounds slippage. Floor the reduce-only bid cap at one min_size — (base_debt - base_asset).max( min_size) — so a sub-lot net debt isn't stuck in reduce-only (margin-disabled) mode. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Change the reduce-only bid cap from net.max(min_size) to reduce_only_bid_cap = (net.div_ceil(lot_size) * lot_size).max(min_size), so a non-lot-aligned net debt (e.g. owe 40.001 with lot 0.1) can be cleared by buying the next lot up — not just the sub-min_size case. The over-round is bounded by one lot, so the post-repay residual long is always under one lot (dust), never opening meaningful new exposure. Applied to all reduce-only entries via a shared helper. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Limit sibling of place_reduce_only_market_order_and_repay_loan: places a limit order, repays the debt with the settled taker fills before the net-state monotonic check. Closes the gap where a crossing reduce-only limit's taker portion pays the spread and aborts place_reduce_only_limit_order_v2's swap-only monotonic check — the price-bounded danger-band reduce. Resting remainder just locks balance (counted in assets), so the ratio holds. Same reduce-only cap. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- place_reduce_only_market_order_v2 ask now uses the gross base_asset cap (quote_debt > 0 && quantity <= base_asset), consistent with place_reduce_only_limit_order_v2; drops the stale net-quote ask limit and the now-unused quote_asset/quote_quantity locals. - reduce_only_bid_cap: compute the lot ceiling with modulo arithmetic instead of u64.div_ceil, which CI's older Sui std lacks (math::div_round_up is FLOAT_SCALING-scaled, not a plain integer ceiling, so it's not usable here). - Repurpose the market-v2 ask reduce-only test to assert the gross cap. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ix stale doc - Add place_market_order_and_repay_loan_aborts_when_pool_disabled: the pool_enabled gate is this endpoint's only guard not shared with the reduce-only and-repay siblings, and had no negative test. - Fix stale doc on place_reduce_only_market_order_v2 (it no longer keeps a symmetric net-debt cap; ask is now gross-base, bid is round-up-to-lot, matching the other reduce-only entries). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Add test_repay_base_fails_non_owner: the owner gate that backstops withdraw_without_owner_check had no negative test (review #1). - Strengthen overbuys-short test to a clear overshoot (150 vs 100 debt) and assert the 50 surplus lands as manager-owned base, not lost/aborted (#7). - Fix reduce_only_bid_cap doc: the residual over-cover is bounded by one lot OR one min_size (floor branch can exceed a lot when min_size > lot_size), not 'always under one lot'; note monotonic/solvency is the real guard (#3/#4). - Pin the single-debt-side invariant (single margin_pool_id) at the v3 repay dispatch so has_base_debt-else-quote reads as exhaustive (#6). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
bathord
reviewed
Jun 24, 2026
…open Per integration feedback: the min_open opening floor is mismatched for a close/deleverage tool — it rejects partial danger-band improvements (e.g. 1.12 -> 1.15 where 1.15 < min_open). Swap it for the net-state monotonic gate (post-repay risk_ratio >= pre-trade, skipped when a full repay clears debt). 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. This makes the quantity cap unnecessary for the market case (monotonic subsumes reduce-only intent for immediate fills). - Rename EReduceOnlyMustImproveRiskRatio -> ERiskRatioMustNotWorsen (value 7 unchanged) since the gate is now shared with this non-reduce-only entry; broaden its doc. Integrators get one consistent code for a failed close. - Add place_market_order_and_repay_loan_partial_close_below_min_open_ok: a partial danger-band close that improves but stays below min_open now passes (would abort under the old floor). The worsening-aborts direction is already covered by the shared ERiskRatioMustNotWorsen reduce-only taker-fill tests. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- place_market_order_and_repay_loan gates on the net-state monotonic check, not the min_open floor (it no longer calls assert_post_trade_solvent). - Error constant renamed EReduceOnlyMustImproveRiskRatio -> ERiskRatioMustNotWorsen (code 7 unchanged), now shared with that non-reduce-only entry. - Fix the reduce_only_bid_cap over-cover bound: one lot OR one min_size (the floor branch can exceed a lot when min_size > lot_size), not 'always under one lot'. - Fix the assert_post_trade_solvent floor mention (min_open, not min_borrow). - Add the design rationale for why the monotonic gate is market-only and a limit and-repay must keep the cap (placement-time check vs. ungated maker fill). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ze cap) Per integrator (Deeptrade) feedback: the net-debt bid size cap guarded a non-risk and wasn't enforceable. - Over-buying past the debt on a reduce-only bid converts quote into base, which for a base-denominated debt is price-invariant (28 SUI held vs 25 SUI debt is ratio 28/25 at every price) — it *reduces*, not increases, ratio exposure. - The cap was bypassable anyway (stacking reduce-only orders in one PTB exceeds the net debt), so it was friction, not protection. Across all four reduce-only entries the bid predicate becomes `base_debt > 0` (direction guard only, symmetric with the ask's `quote_debt > 0 && quantity <= base_asset`); funding + the monotonic / post-repay gate bound the size. Delete the orphaned reduce_only_bid_cap helper. The direction guard is the real boundary — it blocks a long (base_debt == 0) from bidding, the only genuinely exposure-increasing misuse. Tests: - Repurpose reduce_only_and_repay_quantity_exceeds_debt_aborts -> reduce_only_and_repay_bid_over_net_debt_fully_closes (over-cover now OK). - Repurpose the two *_not_reduce_only_quantity_bid tests -> *_bid_requires_base_debt (direction-guard regression: a long can't bid). - Rename the dust tests (no longer about the removed floor/round-up). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Verify the reduce-only limit resting-fill scenario (M1 rests a band-edge bid in an empty book, a counterparty takes it) is bounded, not a pool-theft exploit: - reduce_only_limit_resting_fill_at_band_edge_bounded_and_solvent: M1 (short) rests a $1.05 bid, a plain counterparty balance manager takes it -> M1 overpays 5% UNGATED (the fill doesn't re-run M1's margin check). Asserts the leak is real (ratio drops) but bounded (M1 stays solvent, > 1.0). - test_place_reduce_only_limit_order_ask_requires_quote_debt: the one-way direction guard -- a short can't ask -- which caps the leak to one conversion (~the price band) so a manager can't be ratcheted underwater. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…/o price move Attacker-perspective tests for the 'manipulate a manager underwater via self-trades / empty book' vectors: - attack_resting_fill_from_danger_band_cannot_go_underwater: M1 drifted to *at liquidation* (1.10), a counterparty takes a maximal band-edge resting bid (ungated 5% leak) -> M1 lands at ~1.05, still > 1.0. The 5% band caps the per-conversion loss and the one-way direction guard prevents a second conversion, so it can't compound underwater. - attack_market_and_repay_empty_book_aborts: empty book -> no execution surface, aborts ENoLiquidityInOrderbook. - attack_resting_order_by_itself_is_ratio_neutral: placing a resting order with no counterparty doesn't move the manager's own ratio (locked balance counts). With the one-way guard tests (short can't ask / long can't bid) and the healthy-case resting-fill PoC, this establishes: absent real price movement, no sequence of self-trades or empty/sparse-book fills can push a manager below 1.0. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…repay_loan place_market_order_and_repay_loan_worsening_aborts: a long that spends nearly all its quote buying base at a +4% (in-band) premium increases exposure with too little leftover to repay, so the net post-repay ratio drops -> ERiskRatioMustNotWorsen. Covers the after-repay monotonic branch on the non-reduce-only entry (distinct from the v2 swap-only check). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The ungated resting fill can't drive a manager underwater without real price movement: assert_price caps each fill at 5%, the direction guard makes each manager one-way (so the leak can't compound), and liquidate cancels open orders so locked collateral is recoverable. References the adversarial PoC suite. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… fill The conditional-order price-bound pre-check simulates the market order against the whole book (get_quote_quantity_out), but execution uses the order's self_matching_option. With cancel_maker, execution removes the manager's own resting maker orders and fills deeper into worse liquidity, so a fill could settle outside the oracle bounds the pre-check approved. Add a post-execution check in place_triggered_orders (shared by the v2 and v3 executors): after a triggered MARKET order fills, re-verify the actual executed price (cumulative_quote / executed) is within the same oracle bounds, aborting EFillOutsidePriceBounds otherwise. Limit fills are already bounded by their own limit price. Regression test tpsl_cancel_maker_self_match_cannot_bypass_price_bounds reproduces the cancel_maker self-match case. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Drop the attack_ prefix on the self-trade-bound regression tests. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A pre-trade orderbook simulation (get_quote_quantity_out) is not binding under cancel_maker, which cancels the manager's own makers and fills deeper. Enforce price bounds on the realized fill (EFillOutsidePriceBounds), not the simulated one. Documents the TPSL fix's pattern + trade-off. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Addresses the DeepBook Margin v5 UX issues raised by Deeptrade. Issues #1–#6 and #8/#9/#10 are all covered.
Summary
Exit / close path (#1, #4, #5, #6)
pool_proxy::place_reduce_only_market_order_and_repay_loan: an atomic close that places a reduce-only market order, repays the loan with the proceeds, then enforces the monotonic invariant on the net (post-repay) state. A swap alone only lowers the oracle-valued ratio, so repaying first is what lets a danger-zone manager wind down.reduce_only_bid_cap) was removed: over-covering a short converts quote into base, which for a base-denominated debt is price-invariant, so it can't raise ratio-exposure; and the cap was bypassable anyway (stacking orders in one PTB). The direction guard blocks the only exposure-increasing misuse (a long bidding to grow its long).Opening floor (#2)
min_open_risk_ratio, strictly betweenliquidationandmin_borrow; the post-trade check on opening orders (place_limit_order_v2/place_market_order_v2) uses it so a max-leverage open can absorb its own spread. Derived-default (midpoint) + optional admin dynamic-field override that self-heals if a risk-param change strands it. Borrow gate stays atmin_borrow.Closing API
pool_proxy::place_market_order_and_repay_loan— the everyday close/deleverage tool. Places a market order, repays the debt side, then gates on the net-state monotonic check (post-repayrisk_ratio >=the pre-trade ratio; a full repay clears the debt →risk_ratioMAX → skipped). The monotonic gate — rather than themin_openfloor — lets a danger-band position improve partially without having to reachmin_open(e.g. lift 1.12 → 1.15). Safe because a market (taker) fill settles immediately, so the check reflects the true final state: any exposure-increasing trade aborts, any deleveraging trade is allowed at any size, so no quantity cap is needed. Requires margin trading enabled; reduce-only entries remain for the disabled mode.pool_proxy::place_reduce_only_limit_order_and_repay_loan— the limit sibling of the reduce-only market-and-repay: it repays the debt with the settled taker fills before the net-state monotonic check, so a crossing reduce-only limit (whose taker portion pays the spread) deleverages instead of abortingplace_reduce_only_limit_order_v2's swap-only monotonic check. The resting remainder just locks balance (ratio unchanged). For price-bounded reduces in the danger band.ERiskRatioMustNotWorsen(renamed fromEReduceOnlyMustImproveRiskRatio, value7unchanged) since it is now shared by the reduce-only entries and the non-reduce-only market and-repay.TP/SL execution (#8, #9, #10)
margin_manager::execute_conditional_orders_v3: takes the margin pools as&mutand, after each market-type conditional fill, repays the loan with the proceeds, then gates on net (post-repay)risk_ratio >= pre-fill ratio. This lets a stop-loss fire in theliquidation..min_borrowdanger band (the v2 borrow-floor gate rejects a swap-only fill there) and the order executes and is removed instead of looping the bot. v2 is unchanged.repayinto the publicrepay_base/repay_quotewrappers so the permissionless executor can deleverage (safe: repay only moves the manager's own funds against its own debt). Sharedcollect/place/finalizehelpers back both v2 and v3.get_quote_quantity_out), but execution uses the order'sself_matching_option; withcancel_maker, execution cancels the manager's own resting maker orders and can then fill deeper into worse liquidity — so a fill could settle outside the oracle bounds the pre-check approved. Added a post-execution check (in the sharedplace_triggered_orders): after a triggered market order fills, the actual executed price (cumulative_quote / executed) is re-verified against the same oracle bounds, abortingEFillOutsidePriceBoundsotherwise. Limit fills are already bounded by their own limit price.max_order_ttl_ms. Documented onadd_conditional_order.Key decisions
RiskRatios) changed:min_openis a derived default + dynamic-field override, and the deleveraging executor is a new_v3rather than a&mutchange to v2. New functions / dynamic fields are the additive levers.risk_ratio1.0 without real price movement (proven by an adversarial test suite).min_open. Opening orders gate onmin_open(a floor); close/deleverage orders gate on monotonic (improve-or-hold). Monotonic is airtight for a market order (immediate fill == final state); for a limit, the resting remainder is protected by the direction guard + the price band, not the monotonic check.Test plan
sui move test --gas-limit 100000000000inpackages/deepbook_margin— 362 passedsui move test --gas-limit 100000000000inpackages/margin_liquidation— 16 passedbunx prettier-moveon all modified.movefilesreduce_only_and_repay_bid_over_net_debt_fully_closes(over-cover fully closes),*_bid_requires_base_debt/*_ask_requires_quote_debt(direction guard, both sides), gross-ask boundary, swap-only-fails-vs-and-repay, danger-zone closeplace_market_order_and_repay_loan_fully_closes_long,..._overbuys_short_past_debt,..._partial_close_below_min_open_ok,..._worsening_aborts,..._aborts_when_pool_disabled,reduce_only_limit_and_repay_partial_taker_fill_repaysrisk_ratio < 1.0without price movement):resting_fill_from_danger_band_cannot_go_underwater,reduce_only_limit_resting_fill_at_band_edge_bounded_and_solvent,market_and_repay_empty_book_aborts,resting_order_by_itself_is_ratio_neutraltest_repay_base_fails_non_owner(non-owner repay rejected)execute_conditional_orders_v3_stop_loss_deleverages_in_danger_band+ pairedexecute_conditional_orders_v2_stop_loss_aborts_in_danger_bandtpsl_cancel_maker_self_match_cannot_bypass_price_bounds(acancel_makermarket sell can't settle outside the oracle bounds even when the manager's own bid distorts the pre-check)