Skip to content

Commit e8d42f9

Browse files
tonylee08claude
andcommitted
margin: address review — owner-gate test, surplus assert, comment fixes
- 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>
1 parent 1519920 commit e8d42f9

4 files changed

Lines changed: 69 additions & 11 deletions

File tree

packages/deepbook_margin/sources/margin_manager.move

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2059,7 +2059,10 @@ fun process_collected_orders_v3<BaseAsset, QuoteAsset>(
20592059

20602060
// Deleverage with the market proceeds so the fill improves (not just holds)
20612061
// solvency. The repay only moves the manager's own funds against its own
2062-
// debt, so it is safe in this permissionless path.
2062+
// debt, so it is safe in this permissionless path. A manager holds at most
2063+
// one debt side at a time (single `margin_pool_id: Option<ID>`, enforced by
2064+
// `can_borrow`), so `has_base_debt()`-else-quote is an exhaustive dispatch,
2065+
// not a both-sides-simultaneously assumption.
20632066
if (market_filled && has_debt) {
20642067
if (self.has_base_debt()) {
20652068
self.repay<BaseAsset, QuoteAsset, BaseAsset>(

packages/deepbook_margin/sources/pool_proxy.move

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -999,9 +999,13 @@ public fun claim_rebates<BaseAsset, QuoteAsset>(
999999

10001000
/// Reduce-only bid quantity cap for covering a short. The net short is rounded
10011001
/// *up* to the next lot so a non-lot-aligned debt (e.g. accrued interest) can be
1002-
/// fully cleared, and floored at one `min_size` order so a sub-lot net debt
1003-
/// isn't stuck. The round-up is bounded by one lot, so the post-repay residual
1004-
/// long is always under one lot (dust) — it never opens meaningful new exposure.
1002+
/// fully cleared, and floored at one `min_size` so a sub-`min_size` net debt
1003+
/// isn't stuck below the orderbook minimum. The over-cover is therefore bounded
1004+
/// by one lot (round-up branch) or one `min_size` (floor branch — which can
1005+
/// exceed a lot when `min_size > lot_size`); either way it's a fixed bound, not
1006+
/// open-ended exposure. The reduce-only monotonic check — or, in the and-repay
1007+
/// entries, the post-repay solvency gate — is what actually guards value; this
1008+
/// cap only enforces reduce-only *semantics*.
10051009
fun reduce_only_bid_cap<BaseAsset, QuoteAsset>(
10061010
pool: &Pool<BaseAsset, QuoteAsset>,
10071011
net_debt: u64,

packages/deepbook_margin/tests/margin_manager_tests.move

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -890,6 +890,55 @@ fun test_repay_fails_wrong_pool() {
890890
abort
891891
}
892892

893+
#[test, expected_failure(abort_code = margin_manager::EInvalidMarginManagerOwner)]
894+
fun test_repay_base_fails_non_owner() {
895+
// The owner gate that backstops withdraw_without_owner_check: a non-owner
896+
// cannot reach `repay` (and the underlying withdraw) through the public
897+
// repay_base wrapper — validate_owner aborts first. The only owner-unchecked
898+
// callers of withdraw_without_owner_check are the internal permissionless
899+
// paths (liquidation, conditional execution), which route funds into the
900+
// manager's own debt or to a liquidator under the reward formula, never to an
901+
// arbitrary caller.
902+
let (
903+
mut scenario,
904+
clock,
905+
_admin_cap,
906+
_maintainer_cap,
907+
_usdc_pool_id,
908+
usdt_pool_id,
909+
_pool_id,
910+
registry_id,
911+
) = setup_usdc_usdt_deepbook_margin();
912+
913+
scenario.next_tx(test_constants::user1());
914+
let mut registry = scenario.take_shared<MarginRegistry>();
915+
let pool = scenario.take_shared<Pool<USDT, USDC>>();
916+
let deepbook_registry = scenario.take_shared_by_id<Registry>(registry_id);
917+
margin_manager::new<USDT, USDC>(
918+
&pool,
919+
&deepbook_registry,
920+
&mut registry,
921+
&clock,
922+
scenario.ctx(),
923+
);
924+
return_shared(deepbook_registry);
925+
926+
// A different account (not the manager owner) attempts to repay.
927+
scenario.next_tx(test_constants::user2());
928+
let mut mm = scenario.take_shared<MarginManager<USDT, USDC>>();
929+
let mut usdt_pool = scenario.take_shared_by_id<MarginPool<USDT>>(usdt_pool_id);
930+
931+
mm.repay_base<USDT, USDC>(
932+
&registry,
933+
&mut usdt_pool,
934+
option::none(),
935+
&clock,
936+
scenario.ctx(),
937+
);
938+
939+
abort 999
940+
}
941+
893942
#[test]
894943
fun test_repay_full_with_none() {
895944
let (

packages/deepbook_margin/tests/pool_proxy_tests.move

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6672,11 +6672,11 @@ fun place_market_order_and_repay_loan_fully_closes_long() {
66726672

66736673
#[test]
66746674
fun place_market_order_and_repay_loan_overbuys_short_past_debt() {
6675-
// A short (USDC debt, holding USDT) closes with a bid that buys *more* than
6676-
// the debt — there is no reduce-only quantity cap, so it can overshoot to
6677-
// clear the loan (e.g. round past dust). Owe 100 USDC, buy 101, repay 100,
6678-
// debt -> 0 with ~1 USDC surplus. The reduce-only bid (capped at the net
6679-
// short) would reject 101.
6675+
// A short (USDC debt, holding USDT) closes with a bid that buys well *past*
6676+
// the debt — there is no reduce-only quantity cap, so the overshoot is
6677+
// allowed. Owe 100 USDC, buy 150, repay 100, debt -> 0 with a 50 USDC surplus
6678+
// that lands as the manager's own holding (not an abort, not lost). The
6679+
// reduce-only bid (capped at the net short rounded to a lot) would reject 150.
66806680
let (
66816681
mut scenario,
66826682
clock,
@@ -6752,15 +6752,17 @@ fun place_market_order_and_repay_loan_overbuys_short_past_debt() {
67526752
&mut quote_pool,
67536753
1,
67546754
constants::self_matching_allowed(),
6755-
101 * test_constants::usdc_multiplier(), // buy 101 to cover a 100 debt
6755+
150 * test_constants::usdc_multiplier(), // buy 150 to cover a 100 debt
67566756
true, // is_bid = true (buy base to cover the short)
67576757
false,
67586758
&clock,
67596759
);
67606760
destroy(order_info);
67616761

6762-
// Overbought past the debt and fully closed.
6762+
// Overbought well past the debt and fully closed; the 50 USDC overshoot is
6763+
// retained as the manager's own base balance, not lost or aborted.
67636764
assert_eq!(mm.borrowed_base_shares(), 0);
6765+
assert_eq!(mm.base_balance(), 50 * test_constants::usdc_multiplier());
67646766

67656767
return_shared_3!(mm, pool, base_pool);
67666768
return_shared(quote_pool);

0 commit comments

Comments
 (0)