Skip to content

Commit b4dff41

Browse files
check margin on leverage updates
1 parent 2b5572a commit b4dff41

4 files changed

Lines changed: 340 additions & 10 deletions

File tree

protocol/x/clob/keeper/leverage_e2e_test.go

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

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) {

protocol/x/subaccounts/keeper/subaccount.go

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -728,6 +728,22 @@ func (k Keeper) internalCanUpdateSubaccountsWithLeverage(
728728
return success, successPerUpdate, nil
729729
}
730730

731+
func (k Keeper) GetNetCollateralAndMarginRequirements(
732+
ctx sdk.Context,
733+
update types.Update,
734+
) (
735+
risk margin.Risk,
736+
err error,
737+
) {
738+
// Get leverage configuration for this subaccount
739+
var leverageMap map[uint32]uint32
740+
if leverage, found := k.GetLeverage(ctx, &update.SubaccountId); found {
741+
leverageMap = leverage
742+
}
743+
744+
return k.GetNetCollateralAndMarginRequirementsWithLeverage(ctx, update, leverageMap)
745+
}
746+
731747
// GetNetCollateralAndMarginRequirements returns the total net collateral, total initial margin requirement,
732748
// and total maintenance margin requirement for the subaccount as if the `update` was applied.
733749
// It is used to get information about speculative changes to the subaccount.
@@ -738,9 +754,10 @@ func (k Keeper) internalCanUpdateSubaccountsWithLeverage(
738754
// If two position updates reference the same position, an error is returned.
739755
//
740756
// All return values are denoted in quote quantums.
741-
func (k Keeper) GetNetCollateralAndMarginRequirements(
757+
func (k Keeper) GetNetCollateralAndMarginRequirementsWithLeverage(
742758
ctx sdk.Context,
743759
update types.Update,
760+
leverageMap map[uint32]uint32,
744761
) (
745762
risk margin.Risk,
746763
err error,
@@ -760,12 +777,6 @@ func (k Keeper) GetNetCollateralAndMarginRequirements(
760777
}
761778
updatedSubaccount := salib.CalculateUpdatedSubaccount(settledUpdate, perpInfos)
762779

763-
// Get leverage configuration for this subaccount
764-
var leverageMap map[uint32]uint32
765-
if leverage, found := k.GetLeverage(ctx, &update.SubaccountId); found {
766-
leverageMap = leverage
767-
}
768-
769780
return salib.GetRiskForSubaccount(
770781
updatedSubaccount,
771782
perpInfos,

0 commit comments

Comments
 (0)