Skip to content

Commit e87b758

Browse files
Check Leverage on Withdrawals and Updates (Backport #3153) (#3223)
1 parent 2f05415 commit e87b758

File tree

4 files changed

+345
-10
lines changed

4 files changed

+345
-10
lines changed

protocol/x/clob/keeper/leverage_e2e_test.go

Lines changed: 282 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010
"github.com/dydxprotocol/v4-chain/protocol/testutil/constants"
1111
assettypes "github.com/dydxprotocol/v4-chain/protocol/x/assets/types"
1212
clobtypes "github.com/dydxprotocol/v4-chain/protocol/x/clob/types"
13+
sendingtypes "github.com/dydxprotocol/v4-chain/protocol/x/sending/types"
1314
satypes "github.com/dydxprotocol/v4-chain/protocol/x/subaccounts/types"
1415
"github.com/stretchr/testify/require"
1516
)
@@ -264,3 +265,284 @@ func TestOrderPlacementFailsWithLeverageConfigured(t *testing.T) {
264265
require.False(t, resp.IsOK(), "Expected Alice's CheckTx to fail due to leverage. Response: %+v", resp)
265266
}
266267
}
268+
269+
func TestWithdrawalWithLeverage(t *testing.T) {
270+
tApp := testapp.NewTestAppBuilder(t).Build()
271+
ctx := tApp.InitChain()
272+
273+
// Use CheckTx to set leverage (this goes through ante handler which persists it)
274+
leverageMsg := &clobtypes.MsgUpdateLeverage{
275+
SubaccountId: &constants.Alice_Num0,
276+
ClobPairLeverage: []*clobtypes.LeverageEntry{
277+
{
278+
ClobPairId: 0,
279+
CustomImfPpm: 200_000,
280+
},
281+
},
282+
}
283+
for _, checkTx := range testapp.MustMakeCheckTxsWithClobMsg(ctx, tApp.App, *leverageMsg) {
284+
resp := tApp.CheckTx(checkTx)
285+
require.True(t, resp.IsOK(), "Expected leverage CheckTx to succeed. Response: %+v", resp)
286+
}
287+
288+
ctx = tApp.AdvanceToBlock(2, testapp.AdvanceToBlockOptions{})
289+
290+
// Verify leverage was set immediately (ante handler sets it)
291+
aliceLeverageMap, found := tApp.App.SubaccountsKeeper.GetLeverage(ctx, &constants.Alice_Num0)
292+
require.True(t, found, "Alice's leverage should be set")
293+
require.Equal(
294+
t,
295+
uint32(200_000),
296+
aliceLeverageMap[0],
297+
"Alice's custom imf ppm for perpetual 0 should be 200,000",
298+
)
299+
300+
// Use orders large enough to have meaningful margin requirements
301+
// Both Alice and Bob trade 1,000,000 quantums (0.0001 BTC) at price 5,000,000,000 subticks ($50,000/BTC)
302+
// This creates a $5 notional position which will have measurable IMR
303+
// Quantums must be multiple of StepBaseQuantums = 10
304+
// Subticks must be multiple of SubticksPerTick = 10000
305+
aliceOrder := clobtypes.Order{
306+
OrderId: clobtypes.OrderId{
307+
SubaccountId: constants.Alice_Num0,
308+
ClientId: 0,
309+
OrderFlags: clobtypes.OrderIdFlags_ShortTerm,
310+
ClobPairId: 0,
311+
},
312+
Side: clobtypes.Order_SIDE_BUY,
313+
Quantums: 1_000_000, // 0.0001 BTC
314+
Subticks: 5_000_000_000, // $50,000 per BTC
315+
GoodTilOneof: &clobtypes.Order_GoodTilBlock{
316+
GoodTilBlock: 20,
317+
},
318+
}
319+
320+
bobOrder := clobtypes.Order{
321+
OrderId: clobtypes.OrderId{
322+
SubaccountId: constants.Bob_Num0,
323+
ClientId: 0,
324+
OrderFlags: clobtypes.OrderIdFlags_ShortTerm,
325+
ClobPairId: 0,
326+
},
327+
Side: clobtypes.Order_SIDE_BUY,
328+
Quantums: 1_000_000, // 0.0001 BTC
329+
Subticks: 5_000_000_000, // $50,000 per BTC
330+
GoodTilOneof: &clobtypes.Order_GoodTilBlock{
331+
GoodTilBlock: 20,
332+
},
333+
}
334+
335+
carlSellOrder := clobtypes.Order{
336+
OrderId: clobtypes.OrderId{
337+
SubaccountId: constants.Carl_Num0,
338+
ClientId: 0,
339+
OrderFlags: clobtypes.OrderIdFlags_ShortTerm,
340+
ClobPairId: 0,
341+
},
342+
Side: clobtypes.Order_SIDE_SELL,
343+
Quantums: 2_000_000, // 0.0002 BTC
344+
Subticks: 5_000_000_000, // $50,000 per BTC
345+
GoodTilOneof: &clobtypes.Order_GoodTilBlock{
346+
GoodTilBlock: 20,
347+
},
348+
}
349+
350+
// Place all orders
351+
for _, checkTx := range testapp.MustMakeCheckTxsWithClobMsg(
352+
ctx,
353+
tApp.App,
354+
*clobtypes.NewMsgPlaceOrder(carlSellOrder),
355+
) {
356+
resp := tApp.CheckTx(checkTx)
357+
require.True(t, resp.IsOK(), "Expected Carl's order to succeed. Response: %+v", resp)
358+
}
359+
360+
for _, checkTx := range testapp.MustMakeCheckTxsWithClobMsg(
361+
ctx,
362+
tApp.App,
363+
*clobtypes.NewMsgPlaceOrder(bobOrder),
364+
) {
365+
resp := tApp.CheckTx(checkTx)
366+
require.True(t, resp.IsOK(), "Expected Bob's order to succeed. Response: %+v", resp)
367+
}
368+
369+
for _, checkTx := range testapp.MustMakeCheckTxsWithClobMsg(
370+
ctx,
371+
tApp.App,
372+
*clobtypes.NewMsgPlaceOrder(aliceOrder),
373+
) {
374+
resp := tApp.CheckTx(checkTx)
375+
require.True(t, resp.IsOK(), "Expected Alice's order to succeed. Response: %+v", resp)
376+
}
377+
378+
// Advance to next block which matches the orders
379+
ctx = tApp.AdvanceToBlock(3, testapp.AdvanceToBlockOptions{})
380+
381+
// Get final subaccount states
382+
aliceAfter := tApp.App.SubaccountsKeeper.GetSubaccount(ctx, constants.Alice_Num0)
383+
bobAfter := tApp.App.SubaccountsKeeper.GetSubaccount(ctx, constants.Bob_Num0)
384+
385+
// Both should have positions now
386+
require.Len(t, aliceAfter.PerpetualPositions, 1, "Alice should have 1 perpetual position")
387+
require.Len(t, bobAfter.PerpetualPositions, 1, "Bob should have 1 perpetual position")
388+
389+
// Verify the positions are equal in size but opposite in direction
390+
require.Equal(t, uint64(1_000_000), new(big.Int).Abs(aliceAfter.PerpetualPositions[0].GetBigQuantums()).Uint64())
391+
require.Equal(t, uint64(1_000_000), new(big.Int).Abs(bobAfter.PerpetualPositions[0].GetBigQuantums()).Uint64())
392+
393+
// Get actual risk calculations to verify leverage is being applied
394+
aliceRisk, err := tApp.App.SubaccountsKeeper.GetNetCollateralAndMarginRequirements(
395+
ctx,
396+
satypes.Update{SubaccountId: constants.Alice_Num0},
397+
)
398+
require.NoError(t, err)
399+
400+
bobRisk, err := tApp.App.SubaccountsKeeper.GetNetCollateralAndMarginRequirements(
401+
ctx,
402+
satypes.Update{SubaccountId: constants.Bob_Num0},
403+
)
404+
require.NoError(t, err)
405+
406+
// Alice's IMR should be higher due to lower leverage (more conservative)
407+
require.True(t, aliceRisk.IMR.Cmp(bobRisk.IMR) > 0,
408+
"Alice's IMR (%s) should be higher than Bob's IMR (%s) due to lower leverage setting (5x vs 20x)",
409+
aliceRisk.IMR.String(), bobRisk.IMR.String())
410+
411+
// Calculate available collateral for new positions: NC - IMR
412+
aliceAvailable := new(big.Int).Sub(aliceRisk.NC, aliceRisk.IMR)
413+
bobAvailable := new(big.Int).Sub(bobRisk.NC, bobRisk.IMR)
414+
415+
// Verify Bob has more available collateral than Alice due to lower leverage requirements
416+
require.True(t, bobAvailable.Cmp(aliceAvailable) > 0,
417+
"Bob's available collateral (%s) should be greater than Alice's (%s) due to lower IMR from higher leverage",
418+
bobAvailable.String(), aliceAvailable.String())
419+
420+
// Let's try to withdraw bob's available collateral from both subaccounts
421+
422+
bobWithdrawal := &sendingtypes.MsgWithdrawFromSubaccount{
423+
Sender: constants.Bob_Num0,
424+
Recipient: constants.BobAccAddress.String(),
425+
AssetId: constants.Usdc.Id,
426+
Quantums: bobAvailable.Uint64(),
427+
}
428+
429+
for _, checkTx := range testapp.MustMakeCheckTxsWithSdkMsg(
430+
ctx,
431+
tApp.App,
432+
testapp.MustMakeCheckTxOptions{
433+
AccAddressForSigning: constants.Bob_Num0.Owner,
434+
Gas: constants.TestGasLimit,
435+
FeeAmt: constants.TestFeeCoins_5Cents,
436+
},
437+
bobWithdrawal,
438+
) {
439+
resp := tApp.CheckTx(checkTx)
440+
require.True(t, resp.IsOK(), "Expected Bob's withdrawal to succeed. Response: %+v", resp)
441+
}
442+
443+
aliceWithdrawal := &sendingtypes.MsgWithdrawFromSubaccount{
444+
Sender: constants.Alice_Num0,
445+
Recipient: constants.AliceAccAddress.String(),
446+
AssetId: constants.Usdc.Id,
447+
Quantums: bobAvailable.Uint64(), // using bob's available collateral which is greater than alice's
448+
}
449+
450+
for _, checkTx := range testapp.MustMakeCheckTxsWithSdkMsg(
451+
ctx,
452+
tApp.App,
453+
testapp.MustMakeCheckTxOptions{
454+
AccAddressForSigning: constants.Alice_Num0.Owner,
455+
Gas: constants.TestGasLimit * 10,
456+
FeeAmt: constants.TestFeeCoins_5Cents,
457+
},
458+
aliceWithdrawal,
459+
) {
460+
resp := tApp.CheckTx(checkTx)
461+
require.False(t, resp.IsOK(), "Expected Alice's withdrawal to fail. Response: %+v", resp)
462+
}
463+
}
464+
465+
func TestUpdateLeverageWithExistingPosition(t *testing.T) {
466+
tApp := testapp.NewTestAppBuilder(t).Build()
467+
ctx := tApp.InitChain()
468+
469+
// Place orders for both Alice and Bob that would require the entire margin if leverage was unchanged
470+
orderSize := dtypes.NewIntFromBigInt(big.NewInt(5_500_000_000_000_000))
471+
472+
// Use the same price and clob pair as in the other test
473+
price := uint64(2_000_000_000)
474+
475+
// Bob's order should succeed
476+
bobOrder := &clobtypes.Order{
477+
OrderId: clobtypes.OrderId{
478+
SubaccountId: constants.Bob_Num0,
479+
ClientId: 0,
480+
OrderFlags: clobtypes.OrderIdFlags_LongTerm,
481+
ClobPairId: 0,
482+
},
483+
Side: clobtypes.Order_SIDE_SELL,
484+
Quantums: orderSize.BigInt().Uint64(),
485+
Subticks: price,
486+
GoodTilOneof: &clobtypes.Order_GoodTilBlockTime{
487+
GoodTilBlockTime: uint32(ctx.BlockTime().Unix() + 100),
488+
},
489+
}
490+
for _, checkTx := range testapp.MustMakeCheckTxsWithClobMsg(
491+
ctx,
492+
tApp.App,
493+
*clobtypes.NewMsgPlaceOrder(*bobOrder),
494+
) {
495+
resp := tApp.CheckTx(checkTx)
496+
require.True(t, resp.IsOK(), "Expected Bob's CheckTx to succeed. Response: %+v", resp)
497+
}
498+
499+
bobSubaccount := tApp.App.SubaccountsKeeper.GetSubaccount(ctx, constants.Bob_Num0)
500+
require.True(t, bobSubaccount.AssetPositions != nil, "Bob should have a subaccount")
501+
502+
// Alice's order should succeed (no leverage configured yet)
503+
aliceOrder := &clobtypes.Order{
504+
OrderId: clobtypes.OrderId{
505+
SubaccountId: constants.Alice_Num0,
506+
ClientId: 0,
507+
OrderFlags: clobtypes.OrderIdFlags_LongTerm,
508+
ClobPairId: 0,
509+
},
510+
Side: clobtypes.Order_SIDE_BUY,
511+
Quantums: orderSize.BigInt().Uint64(),
512+
Subticks: price,
513+
GoodTilOneof: &clobtypes.Order_GoodTilBlockTime{
514+
GoodTilBlockTime: uint32(ctx.BlockTime().Unix() + 100),
515+
},
516+
}
517+
for _, checkTx := range testapp.MustMakeCheckTxsWithClobMsg(
518+
ctx,
519+
tApp.App,
520+
*clobtypes.NewMsgPlaceOrder(*aliceOrder),
521+
) {
522+
resp := tApp.CheckTx(checkTx)
523+
require.True(t, resp.IsOK(), "Expected Alice's CheckTx to succeed. Response: %+v", resp)
524+
}
525+
526+
// Advance to next block which matches the orders
527+
ctx = tApp.AdvanceToBlock(2, testapp.AdvanceToBlockOptions{})
528+
529+
// Configure leverage for Alice with an existing bitcoin position
530+
// This should make the account fail against the IMR check
531+
aliceLeverage := &clobtypes.MsgUpdateLeverage{
532+
SubaccountId: &constants.Alice_Num0,
533+
ClobPairLeverage: []*clobtypes.LeverageEntry{
534+
{
535+
ClobPairId: 0,
536+
CustomImfPpm: 500_000,
537+
},
538+
},
539+
}
540+
for _, checkTx := range testapp.MustMakeCheckTxsWithClobMsg(
541+
ctx,
542+
tApp.App,
543+
*aliceLeverage,
544+
) {
545+
resp := tApp.CheckTx(checkTx)
546+
require.False(t, resp.IsOK(), "Expected Alice's CheckTx to fail. Response: %+v", resp)
547+
}
548+
}

