Skip to content

margin: add place_reduce_only_market_order_and_repay_loan#1035

Open
tonylee08 wants to merge 28 commits into
mainfrom
tlee/margin-close-and-repay
Open

margin: add place_reduce_only_market_order_and_repay_loan#1035
tonylee08 wants to merge 28 commits into
mainfrom
tlee/margin-close-and-repay

Conversation

@tonylee08

@tonylee08 tonylee08 commented May 22, 2026

Copy link
Copy Markdown
Collaborator

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)

  • Adds 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.
  • The reduce-only quantity check is a direction guard, not a size cap: a bid requires base (short-side) debt, an ask requires quote (long-side) debt and sells up to gross base held. There is no debt-relative size cap — the earlier net-debt / round-up bid cap (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)

  • New per-pool min_open_risk_ratio, strictly between liquidation and min_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 at min_borrow.

Closing API

  • Adds 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-repay risk_ratio >= the pre-trade ratio; a full repay clears the debt → risk_ratio MAX → skipped). The monotonic gate — rather than the min_open floor — lets a danger-band position improve partially without having to reach min_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.
  • Adds 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 aborting place_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.
  • The monotonic gate's abort code is ERiskRatioMustNotWorsen (renamed from EReduceOnlyMustImproveRiskRatio, value 7 unchanged) since it is now shared by the reduce-only entries and the non-reduce-only market and-repay.

TP/SL execution (#8, #9, #10)

  • Adds margin_manager::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 >= pre-fill ratio. This lets a stop-loss fire in the liquidation..min_borrow danger 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.
  • The repay owner check moves 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). Shared collect/place/finalize helpers back both v2 and v3.
  • Self-match-aware price bounds. The conditional market-order price-bound pre-check simulates the fill against the whole book (get_quote_quantity_out), but execution uses the order's self_matching_option; with cancel_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 shared place_triggered_orders): after a triggered market order fills, the actual executed price (cumulative_quote / executed) is re-verified against the same oracle bounds, aborting EFillOutsidePriceBounds otherwise. Limit fills are already bounded by their own limit price.
  • Pool Updates #9 (TTL) is doc-only: a market conditional order has no expiry, so it is the "until cancelled" stop; only a limit conditional's placed order is clamped to max_order_ttl_ms. Documented on add_conditional_order.

Key decisions

  • Upgrade-compatible. No existing public signature or stored struct (RiskRatios) changed: min_open is a derived default + dynamic-field override, and the deleveraging executor is a new _v3 rather than a &mut change to v2. New functions / dynamic fields are the additive levers.
  • Reduce-only is a direction guard, not a size cap. Over-covering a short is price-invariant and the cap was bypassable, so the direction guard (a short can only bid, a long can only ask) is the load-bearing protection — it also bounds any self-trade leak to a single ~5%-band conversion, so a manager can't be ratcheted below risk_ratio 1.0 without real price movement (proven by an adversarial test suite).
  • Monotonic vs min_open. Opening orders gate on min_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.
  • Conditional deleverage is permissionless but safe: repay can't extract value, and the price-bound check is now enforced on the actual fill, so a self-matching market order can't settle outside the oracle bounds.

Test plan

  • sui move test --gas-limit 100000000000 in packages/deepbook_margin — 362 passed
  • sui move test --gas-limit 100000000000 in packages/margin_liquidation — 16 passed
  • bunx prettier-move on all modified .move files
  • Close/exit + bid-cap relaxation: reduce_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 close
  • Open floor: borrow-at-floor adverse fill succeeds; strict-override aborts; setter band + default-midpoint
  • Closing API: place_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_repays
  • Adversarial (no manager → risk_ratio < 1.0 without 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_neutral
  • Owner gate: test_repay_base_fails_non_owner (non-owner repay rejected)
  • TP/SL v3: execute_conditional_orders_v3_stop_loss_deleverages_in_danger_band + paired execute_conditional_orders_v2_stop_loss_aborts_in_danger_band
  • TP/SL price bounds: tpsl_cancel_maker_self_match_cannot_bypass_price_bounds (a cancel_maker market sell can't settle outside the oracle bounds even when the manager's own bid distorts the pre-check)

tonylee08 and others added 3 commits May 22, 2026 19:04
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>
@bathord

This comment was marked as outdated.

@tonylee08 tonylee08 marked this pull request as draft May 27, 2026 20:02
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>
@tonylee08 tonylee08 force-pushed the tlee/margin-close-and-repay branch from 169d6ca to 0213a67 Compare June 22, 2026 17:02
tonylee08 and others added 6 commits June 22, 2026 13:05
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>
@tonylee08 tonylee08 marked this pull request as ready for review June 22, 2026 19:08
tonylee08 and others added 2 commits June 22, 2026 15:12
…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>
Comment thread packages/deepbook_margin/sources/pool_proxy.move
Comment thread packages/deepbook_margin/sources/pool_proxy.move Outdated
Comment thread packages/deepbook_margin/sources/pool_proxy.move Outdated
tonylee08 and others added 6 commits June 24, 2026 11:04
…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>
Comment thread packages/deepbook_margin/sources/pool_proxy.move Outdated
tonylee08 and others added 4 commits June 24, 2026 15:50
…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>
tonylee08 and others added 6 commits June 25, 2026 12:55
…/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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants