From d446a8975adf63d4e954ba449de4f310d4bcf680 Mon Sep 17 00:00:00 2001 From: Jay Yu <103467857+jayy04@users.noreply.github.com> Date: Sun, 10 Dec 2023 16:54:02 -0500 Subject: [PATCH] [CLOB-1043] replicate IsLiquidatable logic on daemon --- protocol/daemons/flags/flags.go | 39 +-- protocol/daemons/flags/flags_test.go | 8 +- .../daemons/liquidation/client/client_test.go | 183 ------------- .../daemons/liquidation/client/grpc_helper.go | 31 --- .../liquidation/client/grpc_helper_test.go | 115 +------- .../liquidation/client/sub_task_runner.go | 211 +++++++++++++-- .../client/sub_task_runner_test.go | 253 ++++++++++++++++++ protocol/lib/collections.go | 18 ++ protocol/lib/collections_test.go | 52 ++++ protocol/testutil/constants/subaccounts.go | 15 ++ protocol/x/clob/keeper/liquidations.go | 19 +- protocol/x/perpetuals/keeper/perpetual.go | 34 ++- 12 files changed, 589 insertions(+), 389 deletions(-) create mode 100644 protocol/daemons/liquidation/client/sub_task_runner_test.go diff --git a/protocol/daemons/flags/flags.go b/protocol/daemons/flags/flags.go index e386f379ab..95deee1d8b 100644 --- a/protocol/daemons/flags/flags.go +++ b/protocol/daemons/flags/flags.go @@ -20,10 +20,9 @@ const ( FlagBridgeDaemonLoopDelayMs = "bridge-daemon-loop-delay-ms" FlagBridgeDaemonEthRpcEndpoint = "bridge-daemon-eth-rpc-endpoint" - FlagLiquidationDaemonEnabled = "liquidation-daemon-enabled" - FlagLiquidationDaemonLoopDelayMs = "liquidation-daemon-loop-delay-ms" - FlagLiquidationDaemonSubaccountPageLimit = "liquidation-daemon-subaccount-page-limit" - FlagLiquidationDaemonRequestChunkSize = "liquidation-daemon-request-chunk-size" + FlagLiquidationDaemonEnabled = "liquidation-daemon-enabled" + FlagLiquidationDaemonLoopDelayMs = "liquidation-daemon-loop-delay-ms" + FlagLiquidationDaemonQueryPageLimit = "liquidation-daemon-query-page-limit" ) // Shared flags contains configuration flags shared by all daemons. @@ -52,9 +51,8 @@ type LiquidationFlags struct { Enabled bool // LoopDelayMs configures the update frequency of the liquidation daemon. LoopDelayMs uint32 - // SubaccountPageLimit configures the pagination limit for fetching subaccounts. - SubaccountPageLimit uint64 - RequestChunkSize uint64 + // QueryPageLimit configures the pagination limit for fetching subaccounts. + QueryPageLimit uint64 } // PriceFlags contains configuration flags for the Price Daemon. @@ -90,10 +88,9 @@ func GetDefaultDaemonFlags() DaemonFlags { EthRpcEndpoint: "", }, Liquidation: LiquidationFlags{ - Enabled: true, - LoopDelayMs: 1_600, - SubaccountPageLimit: 1_000, - RequestChunkSize: 50, + Enabled: true, + LoopDelayMs: 1_600, + QueryPageLimit: 1_000, }, Price: PriceFlags{ Enabled: true, @@ -160,14 +157,9 @@ func AddDaemonFlagsToCmd( "Delay in milliseconds between running the Liquidation Daemon task loop.", ) cmd.Flags().Uint64( - FlagLiquidationDaemonSubaccountPageLimit, - df.Liquidation.SubaccountPageLimit, - "Limit on the number of subaccounts to fetch per query in the Liquidation Daemon task loop.", - ) - cmd.Flags().Uint64( - FlagLiquidationDaemonRequestChunkSize, - df.Liquidation.RequestChunkSize, - "Limit on the number of subaccounts per collateralization check in the Liquidation Daemon task loop.", + FlagLiquidationDaemonQueryPageLimit, + df.Liquidation.QueryPageLimit, + "Limit on the number of items to fetch per query in the Liquidation Daemon task loop.", ) // Price Daemon. @@ -235,14 +227,9 @@ func GetDaemonFlagValuesFromOptions( result.Liquidation.LoopDelayMs = v } } - if option := appOpts.Get(FlagLiquidationDaemonSubaccountPageLimit); option != nil { - if v, err := cast.ToUint64E(option); err == nil { - result.Liquidation.SubaccountPageLimit = v - } - } - if option := appOpts.Get(FlagLiquidationDaemonRequestChunkSize); option != nil { + if option := appOpts.Get(FlagLiquidationDaemonQueryPageLimit); option != nil { if v, err := cast.ToUint64E(option); err == nil { - result.Liquidation.RequestChunkSize = v + result.Liquidation.QueryPageLimit = v } } diff --git a/protocol/daemons/flags/flags_test.go b/protocol/daemons/flags/flags_test.go index 04191032f6..e94a055d45 100644 --- a/protocol/daemons/flags/flags_test.go +++ b/protocol/daemons/flags/flags_test.go @@ -25,7 +25,7 @@ func TestAddDaemonFlagsToCmd(t *testing.T) { flags.FlagLiquidationDaemonEnabled, flags.FlagLiquidationDaemonLoopDelayMs, - flags.FlagLiquidationDaemonSubaccountPageLimit, + flags.FlagLiquidationDaemonQueryPageLimit, flags.FlagPriceDaemonEnabled, flags.FlagPriceDaemonLoopDelayMs, @@ -52,8 +52,7 @@ func TestGetDaemonFlagValuesFromOptions_Custom(t *testing.T) { optsMap[flags.FlagLiquidationDaemonEnabled] = true optsMap[flags.FlagLiquidationDaemonLoopDelayMs] = uint32(2222) - optsMap[flags.FlagLiquidationDaemonSubaccountPageLimit] = uint64(3333) - optsMap[flags.FlagLiquidationDaemonRequestChunkSize] = uint64(4444) + optsMap[flags.FlagLiquidationDaemonQueryPageLimit] = uint64(3333) optsMap[flags.FlagPriceDaemonEnabled] = true optsMap[flags.FlagPriceDaemonLoopDelayMs] = uint32(4444) @@ -83,8 +82,7 @@ func TestGetDaemonFlagValuesFromOptions_Custom(t *testing.T) { // Liquidation Daemon. require.Equal(t, optsMap[flags.FlagLiquidationDaemonEnabled], r.Liquidation.Enabled) require.Equal(t, optsMap[flags.FlagLiquidationDaemonLoopDelayMs], r.Liquidation.LoopDelayMs) - require.Equal(t, optsMap[flags.FlagLiquidationDaemonSubaccountPageLimit], r.Liquidation.SubaccountPageLimit) - require.Equal(t, optsMap[flags.FlagLiquidationDaemonRequestChunkSize], r.Liquidation.RequestChunkSize) + require.Equal(t, optsMap[flags.FlagLiquidationDaemonQueryPageLimit], r.Liquidation.QueryPageLimit) // Price Daemon. require.Equal(t, optsMap[flags.FlagPriceDaemonEnabled], r.Price.Enabled) diff --git a/protocol/daemons/liquidation/client/client_test.go b/protocol/daemons/liquidation/client/client_test.go index 6461f12cb0..d925852287 100644 --- a/protocol/daemons/liquidation/client/client_test.go +++ b/protocol/daemons/liquidation/client/client_test.go @@ -7,20 +7,14 @@ import ( "testing" "github.com/cometbft/cometbft/libs/log" - "github.com/cosmos/cosmos-sdk/types/query" appflags "github.com/dydxprotocol/v4-chain/protocol/app/flags" d_constants "github.com/dydxprotocol/v4-chain/protocol/daemons/constants" "github.com/dydxprotocol/v4-chain/protocol/daemons/flags" - "github.com/dydxprotocol/v4-chain/protocol/daemons/liquidation/api" "github.com/dydxprotocol/v4-chain/protocol/daemons/liquidation/client" "github.com/dydxprotocol/v4-chain/protocol/mocks" "github.com/dydxprotocol/v4-chain/protocol/testutil/appoptions" - "github.com/dydxprotocol/v4-chain/protocol/testutil/constants" daemontestutils "github.com/dydxprotocol/v4-chain/protocol/testutil/daemons" "github.com/dydxprotocol/v4-chain/protocol/testutil/grpc" - clobtypes "github.com/dydxprotocol/v4-chain/protocol/x/clob/types" - satypes "github.com/dydxprotocol/v4-chain/protocol/x/subaccounts/types" - "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" ) @@ -70,183 +64,6 @@ func TestStart_UnixSocketConnectionFails(t *testing.T) { mockGrpcClient.AssertNumberOfCalls(t, "CloseConnection", 1) } -func TestRunLiquidationDaemonTaskLoop(t *testing.T) { - df := flags.GetDefaultDaemonFlags() - tests := map[string]struct { - // mocks - setupMocks func(ctx context.Context, mck *mocks.QueryClient) - - // expectations - expectedLiquidatableSubaccountIds []satypes.SubaccountId - expectedError error - }{ - "Success": { - setupMocks: func(ctx context.Context, mck *mocks.QueryClient) { - req := &satypes.QueryAllSubaccountRequest{ - Pagination: &query.PageRequest{ - Limit: df.Liquidation.SubaccountPageLimit, - }, - } - response := &satypes.QuerySubaccountAllResponse{ - Subaccount: []satypes.Subaccount{ - constants.Carl_Num0_1BTC_Short, - constants.Dave_Num0_1BTC_Long_50000USD, - }, - } - mck.On("SubaccountAll", ctx, req).Return(response, nil) - - req2 := &clobtypes.AreSubaccountsLiquidatableRequest{ - SubaccountIds: []satypes.SubaccountId{ - constants.Carl_Num0, - constants.Dave_Num0, - }, - } - response2 := &clobtypes.AreSubaccountsLiquidatableResponse{ - Results: []clobtypes.AreSubaccountsLiquidatableResponse_Result{ - { - SubaccountId: constants.Carl_Num0, - IsLiquidatable: true, - }, - { - SubaccountId: constants.Dave_Num0, - IsLiquidatable: false, - }, - }, - } - mck.On("AreSubaccountsLiquidatable", ctx, req2).Return(response2, nil) - - req3 := &api.LiquidateSubaccountsRequest{ - SubaccountIds: []satypes.SubaccountId{ - constants.Carl_Num0, - }, - } - response3 := &api.LiquidateSubaccountsResponse{} - mck.On("LiquidateSubaccounts", ctx, req3).Return(response3, nil) - }, - }, - "Success - no open position": { - setupMocks: func(ctx context.Context, mck *mocks.QueryClient) { - req := &satypes.QueryAllSubaccountRequest{ - Pagination: &query.PageRequest{ - Limit: df.Liquidation.SubaccountPageLimit, - }, - } - response := &satypes.QuerySubaccountAllResponse{ - Subaccount: []satypes.Subaccount{ - constants.Carl_Num0_599USD, // no open positions - constants.Dave_Num0_599USD, // no open positions - }, - } - mck.On("SubaccountAll", ctx, req).Return(response, nil) - req2 := &api.LiquidateSubaccountsRequest{ - SubaccountIds: []satypes.SubaccountId{}, - } - response2 := &api.LiquidateSubaccountsResponse{} - mck.On("LiquidateSubaccounts", ctx, req2).Return(response2, nil) - }, - }, - "Success - no liquidatable subaccounts": { - setupMocks: func(ctx context.Context, mck *mocks.QueryClient) { - req := &satypes.QueryAllSubaccountRequest{ - Pagination: &query.PageRequest{ - Limit: df.Liquidation.SubaccountPageLimit, - }, - } - response := &satypes.QuerySubaccountAllResponse{ - Subaccount: []satypes.Subaccount{ - constants.Carl_Num0_1BTC_Short, - constants.Dave_Num0_1BTC_Long_50000USD, - }, - } - mck.On("SubaccountAll", ctx, req).Return(response, nil) - - req2 := &clobtypes.AreSubaccountsLiquidatableRequest{ - SubaccountIds: []satypes.SubaccountId{ - constants.Carl_Num0, - constants.Dave_Num0, - }, - } - response2 := &clobtypes.AreSubaccountsLiquidatableResponse{ - Results: []clobtypes.AreSubaccountsLiquidatableResponse_Result{ - { - SubaccountId: constants.Carl_Num0, - IsLiquidatable: false, - }, - { - SubaccountId: constants.Dave_Num0, - IsLiquidatable: false, - }, - }, - } - mck.On("AreSubaccountsLiquidatable", ctx, req2).Return(response2, nil) - req3 := &api.LiquidateSubaccountsRequest{ - SubaccountIds: []satypes.SubaccountId{}, - } - response3 := &api.LiquidateSubaccountsResponse{} - mck.On("LiquidateSubaccounts", ctx, req3).Return(response3, nil) - }, - }, - "Panics on error - SubaccountAll": { - setupMocks: func(ctx context.Context, mck *mocks.QueryClient) { - mck.On("SubaccountAll", mock.Anything, mock.Anything).Return(nil, errors.New("test error")) - }, - expectedError: errors.New("test error"), - }, - "Panics on error - AreSubaccountsLiquidatable": { - setupMocks: func(ctx context.Context, mck *mocks.QueryClient) { - mck.On("SubaccountAll", mock.Anything, mock.Anything).Return(&satypes.QuerySubaccountAllResponse{ - Subaccount: []satypes.Subaccount{ - constants.Carl_Num0_1BTC_Short, - }, - }, nil) - mck.On("AreSubaccountsLiquidatable", mock.Anything, mock.Anything).Return(nil, errors.New("test error")) - }, - expectedError: errors.New("test error"), - }, - "Panics on error - LiquidateSubaccounts": { - setupMocks: func(ctx context.Context, mck *mocks.QueryClient) { - mck.On("SubaccountAll", mock.Anything, mock.Anything).Return(&satypes.QuerySubaccountAllResponse{ - Subaccount: []satypes.Subaccount{ - constants.Carl_Num0_1BTC_Short, - }, - }, nil, - ) - mck.On("AreSubaccountsLiquidatable", mock.Anything, mock.Anything).Return( - &clobtypes.AreSubaccountsLiquidatableResponse{}, - nil, - ) - mck.On("LiquidateSubaccounts", mock.Anything, mock.Anything).Return(nil, errors.New("test error")) - }, - expectedError: errors.New("test error"), - }, - } - - for name, tc := range tests { - t.Run(name, func(t *testing.T) { - queryClientMock := &mocks.QueryClient{} - tc.setupMocks(grpc.Ctx, queryClientMock) - s := client.SubTaskRunnerImpl{} - - c := client.NewClient(log.NewNopLogger()) - c.SubaccountQueryClient = queryClientMock - c.ClobQueryClient = queryClientMock - c.LiquidationServiceClient = queryClientMock - - err := s.RunLiquidationDaemonTaskLoop( - grpc.Ctx, - c, - flags.GetDefaultDaemonFlags().Liquidation, - ) - if tc.expectedError != nil { - require.EqualError(t, err, tc.expectedError.Error()) - } else { - require.NoError(t, err) - queryClientMock.AssertExpectations(t) - } - }) - } -} - // FakeSubTaskRunner is a mock implementation of the SubTaskRunner interface for testing. type FakeSubTaskRunner struct { err error diff --git a/protocol/daemons/liquidation/client/grpc_helper.go b/protocol/daemons/liquidation/client/grpc_helper.go index 21c7d8c930..d888c483d8 100644 --- a/protocol/daemons/liquidation/client/grpc_helper.go +++ b/protocol/daemons/liquidation/client/grpc_helper.go @@ -12,7 +12,6 @@ import ( "github.com/dydxprotocol/v4-chain/protocol/daemons/liquidation/api" "github.com/dydxprotocol/v4-chain/protocol/lib/metrics" blocktimetypes "github.com/dydxprotocol/v4-chain/protocol/x/blocktime/types" - clobtypes "github.com/dydxprotocol/v4-chain/protocol/x/clob/types" perptypes "github.com/dydxprotocol/v4-chain/protocol/x/perpetuals/types" pricestypes "github.com/dydxprotocol/v4-chain/protocol/x/prices/types" satypes "github.com/dydxprotocol/v4-chain/protocol/x/subaccounts/types" @@ -45,7 +44,6 @@ func (c *Client) GetPreviousBlockInfo( // GetAllPerpetuals queries gRPC server and returns a list of perpetuals. func (c *Client) GetAllPerpetuals( ctx context.Context, - blockHeight uint32, pageLimit uint64, ) ( perpetuals []perptypes.Perpetual, @@ -85,7 +83,6 @@ func (c *Client) GetAllPerpetuals( // GetAllLiquidityTiers queries gRPC server and returns a list of liquidityTiers. func (c *Client) GetAllLiquidityTiers( ctx context.Context, - blockHeight uint32, pageLimit uint64, ) ( liquidityTiers []perptypes.LiquidityTier, @@ -125,7 +122,6 @@ func (c *Client) GetAllLiquidityTiers( // GetAllMarketPrices queries gRPC server and returns a list of market prices. func (c *Client) GetAllMarketPrices( ctx context.Context, - blockHeight uint32, pageLimit uint64, ) ( marketPrices []pricestypes.MarketPrice, @@ -205,33 +201,6 @@ func (c *Client) GetAllSubaccounts( return subaccounts, nil } -// CheckCollateralizationForSubaccounts queries a gRPC server using `AreSubaccountsLiquidatable` -// and returns a list of collateralization statuses for the given list of subaccount ids. -func (c *Client) CheckCollateralizationForSubaccounts( - ctx context.Context, - subaccountIds []satypes.SubaccountId, -) ( - results []clobtypes.AreSubaccountsLiquidatableResponse_Result, - err error, -) { - defer telemetry.ModuleMeasureSince( - metrics.LiquidationDaemon, - time.Now(), - metrics.CheckCollateralizationForSubaccounts, - metrics.Latency, - ) - - query := &clobtypes.AreSubaccountsLiquidatableRequest{ - SubaccountIds: subaccountIds, - } - response, err := c.ClobQueryClient.AreSubaccountsLiquidatable(ctx, query) - if err != nil { - return nil, err - } - - return response.Results, nil -} - // SendLiquidatableSubaccountIds sends a list of unique and potentially liquidatable // subaccount ids to a gRPC server via `LiquidateSubaccounts`. func (c *Client) SendLiquidatableSubaccountIds( diff --git a/protocol/daemons/liquidation/client/grpc_helper_test.go b/protocol/daemons/liquidation/client/grpc_helper_test.go index 764be97688..f64b2a21cb 100644 --- a/protocol/daemons/liquidation/client/grpc_helper_test.go +++ b/protocol/daemons/liquidation/client/grpc_helper_test.go @@ -14,7 +14,6 @@ import ( "github.com/dydxprotocol/v4-chain/protocol/testutil/constants" "github.com/dydxprotocol/v4-chain/protocol/testutil/grpc" blocktimetypes "github.com/dydxprotocol/v4-chain/protocol/x/blocktime/types" - clobtypes "github.com/dydxprotocol/v4-chain/protocol/x/clob/types" perptypes "github.com/dydxprotocol/v4-chain/protocol/x/perpetuals/types" pricestypes "github.com/dydxprotocol/v4-chain/protocol/x/prices/types" satypes "github.com/dydxprotocol/v4-chain/protocol/x/subaccounts/types" @@ -93,7 +92,7 @@ func TestGetAllSubaccounts(t *testing.T) { setupMocks: func(ctx context.Context, mck *mocks.QueryClient) { req := &satypes.QueryAllSubaccountRequest{ Pagination: &query.PageRequest{ - Limit: df.Liquidation.SubaccountPageLimit, + Limit: df.Liquidation.QueryPageLimit, }, } response := &satypes.QuerySubaccountAllResponse{ @@ -113,7 +112,7 @@ func TestGetAllSubaccounts(t *testing.T) { setupMocks: func(ctx context.Context, mck *mocks.QueryClient) { req := &satypes.QueryAllSubaccountRequest{ Pagination: &query.PageRequest{ - Limit: df.Liquidation.SubaccountPageLimit, + Limit: df.Liquidation.QueryPageLimit, }, } nextKey := []byte("next key") @@ -129,7 +128,7 @@ func TestGetAllSubaccounts(t *testing.T) { req2 := &satypes.QueryAllSubaccountRequest{ Pagination: &query.PageRequest{ Key: nextKey, - Limit: df.Liquidation.SubaccountPageLimit, + Limit: df.Liquidation.QueryPageLimit, }, } response2 := &satypes.QuerySubaccountAllResponse{ @@ -148,7 +147,7 @@ func TestGetAllSubaccounts(t *testing.T) { setupMocks: func(ctx context.Context, mck *mocks.QueryClient) { req := &satypes.QueryAllSubaccountRequest{ Pagination: &query.PageRequest{ - Limit: df.Liquidation.SubaccountPageLimit, + Limit: df.Liquidation.QueryPageLimit, }, } mck.On("SubaccountAll", ctx, req).Return(nil, errors.New("test error")) @@ -166,7 +165,7 @@ func TestGetAllSubaccounts(t *testing.T) { daemon.SubaccountQueryClient = queryClientMock actual, err := daemon.GetAllSubaccounts( grpc.Ctx, - df.Liquidation.SubaccountPageLimit, + df.Liquidation.QueryPageLimit, ) if err != nil { require.EqualError(t, err, tc.expectedError.Error()) @@ -258,7 +257,6 @@ func TestGetAllPerpetuals(t *testing.T) { daemon.PerpetualsQueryClient = queryClientMock actual, err := daemon.GetAllPerpetuals( grpc.Ctx, - uint32(50), tc.limit, ) if err != nil { @@ -347,7 +345,6 @@ func TestGetAllLiquidityTiers(t *testing.T) { daemon.PerpetualsQueryClient = queryClientMock actual, err := daemon.GetAllLiquidityTiers( grpc.Ctx, - uint32(50), tc.limit, ) if err != nil { @@ -441,7 +438,6 @@ func TestGetAllMarketPrices(t *testing.T) { daemon.PricesQueryClient = queryClientMock actual, err := daemon.GetAllMarketPrices( grpc.Ctx, - uint32(50), tc.limit, ) if err != nil { @@ -453,107 +449,6 @@ func TestGetAllMarketPrices(t *testing.T) { } } -func TestCheckCollateralizationForSubaccounts(t *testing.T) { - tests := map[string]struct { - // mocks - setupMocks func( - ctx context.Context, - mck *mocks.QueryClient, - results []clobtypes.AreSubaccountsLiquidatableResponse_Result, - ) - subaccountIds []satypes.SubaccountId - - // expectations - expectedResults []clobtypes.AreSubaccountsLiquidatableResponse_Result - expectedError error - }{ - "Success": { - setupMocks: func( - ctx context.Context, - mck *mocks.QueryClient, - results []clobtypes.AreSubaccountsLiquidatableResponse_Result, - ) { - query := &clobtypes.AreSubaccountsLiquidatableRequest{ - SubaccountIds: []satypes.SubaccountId{ - constants.Alice_Num0, - constants.Bob_Num0, - }, - } - response := &clobtypes.AreSubaccountsLiquidatableResponse{ - Results: results, - } - mck.On("AreSubaccountsLiquidatable", ctx, query).Return(response, nil) - }, - subaccountIds: []satypes.SubaccountId{ - constants.Alice_Num0, - constants.Bob_Num0, - }, - expectedResults: []clobtypes.AreSubaccountsLiquidatableResponse_Result{ - { - SubaccountId: constants.Alice_Num0, - IsLiquidatable: true, - }, - { - SubaccountId: constants.Bob_Num0, - IsLiquidatable: false, - }, - }, - }, - "Success - Empty": { - setupMocks: func( - ctx context.Context, - mck *mocks.QueryClient, - results []clobtypes.AreSubaccountsLiquidatableResponse_Result, - ) { - query := &clobtypes.AreSubaccountsLiquidatableRequest{ - SubaccountIds: []satypes.SubaccountId{}, - } - response := &clobtypes.AreSubaccountsLiquidatableResponse{ - Results: results, - } - mck.On("AreSubaccountsLiquidatable", ctx, query).Return(response, nil) - }, - subaccountIds: []satypes.SubaccountId{}, - expectedResults: []clobtypes.AreSubaccountsLiquidatableResponse_Result{}, - }, - "Errors are propagated": { - setupMocks: func( - ctx context.Context, - mck *mocks.QueryClient, - results []clobtypes.AreSubaccountsLiquidatableResponse_Result, - ) { - query := &clobtypes.AreSubaccountsLiquidatableRequest{ - SubaccountIds: []satypes.SubaccountId{}, - } - mck.On("AreSubaccountsLiquidatable", ctx, query).Return(nil, errors.New("test error")) - }, - subaccountIds: []satypes.SubaccountId{}, - expectedResults: []clobtypes.AreSubaccountsLiquidatableResponse_Result{}, - expectedError: errors.New("test error"), - }, - } - - for name, tc := range tests { - t.Run(name, func(t *testing.T) { - queryClientMock := &mocks.QueryClient{} - tc.setupMocks(grpc.Ctx, queryClientMock, tc.expectedResults) - - daemon := client.NewClient(log.NewNopLogger()) - daemon.ClobQueryClient = queryClientMock - actual, err := daemon.CheckCollateralizationForSubaccounts( - grpc.Ctx, - tc.subaccountIds, - ) - - if err != nil { - require.EqualError(t, err, tc.expectedError.Error()) - } else { - require.Equal(t, tc.expectedResults, actual) - } - }) - } -} - func TestSendLiquidatableSubaccountIds(t *testing.T) { tests := map[string]struct { // mocks diff --git a/protocol/daemons/liquidation/client/sub_task_runner.go b/protocol/daemons/liquidation/client/sub_task_runner.go index e7ae323f79..aad5b0bd4e 100644 --- a/protocol/daemons/liquidation/client/sub_task_runner.go +++ b/protocol/daemons/liquidation/client/sub_task_runner.go @@ -2,12 +2,19 @@ package client import ( "context" + "fmt" + "math/big" "time" "github.com/cosmos/cosmos-sdk/telemetry" "github.com/dydxprotocol/v4-chain/protocol/daemons/flags" "github.com/dydxprotocol/v4-chain/protocol/lib" "github.com/dydxprotocol/v4-chain/protocol/lib/metrics" + assetstypes "github.com/dydxprotocol/v4-chain/protocol/x/assets/types" + clobkeeper "github.com/dydxprotocol/v4-chain/protocol/x/clob/keeper" + perpkeeper "github.com/dydxprotocol/v4-chain/protocol/x/perpetuals/keeper" + perptypes "github.com/dydxprotocol/v4-chain/protocol/x/perpetuals/types" + pricestypes "github.com/dydxprotocol/v4-chain/protocol/x/prices/types" satypes "github.com/dydxprotocol/v4-chain/protocol/x/subaccounts/types" ) @@ -40,17 +47,31 @@ func (s *SubTaskRunnerImpl) RunLiquidationDaemonTaskLoop( metrics.Latency, ) - // 1. Fetch all subaccounts from query service. - subaccounts, err := daemonClient.GetAllSubaccounts(ctx, liqFlags.SubaccountPageLimit) + lastCommittedBlockHeight, err := daemonClient.GetPreviousBlockInfo(ctx) if err != nil { return err } - // 2. Check collateralization statuses of subaccounts with at least one open position. - liquidatableSubaccountIds, err := daemonClient.GetLiquidatableSubaccountIds( + // 1. Fetch all information needed to calculate total net collateral and margin requirements. + subaccounts, + marketPrices, + perpetuals, + liquidityTiers, + err := daemonClient.FetchApplicationStateAtBlockHeight( ctx, + lastCommittedBlockHeight, liqFlags, + ) + if err != nil { + return err + } + + // 2. Check collateralization statuses of subaccounts with at least one open position. + liquidatableSubaccountIds, err := daemonClient.GetLiquidatableSubaccountIds( subaccounts, + marketPrices, + perpetuals, + liquidityTiers, ) if err != nil { return err @@ -65,12 +86,69 @@ func (s *SubTaskRunnerImpl) RunLiquidationDaemonTaskLoop( return nil } +// FetchApplicationStateAtBlockHeight queries a gRPC server and fetches the following information given a block height: +// - Last committed block height. +// - Subaccounts including their open positions. +// - Market prices. +// - Perpetuals. +// - Liquidity tiers. +func (c *Client) FetchApplicationStateAtBlockHeight( + ctx context.Context, + blockHeight uint32, + liqFlags flags.LiquidationFlags, +) ( + subaccounts []satypes.Subaccount, + marketPricesMap map[uint32]pricestypes.MarketPrice, + perpetualsMap map[uint32]perptypes.Perpetual, + liquidityTiersMap map[uint32]perptypes.LiquidityTier, + err error, +) { + // Execute all queries at the given block height. + queryCtx := newContextWithQueryBlockHeight(ctx, blockHeight) + + // Subaccounts + subaccounts, err = c.GetAllSubaccounts(queryCtx, liqFlags.QueryPageLimit) + if err != nil { + return nil, nil, nil, nil, err + } + + // Market prices + marketPrices, err := c.GetAllMarketPrices(queryCtx, liqFlags.QueryPageLimit) + if err != nil { + return nil, nil, nil, nil, err + } + marketPricesMap = lib.UniqueSliceToMap(marketPrices, func(m pricestypes.MarketPrice) uint32 { + return m.Id + }) + + // Perpetuals + perpetuals, err := c.GetAllPerpetuals(queryCtx, liqFlags.QueryPageLimit) + if err != nil { + return nil, nil, nil, nil, err + } + perpetualsMap = lib.UniqueSliceToMap(perpetuals, func(p perptypes.Perpetual) uint32 { + return p.Params.Id + }) + + // Liquidity tiers + liquidityTiers, err := c.GetAllLiquidityTiers(queryCtx, liqFlags.QueryPageLimit) + if err != nil { + return nil, nil, nil, nil, err + } + liquidityTiersMap = lib.UniqueSliceToMap(liquidityTiers, func(l perptypes.LiquidityTier) uint32 { + return l.Id + }) + + return subaccounts, marketPricesMap, perpetualsMap, liquidityTiersMap, nil +} + // GetLiquidatableSubaccountIds verifies collateralization statuses of subaccounts with // at least one open position and returns a list of unique and potentially liquidatable subaccount ids. func (c *Client) GetLiquidatableSubaccountIds( - ctx context.Context, - liqFlags flags.LiquidationFlags, subaccounts []satypes.Subaccount, + marketPrices map[uint32]pricestypes.MarketPrice, + perpetuals map[uint32]perptypes.Perpetual, + liquidityTiers map[uint32]perptypes.LiquidityTier, ) ( liquidatableSubaccountIds []satypes.SubaccountId, err error, @@ -82,39 +160,120 @@ func (c *Client) GetLiquidatableSubaccountIds( metrics.Latency, ) - // Filter out subaccounts with no open positions. - subaccountsToCheck := make([]satypes.SubaccountId, 0) + numSubaccountsWithOpenPositions := 0 + liquidatableSubaccountIds = make([]satypes.SubaccountId, 0) for _, subaccount := range subaccounts { - if len(subaccount.PerpetualPositions) > 0 { - subaccountsToCheck = append(subaccountsToCheck, *subaccount.Id) + // Skip subaccounts with no open positions. + if len(subaccount.PerpetualPositions) == 0 { + numSubaccountsWithOpenPositions++ + continue + } + + // Check if the subaccount is liquidatable. + isLiquidatable, err := c.CheckSubaccountCollateralization( + subaccount, + marketPrices, + perpetuals, + liquidityTiers, + ) + if err != nil { + return nil, err + } + + if isLiquidatable { + liquidatableSubaccountIds = append(liquidatableSubaccountIds, *subaccount.Id) } } telemetry.ModuleSetGauge( metrics.LiquidationDaemon, - float32(len(subaccountsToCheck)), + float32(numSubaccountsWithOpenPositions), metrics.SubaccountsWithOpenPositions, metrics.Count, ) - // Query the gRPC server in chunks of size `liqFlags.RequestChunkSize`. - liquidatableSubaccountIds = make([]satypes.SubaccountId, 0) - for start := 0; start < len(subaccountsToCheck); start += int(liqFlags.RequestChunkSize) { - end := lib.Min(start+int(liqFlags.RequestChunkSize), len(subaccountsToCheck)) + return liquidatableSubaccountIds, nil +} - results, err := c.CheckCollateralizationForSubaccounts( - ctx, - subaccountsToCheck[start:end], - ) - if err != nil { - return nil, err +// CheckSubaccountCollateralization queries a gRPC server using `AreSubaccountsLiquidatable` +// and returns a list of collateralization statuses for the given list of subaccount ids. +func (c *Client) CheckSubaccountCollateralization( + subaccount satypes.Subaccount, + marketPrices map[uint32]pricestypes.MarketPrice, + perpetuals map[uint32]perptypes.Perpetual, + liquidityTiers map[uint32]perptypes.LiquidityTier, +) ( + isLiquidatable bool, + err error, +) { + defer telemetry.ModuleMeasureSince( + metrics.LiquidationDaemon, + time.Now(), + metrics.CheckCollateralizationForSubaccounts, + metrics.Latency, + ) + + bigTotalNetCollateral := big.NewInt(0) + bigTotalMaintenanceMargin := big.NewInt(0) + + // Calculate the net collateral and maintenance margin for each of the asset positions. + // Note that we only expect USDC before multi-collateral support is added. + for _, assetPosition := range subaccount.AssetPositions { + if assetPosition.AssetId != assetstypes.AssetUsdc.Id { + panic("liquidation daemon only supports USDC collateral") + } + // Net collateral for USDC is the quantums of the position. + // Margin requirements for USDC are zero. + bigTotalNetCollateral.Add(bigTotalNetCollateral, assetPosition.GetBigQuantums()) + } + + // Calculate the net collateral and maintenance margin for each of the perpetual positions. + for _, perpetualPosition := range subaccount.PerpetualPositions { + perpetual, ok := perpetuals[perpetualPosition.PerpetualId] + if !ok { + panic( + fmt.Sprintf( + "Perpetual not found for perpetual id %d", + perpetualPosition.PerpetualId, + ), + ) } - for _, result := range results { - if result.IsLiquidatable { - liquidatableSubaccountIds = append(liquidatableSubaccountIds, result.SubaccountId) - } + marketPrice, ok := marketPrices[perpetual.Params.MarketId] + if !ok { + panic( + fmt.Sprintf( + "MarketPrice not found for perpetual %+v", + perpetual, + ), + ) } + + bigQuantums := perpetualPosition.GetBigQuantums() + + // Get the net collateral for the position. + bigNetCollateral := perpkeeper.GetNetNotional(perpetual, marketPrice, bigQuantums) + bigTotalNetCollateral.Add(bigTotalNetCollateral, bigNetCollateral) + + liquidityTier, ok := liquidityTiers[perpetual.Params.LiquidityTier] + if !ok { + panic( + fmt.Sprintf( + "LiquidityTier not found for perpetual %+v", + perpetual, + ), + ) + } + + // Get the maintenance margin requirement for the position. + _, bigMaintenanceMargin := perpkeeper.GetMarginRequirements( + perpetual, + marketPrice, + liquidityTier, + bigQuantums, + ) + bigTotalMaintenanceMargin.Add(bigTotalMaintenanceMargin, bigMaintenanceMargin) } - return liquidatableSubaccountIds, nil + + return clobkeeper.IsLiquidatable(bigTotalNetCollateral, bigTotalMaintenanceMargin), nil } diff --git a/protocol/daemons/liquidation/client/sub_task_runner_test.go b/protocol/daemons/liquidation/client/sub_task_runner_test.go new file mode 100644 index 0000000000..c32276b17b --- /dev/null +++ b/protocol/daemons/liquidation/client/sub_task_runner_test.go @@ -0,0 +1,253 @@ +package client_test + +import ( + "context" + "testing" + + "github.com/cometbft/cometbft/libs/log" + "github.com/dydxprotocol/v4-chain/protocol/daemons/flags" + "github.com/dydxprotocol/v4-chain/protocol/daemons/liquidation/api" + "github.com/dydxprotocol/v4-chain/protocol/daemons/liquidation/client" + "github.com/dydxprotocol/v4-chain/protocol/mocks" + "github.com/dydxprotocol/v4-chain/protocol/testutil/constants" + "github.com/dydxprotocol/v4-chain/protocol/testutil/grpc" + blocktimetypes "github.com/dydxprotocol/v4-chain/protocol/x/blocktime/types" + perptypes "github.com/dydxprotocol/v4-chain/protocol/x/perpetuals/types" + pricestypes "github.com/dydxprotocol/v4-chain/protocol/x/prices/types" + satypes "github.com/dydxprotocol/v4-chain/protocol/x/subaccounts/types" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +func TestRunLiquidationDaemonTaskLoop(t *testing.T) { + tests := map[string]struct { + // mocks + setupMocks func(ctx context.Context, mck *mocks.QueryClient) + + // expectations + expectedLiquidatableSubaccountIds []satypes.SubaccountId + expectedError error + }{ + "Can get liquidatable subaccount with short position": { + setupMocks: func(ctx context.Context, mck *mocks.QueryClient) { + // Block height. + res := &blocktimetypes.QueryPreviousBlockInfoResponse{ + Info: &blocktimetypes.BlockInfo{ + Height: uint32(50), + Timestamp: constants.TimeTen, + }, + } + mck.On("PreviousBlockInfo", mock.Anything, mock.Anything).Return(res, nil) + + // Subaccount. + res2 := &satypes.QuerySubaccountAllResponse{ + Subaccount: []satypes.Subaccount{ + constants.Carl_Num0_1BTC_Short_54999USD, + }, + } + mck.On("SubaccountAll", mock.Anything, mock.Anything).Return(res2, nil) + + // Market prices. + res3 := &pricestypes.QueryAllMarketPricesResponse{ + MarketPrices: constants.TestMarketPrices, + } + mck.On("AllMarketPrices", mock.Anything, mock.Anything).Return(res3, nil) + + // Perpetuals. + res4 := &perptypes.QueryAllPerpetualsResponse{ + Perpetual: []perptypes.Perpetual{ + constants.BtcUsd_20PercentInitial_10PercentMaintenance, + }, + } + mck.On("AllPerpetuals", mock.Anything, mock.Anything).Return(res4, nil) + + // Liquidity tiers. + res5 := &perptypes.QueryAllLiquidityTiersResponse{ + LiquidityTiers: constants.LiquidityTiers, + } + mck.On("AllLiquidityTiers", mock.Anything, mock.Anything).Return(res5, nil) + + // Sends liquidatable subaccount ids to the server. + req := &api.LiquidateSubaccountsRequest{ + SubaccountIds: []satypes.SubaccountId{ + constants.Carl_Num0, + }, + } + response3 := &api.LiquidateSubaccountsResponse{} + mck.On("LiquidateSubaccounts", ctx, req).Return(response3, nil) + }, + }, + "Can get liquidatable subaccount with long position": { + setupMocks: func(ctx context.Context, mck *mocks.QueryClient) { + // Block height. + res := &blocktimetypes.QueryPreviousBlockInfoResponse{ + Info: &blocktimetypes.BlockInfo{ + Height: uint32(50), + Timestamp: constants.TimeTen, + }, + } + mck.On("PreviousBlockInfo", mock.Anything, mock.Anything).Return(res, nil) + + // Subaccount. + res2 := &satypes.QuerySubaccountAllResponse{ + Subaccount: []satypes.Subaccount{ + constants.Dave_Num0_1BTC_Long_45001USD_Short, + }, + } + mck.On("SubaccountAll", mock.Anything, mock.Anything).Return(res2, nil) + + // Market prices. + res3 := &pricestypes.QueryAllMarketPricesResponse{ + MarketPrices: constants.TestMarketPrices, + } + mck.On("AllMarketPrices", mock.Anything, mock.Anything).Return(res3, nil) + + // Perpetuals. + res4 := &perptypes.QueryAllPerpetualsResponse{ + Perpetual: []perptypes.Perpetual{ + constants.BtcUsd_20PercentInitial_10PercentMaintenance, + }, + } + mck.On("AllPerpetuals", mock.Anything, mock.Anything).Return(res4, nil) + + // Liquidity tiers. + res5 := &perptypes.QueryAllLiquidityTiersResponse{ + LiquidityTiers: constants.LiquidityTiers, + } + mck.On("AllLiquidityTiers", mock.Anything, mock.Anything).Return(res5, nil) + + // Sends liquidatable subaccount ids to the server. + req := &api.LiquidateSubaccountsRequest{ + SubaccountIds: []satypes.SubaccountId{ + constants.Dave_Num0, + }, + } + response3 := &api.LiquidateSubaccountsResponse{} + mck.On("LiquidateSubaccounts", ctx, req).Return(response3, nil) + }, + }, + "Skip well collateralized subaccounts": { + setupMocks: func(ctx context.Context, mck *mocks.QueryClient) { + // Block height. + res := &blocktimetypes.QueryPreviousBlockInfoResponse{ + Info: &blocktimetypes.BlockInfo{ + Height: uint32(50), + Timestamp: constants.TimeTen, + }, + } + mck.On("PreviousBlockInfo", mock.Anything, mock.Anything).Return(res, nil) + + // Subaccount. + res2 := &satypes.QuerySubaccountAllResponse{ + Subaccount: []satypes.Subaccount{ + constants.Carl_Num0_1BTC_Short_55000USD, + constants.Dave_Num0_1BTC_Long_45000USD_Short, + }, + } + mck.On("SubaccountAll", mock.Anything, mock.Anything).Return(res2, nil) + + // Market prices. + res3 := &pricestypes.QueryAllMarketPricesResponse{ + MarketPrices: constants.TestMarketPrices, + } + mck.On("AllMarketPrices", mock.Anything, mock.Anything).Return(res3, nil) + + // Perpetuals. + res4 := &perptypes.QueryAllPerpetualsResponse{ + Perpetual: []perptypes.Perpetual{ + constants.BtcUsd_20PercentInitial_10PercentMaintenance, + }, + } + mck.On("AllPerpetuals", mock.Anything, mock.Anything).Return(res4, nil) + + // Liquidity tiers. + res5 := &perptypes.QueryAllLiquidityTiersResponse{ + LiquidityTiers: constants.LiquidityTiers, + } + mck.On("AllLiquidityTiers", mock.Anything, mock.Anything).Return(res5, nil) + + // Sends liquidatable subaccount ids to the server. + req := &api.LiquidateSubaccountsRequest{ + SubaccountIds: []satypes.SubaccountId{}, + } + response3 := &api.LiquidateSubaccountsResponse{} + mck.On("LiquidateSubaccounts", ctx, req).Return(response3, nil) + }, + }, + "Skip subaccounts with no open positions": { + setupMocks: func(ctx context.Context, mck *mocks.QueryClient) { + // Block height. + res := &blocktimetypes.QueryPreviousBlockInfoResponse{ + Info: &blocktimetypes.BlockInfo{ + Height: uint32(50), + Timestamp: constants.TimeTen, + }, + } + mck.On("PreviousBlockInfo", mock.Anything, mock.Anything).Return(res, nil) + + // Subaccount. + res2 := &satypes.QuerySubaccountAllResponse{ + Subaccount: []satypes.Subaccount{ + constants.Alice_Num0_100_000USD, + }, + } + mck.On("SubaccountAll", mock.Anything, mock.Anything).Return(res2, nil) + + // Market prices. + res3 := &pricestypes.QueryAllMarketPricesResponse{ + MarketPrices: constants.TestMarketPrices, + } + mck.On("AllMarketPrices", mock.Anything, mock.Anything).Return(res3, nil) + + // Perpetuals. + res4 := &perptypes.QueryAllPerpetualsResponse{ + Perpetual: []perptypes.Perpetual{ + constants.BtcUsd_20PercentInitial_10PercentMaintenance, + }, + } + mck.On("AllPerpetuals", mock.Anything, mock.Anything).Return(res4, nil) + + // Liquidity tiers. + res5 := &perptypes.QueryAllLiquidityTiersResponse{ + LiquidityTiers: constants.LiquidityTiers, + } + mck.On("AllLiquidityTiers", mock.Anything, mock.Anything).Return(res5, nil) + + // Sends liquidatable subaccount ids to the server. + req := &api.LiquidateSubaccountsRequest{ + SubaccountIds: []satypes.SubaccountId{}, + } + response3 := &api.LiquidateSubaccountsResponse{} + mck.On("LiquidateSubaccounts", ctx, req).Return(response3, nil) + }, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + queryClientMock := &mocks.QueryClient{} + tc.setupMocks(grpc.Ctx, queryClientMock) + s := client.SubTaskRunnerImpl{} + + c := client.NewClient(log.NewNopLogger()) + c.SubaccountQueryClient = queryClientMock + c.ClobQueryClient = queryClientMock + c.LiquidationServiceClient = queryClientMock + c.PerpetualsQueryClient = queryClientMock + c.PricesQueryClient = queryClientMock + c.BlocktimeQueryClient = queryClientMock + + err := s.RunLiquidationDaemonTaskLoop( + grpc.Ctx, + c, + flags.GetDefaultDaemonFlags().Liquidation, + ) + if tc.expectedError != nil { + require.EqualError(t, err, tc.expectedError.Error()) + } else { + require.NoError(t, err) + queryClientMock.AssertExpectations(t) + } + }) + } +} diff --git a/protocol/lib/collections.go b/protocol/lib/collections.go index a00a0347df..5f57e7ff42 100644 --- a/protocol/lib/collections.go +++ b/protocol/lib/collections.go @@ -50,6 +50,24 @@ func UniqueSliceToSet[K comparable](values []K) map[K]struct{} { return set } +// UniqueSliceToMap converts a slice to a map using the provided keyFunc to generate the key. +func UniqueSliceToMap[K comparable, V any](slice []V, keyFunc func(V) K) map[K]V { + m := make(map[K]V) + for _, v := range slice { + k := keyFunc(v) + if _, exists := m[k]; exists { + panic( + fmt.Sprintf( + "UniqueSliceToMap: duplicate value: %+v", + v, + ), + ) + } + m[k] = v + } + return m +} + // MapSlice takes a function and executes that function on each element of a slice, returning the result. // Note the function must return one result for each element of the slice. func MapSlice[V any, E any](values []V, mapFunc func(V) E) []E { diff --git a/protocol/lib/collections_test.go b/protocol/lib/collections_test.go index 14878e8365..6b0f75443a 100644 --- a/protocol/lib/collections_test.go +++ b/protocol/lib/collections_test.go @@ -104,6 +104,58 @@ func TestUniqueSliceToSet(t *testing.T) { } } +func TestUniqueSliceToMap(t *testing.T) { + type testStruct struct { + Id uint32 + } + + tests := map[string]struct { + input []testStruct + expected map[uint32]testStruct + panicWith string + }{ + "Empty": { + input: []testStruct{}, + expected: map[uint32]testStruct{}, + }, + "Basic": { + input: []testStruct{ + {Id: 0}, {Id: 1}, {Id: 2}, + }, + expected: map[uint32]testStruct{ + 0: {Id: 0}, + 1: {Id: 1}, + 2: {Id: 2}, + }, + }, + "Duplicate": { + input: []testStruct{ + {Id: 0}, {Id: 0}, + }, + panicWith: "UniqueSliceToMap: duplicate value: {Id:0}", + }, + } + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + if tc.panicWith != "" { + require.PanicsWithValue( + t, + tc.panicWith, + func() { + lib.UniqueSliceToMap(tc.input, func(t testStruct) uint32 { return t.Id }) + }, + ) + } else { + require.Equal( + t, + tc.expected, + lib.UniqueSliceToMap(tc.input, func(t testStruct) uint32 { return t.Id }), + ) + } + }) + } +} + func TestMapSlice(t *testing.T) { // Can increment all numbers in a slice by 1, and change type to `uint64`. require.Equal( diff --git a/protocol/testutil/constants/subaccounts.go b/protocol/testutil/constants/subaccounts.go index 28575e8c0d..a9ed42db28 100644 --- a/protocol/testutil/constants/subaccounts.go +++ b/protocol/testutil/constants/subaccounts.go @@ -328,6 +328,21 @@ var ( }, }, } + Dave_Num0_1BTC_Long_45000USD_Short = satypes.Subaccount{ + Id: &Dave_Num0, + AssetPositions: []*satypes.AssetPosition{ + { + AssetId: 0, + Quantums: dtypes.NewInt(-45_000_000_000), // -$45,000 + }, + }, + PerpetualPositions: []*satypes.PerpetualPosition{ + { + PerpetualId: 0, + Quantums: dtypes.NewInt(100_000_000), // 1 BTC + }, + }, + } Dave_Num0_1BTC_Long_45001USD_Short = satypes.Subaccount{ Id: &Dave_Num0, AssetPositions: []*satypes.AssetPosition{ diff --git a/protocol/x/clob/keeper/liquidations.go b/protocol/x/clob/keeper/liquidations.go index 8017370cfa..53aac3d955 100644 --- a/protocol/x/clob/keeper/liquidations.go +++ b/protocol/x/clob/keeper/liquidations.go @@ -360,11 +360,20 @@ func (k Keeper) IsLiquidatable( return false, err } - // The subaccount is liquidatable if both of the following are true: - // - The maintenance margin requirements are greater than zero (note that they can never be negative). - // - The maintenance margin requirements are greater than the subaccount's net collateral. - isLiquidatable := bigMaintenanceMargin.Sign() > 0 && bigMaintenanceMargin.Cmp(bigNetCollateral) == 1 - return isLiquidatable, nil + return IsLiquidatable(bigNetCollateral, bigMaintenanceMargin), nil +} + +// IsLiquidatable returns true if the subaccount is able to be liquidated given the total net collateral +// and maintenance margin requirement of the subaccount. +// +// The subaccount is liquidatable if both of the following are true: +// - The maintenance margin requirements are greater than zero (note that they can never be negative). +// - The maintenance margin requirements are greater than the subaccount's net collateral. +func IsLiquidatable( + bigNetCollateral *big.Int, + bigMaintenanceMargin *big.Int, +) bool { + return bigMaintenanceMargin.Sign() > 0 && bigMaintenanceMargin.Cmp(bigNetCollateral) == 1 } // EnsureIsLiquidatable returns an error if the subaccount is not liquidatable. diff --git a/protocol/x/perpetuals/keeper/perpetual.go b/protocol/x/perpetuals/keeper/perpetual.go index 26fa55e14d..8b68f4d282 100644 --- a/protocol/x/perpetuals/keeper/perpetual.go +++ b/protocol/x/perpetuals/keeper/perpetual.go @@ -768,6 +768,16 @@ func (k Keeper) GetNetNotional( return new(big.Int), err } + return GetNetNotional(perpetual, marketPrice, bigQuantums), nil +} + +func GetNetNotional( + perpetual types.Perpetual, + marketPrice pricestypes.MarketPrice, + bigQuantums *big.Int, +) ( + bigNetNotionalQuoteQuantums *big.Int, +) { bigQuoteQuantums := lib.BaseToQuoteQuantums( bigQuantums, perpetual.Params.AtomicResolution, @@ -775,7 +785,7 @@ func (k Keeper) GetNetNotional( marketPrice.Exponent, ) - return bigQuoteQuantums, nil + return bigQuoteQuantums } // GetNotionalInBaseQuantums returns the net notional in base quantums, which can be represented @@ -881,6 +891,25 @@ func (k Keeper) GetMarginRequirements( return nil, nil, err } + bigInitialMarginQuoteQuantums, + bigMaintenanceMarginQuoteQuantums = GetMarginRequirements( + perpetual, + marketPrice, + liquidityTier, + bigQuantums, + ) + return bigInitialMarginQuoteQuantums, bigMaintenanceMarginQuoteQuantums, nil +} + +func GetMarginRequirements( + perpetual types.Perpetual, + marketPrice pricestypes.MarketPrice, + liquidityTier types.LiquidityTier, + bigQuantums *big.Int, +) ( + bigInitialMarginQuoteQuantums *big.Int, + bigMaintenanceMarginQuoteQuantums *big.Int, +) { // Always consider the magnitude of the position regardless of whether it is long/short. bigAbsQuantums := new(big.Int).Set(bigQuantums).Abs(bigQuantums) @@ -902,8 +931,7 @@ func (k Keeper) GetMarginRequirements( ), true, ) - - return bigInitialMarginQuoteQuantums, bigMaintenanceMarginQuoteQuantums, nil + return bigInitialMarginQuoteQuantums, bigMaintenanceMarginQuoteQuantums } // GetSettlementPpm returns the net settlement amount ppm (in quote quantums) given