protocol/x/subaccounts/keeper/leverage.go

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
package keeper
22

33
import (
4+
"math/big"
5+
46
errorsmod "cosmossdk.io/errors"
57
"cosmossdk.io/store/prefix"
68
sdk "github.com/cosmos/cosmos-sdk/types"
@@ -106,11 +108,50 @@ func (k Keeper) UpdateLeverage(
106108
existingLeverage[perpetualId] = custom_imf_ppm
107109
}
108110

111+
// Check if the new leverage values break margin requirements
112+
err := k.checkNewLeverageAgainstMarginRequirements(ctx, subaccountId, perpetualLeverage)
113+
if err != nil {
114+
return err
115+
}
116+
109117
// Store updated leverage
110118
k.SetLeverage(ctx, subaccountId, existingLeverage)
111119
return nil
112120
}
113121

122+
// construct empty updates for each perpetual for which leverage is configured
123+
func (k Keeper) checkNewLeverageAgainstMarginRequirements(
124+
ctx sdk.Context,
125+
subaccountId *types.SubaccountId,
126+
leverageMap map[uint32]uint32,
127+
) (err error) {
128+
for perpetualId := range leverageMap {
129+
update := types.Update{
130+
SubaccountId: *subaccountId,
131+
PerpetualUpdates: []types.PerpetualUpdate{
132+
{
133+
PerpetualId: perpetualId,
134+
BigQuantumsDelta: big.NewInt(0),
135+
},
136+
},
137+
}
138+
139+
// check margin requirements with new leverage configuration
140+
risk, err := k.GetNetCollateralAndMarginRequirementsWithLeverage(ctx, update, leverageMap)
141+
if err != nil {
142+
return err
143+
}
144+
if !risk.IsInitialCollateralized() {
145+
return errorsmod.Wrapf(
146+
types.ErrLeverageViolatesMarginRequirements,
147+
"subaccount %s violates margin requirements with new leverage",
148+
subaccountId.String(),
149+
)
150+
}
151+
}
152+
return nil
153+
}
154+
114155
// GetMinImfForPerpetual returns the IMF ppm allowed for a perpetual
115156
// based on its liquidity tier's initial margin requirement.
116157
func (k Keeper) GetMinImfForPerpetual(ctx sdk.Context, perpetualId uint32) (uint32, error) {

0 commit comments

Comments
 (0)