diff --git a/protocol/lib/metrics/metric_keys.go b/protocol/lib/metrics/metric_keys.go index ac5329a76c..7efca550f0 100644 --- a/protocol/lib/metrics/metric_keys.go +++ b/protocol/lib/metrics/metric_keys.go @@ -20,6 +20,7 @@ const ( LiquidationsPlacePerpetualLiquidationQuoteQuantums = "liquidations_place_perpetual_liquidation_quote_quantums" LiquidationsLiquidationMatchNegativeTNC = "liquidations_liquidation_match_negative_tnc" ClobMevErrorCount = "clob_mev_error_count" + SubaccountsNegativeTncSubaccountSeen = "negative_tnc_subaccount_seen" // Gauges InsuranceFundBalance = "insurance_fund_balance" diff --git a/protocol/x/clob/keeper/process_operations.go b/protocol/x/clob/keeper/process_operations.go index 1c7c5bab0e..edab7c0e40 100644 --- a/protocol/x/clob/keeper/process_operations.go +++ b/protocol/x/clob/keeper/process_operations.go @@ -675,6 +675,32 @@ func (k Keeper) PersistMatchDeleveragingToState( } deltaBaseQuantumsIsNegative := position.GetIsLong() + // If there are zero-fill deleveraging operations, this is a sentinel value to indicate a subaccount could not be + // liquidated or deleveraged and still has negative equity. Mark the current block number in state to indicate a + // negative TNC subaccount was seen. + if len(matchDeleveraging.GetFills()) == 0 { + if !shouldDeleverageAtBankruptcyPrice { + return errorsmod.Wrapf( + types.ErrZeroFillDeleveragingForNonNegativeTncSubaccount, + fmt.Sprintf( + "PersistMatchDeleveragingToState: zero-fill deleveraging operation included for subaccount %+v"+ + " and perpetual %d but subaccount isn't negative TNC", + liquidatedSubaccountId, + perpetualId, + ), + ) + } + + metrics.IncrCountMetricWithLabels( + types.ModuleName, + metrics.SubaccountsNegativeTncSubaccountSeen, + metrics.GetLabelForIntValue(metrics.PerpetualId, int(perpetualId)), + metrics.GetLabelForBoolValue(metrics.IsLong, position.GetIsLong()), + ) + k.subaccountsKeeper.SetNegativeTncSubaccountSeenAtBlock(ctx, lib.MustConvertIntegerToUint32(ctx.BlockHeight())) + return nil + } + for _, fill := range matchDeleveraging.GetFills() { deltaBaseQuantums := new(big.Int).SetUint64(fill.FillAmount) if deltaBaseQuantumsIsNegative { diff --git a/protocol/x/clob/keeper/process_operations_test.go b/protocol/x/clob/keeper/process_operations_test.go index a1eab77034..bd5c2fb2cc 100644 --- a/protocol/x/clob/keeper/process_operations_test.go +++ b/protocol/x/clob/keeper/process_operations_test.go @@ -65,6 +65,7 @@ type processProposerOperationsTestCase struct { expectedQuoteBalances map[satypes.SubaccountId]int64 expectedPerpetualPositions map[satypes.SubaccountId][]*satypes.PerpetualPosition expectedSubaccountLiquidationInfo map[satypes.SubaccountId]types.SubaccountLiquidationInfo + expectedNegativeTncSubaccountSeen bool expectedError error expectedPanics string } @@ -920,6 +921,189 @@ func TestProcessProposerOperations(t *testing.T) { constants.Dave_Num0: {}, }, }, + "Zero-fill deleveraging succeeds when the account is negative TNC and updates the last negative TNC subaccount " + + "seen block number in state": { + perpetuals: []*perptypes.Perpetual{ + &constants.BtcUsd_20PercentInitial_10PercentMaintenance, + }, + perpetualFeeParams: &constants.PerpetualFeeParams, + clobPairs: []types.ClobPair{ + constants.ClobPair_Btc, + }, + subaccounts: []satypes.Subaccount{ + // deleverageable since TNC = -$1 + constants.Carl_Num0_1BTC_Short_50499USD, + }, + marketIdToOraclePriceOverride: map[uint32]uint64{ + constants.BtcUsd.MarketId: 5_050_000_000, // $50,500 / BTC + }, + rawOperations: []types.OperationRaw{ + clobtest.NewMatchOperationRawFromPerpetualDeleveragingLiquidation( + types.MatchPerpetualDeleveraging{ + Liquidated: constants.Carl_Num0, + PerpetualId: 0, + Fills: []types.MatchPerpetualDeleveraging_Fill{}, + }, + ), + }, + + expectedProcessProposerMatchesEvents: types.ProcessProposerMatchesEvents{ + BlockHeight: blockHeight, + }, + expectedQuoteBalances: map[satypes.SubaccountId]int64{ + constants.Carl_Num0: constants.Carl_Num0_1BTC_Short_50499USD.GetUsdcPosition().Int64(), + }, + expectedPerpetualPositions: map[satypes.SubaccountId][]*satypes.PerpetualPosition{ + constants.Carl_Num0: constants.Carl_Num0_1BTC_Short_50499USD.GetPerpetualPositions(), + }, + expectedNegativeTncSubaccountSeen: true, + }, + "Zero-fill deleveraging succeeds when the account is negative TNC and has a position in final settlement" + + " market. It updates the last negative TNC subaccount seen block number in state": { + perpetuals: []*perptypes.Perpetual{ + &constants.BtcUsd_100PercentMarginRequirement, + }, + perpetualFeeParams: &constants.PerpetualFeeParams, + clobPairs: []types.ClobPair{ + constants.ClobPair_Btc_Final_Settlement, + }, + subaccounts: []satypes.Subaccount{ + // liquidatable: MMR = $5000, TNC = -$1. + constants.Carl_Num0_1BTC_Short_50499USD, + constants.Dave_Num0_1BTC_Long_50000USD, + }, + marketIdToOraclePriceOverride: map[uint32]uint64{ + constants.BtcUsd.MarketId: 5_050_000_000, // $50,500 / BTC + }, + rawOperations: []types.OperationRaw{ + clobtest.NewMatchOperationRawFromPerpetualDeleveragingLiquidation( + types.MatchPerpetualDeleveraging{ + Liquidated: constants.Carl_Num0, + PerpetualId: 0, + Fills: []types.MatchPerpetualDeleveraging_Fill{}, + }, + ), + }, + expectedProcessProposerMatchesEvents: types.ProcessProposerMatchesEvents{ + BlockHeight: blockHeight, + }, + expectedQuoteBalances: map[satypes.SubaccountId]int64{ + constants.Carl_Num0: constants.Carl_Num0_1BTC_Short_50499USD.GetUsdcPosition().Int64(), + constants.Dave_Num0: constants.Dave_Num0_1BTC_Long_50000USD.GetUsdcPosition().Int64(), + }, + expectedPerpetualPositions: map[satypes.SubaccountId][]*satypes.PerpetualPosition{ + constants.Carl_Num0: constants.Carl_Num0_1BTC_Short_50499USD.GetPerpetualPositions(), + constants.Dave_Num0: constants.Dave_Num0_1BTC_Long_50000USD.GetPerpetualPositions(), + }, + + expectedNegativeTncSubaccountSeen: true, + }, + "Zero-fill deleveraging succeeds when there's multiple zero-fill deleveraging events for the same subaccount " + + "and perpetual ID": { + perpetuals: []*perptypes.Perpetual{ + &constants.BtcUsd_20PercentInitial_10PercentMaintenance, + }, + perpetualFeeParams: &constants.PerpetualFeeParams, + clobPairs: []types.ClobPair{ + constants.ClobPair_Btc, + }, + subaccounts: []satypes.Subaccount{ + // deleverageable since TNC = -$1 + constants.Carl_Num0_1BTC_Short_50499USD, + }, + marketIdToOraclePriceOverride: map[uint32]uint64{ + constants.BtcUsd.MarketId: 5_050_000_000, // $50,500 / BTC + }, + rawOperations: []types.OperationRaw{ + clobtest.NewMatchOperationRawFromPerpetualDeleveragingLiquidation( + types.MatchPerpetualDeleveraging{ + Liquidated: constants.Carl_Num0, + PerpetualId: 0, + Fills: []types.MatchPerpetualDeleveraging_Fill{}, + }, + ), + clobtest.NewMatchOperationRawFromPerpetualDeleveragingLiquidation( + types.MatchPerpetualDeleveraging{ + Liquidated: constants.Carl_Num0, + PerpetualId: 0, + Fills: []types.MatchPerpetualDeleveraging_Fill{}, + }, + ), + }, + + expectedProcessProposerMatchesEvents: types.ProcessProposerMatchesEvents{ + BlockHeight: blockHeight, + }, + expectedQuoteBalances: map[satypes.SubaccountId]int64{ + constants.Carl_Num0: constants.Carl_Num0_1BTC_Short_50499USD.GetUsdcPosition().Int64(), + }, + expectedPerpetualPositions: map[satypes.SubaccountId][]*satypes.PerpetualPosition{ + constants.Carl_Num0: constants.Carl_Num0_1BTC_Short_50499USD.GetPerpetualPositions(), + }, + expectedNegativeTncSubaccountSeen: true, + }, + "Zero-fill deleverage succeeds after the same subaccount is partially deleveraged": { + perpetuals: []*perptypes.Perpetual{ + &constants.BtcUsd_20PercentInitial_10PercentMaintenance, + }, + perpetualFeeParams: &constants.PerpetualFeeParams, + clobPairs: []types.ClobPair{ + constants.ClobPair_Btc, + }, + subaccounts: []satypes.Subaccount{ + // deleveragable: TNC = -$1 + constants.Carl_Num0_1BTC_Short_50499USD, + constants.Dave_Num0_1BTC_Long_50000USD, + }, + marketIdToOraclePriceOverride: map[uint32]uint64{ + constants.BtcUsd.MarketId: 5_050_000_000, // $50,500 / BTC + }, + rawOperations: []types.OperationRaw{ + clobtest.NewMatchOperationRawFromPerpetualDeleveragingLiquidation( + types.MatchPerpetualDeleveraging{ + Liquidated: constants.Carl_Num0, + PerpetualId: 0, + Fills: []types.MatchPerpetualDeleveraging_Fill{ + { + OffsettingSubaccountId: constants.Dave_Num0, + FillAmount: 50_000_000, + }, + }, + }, + ), + clobtest.NewMatchOperationRawFromPerpetualDeleveragingLiquidation( + types.MatchPerpetualDeleveraging{ + Liquidated: constants.Carl_Num0, + PerpetualId: 0, + Fills: []types.MatchPerpetualDeleveraging_Fill{}, + }, + ), + }, + expectedProcessProposerMatchesEvents: types.ProcessProposerMatchesEvents{ + BlockHeight: blockHeight, + }, + expectedQuoteBalances: map[satypes.SubaccountId]int64{ + constants.Carl_Num0: constants.Carl_Num0_1BTC_Short_50499USD.GetUsdcPosition().Int64() - 25_249_500_000, + constants.Dave_Num0: constants.Dave_Num0_1BTC_Long_50000USD.GetUsdcPosition().Int64() + 25_249_500_000, + }, + expectedPerpetualPositions: map[satypes.SubaccountId][]*satypes.PerpetualPosition{ + constants.Carl_Num0: { + { + PerpetualId: 0, + Quantums: dtypes.NewInt(-100_000_000 + 50_000_000), + FundingIndex: dtypes.ZeroInt(), + }, + }, + constants.Dave_Num0: { + { + PerpetualId: 0, + Quantums: dtypes.NewInt(100_000_000 - 50_000_000), + FundingIndex: dtypes.ZeroInt(), + }, + }, + }, + expectedNegativeTncSubaccountSeen: true, + }, "Succeeds order removal operations with previous stateful orders": { perpetuals: []*perptypes.Perpetual{ &constants.BtcUsd_100PercentMarginRequirement, @@ -1351,6 +1535,42 @@ func TestProcessProposerOperations(t *testing.T) { }, expectedPanics: "validateInternalOperationAgainstClobPairStatus: ClobPair's status is not supported", }, + "Returns error if zero-fill deleveraging operation proposed for non-negative TNC subaccount in final settlement": { + perpetuals: []*perptypes.Perpetual{ + &constants.BtcUsd_100PercentMarginRequirement, + }, + perpetualFeeParams: &constants.PerpetualFeeParams, + clobPairs: []types.ClobPair{ + constants.ClobPair_Btc_Final_Settlement, + }, + subaccounts: []satypes.Subaccount{ + // both well-collateralized + constants.Carl_Num0_1BTC_Short_100000USD, + constants.Dave_Num0_1BTC_Long_50000USD, + }, + rawOperations: []types.OperationRaw{ + clobtest.NewMatchOperationRawFromPerpetualDeleveragingLiquidation( + types.MatchPerpetualDeleveraging{ + Liquidated: constants.Carl_Num0, + PerpetualId: 0, + Fills: []types.MatchPerpetualDeleveraging_Fill{}, + IsFinalSettlement: true, + }, + ), + }, + expectedProcessProposerMatchesEvents: types.ProcessProposerMatchesEvents{ + BlockHeight: blockHeight, + }, + expectedQuoteBalances: map[satypes.SubaccountId]int64{ + constants.Carl_Num0: constants.Carl_Num0_1BTC_Short_100000USD.GetUsdcPosition().Int64(), + constants.Dave_Num0: constants.Dave_Num0_1BTC_Long_50000USD.GetUsdcPosition().Int64(), + }, + expectedPerpetualPositions: map[satypes.SubaccountId][]*satypes.PerpetualPosition{ + constants.Carl_Num0: constants.Carl_Num0_1BTC_Short_100000USD.GetPerpetualPositions(), + constants.Dave_Num0: constants.Dave_Num0_1BTC_Long_50000USD.GetPerpetualPositions(), + }, + expectedError: types.ErrZeroFillDeleveragingForNonNegativeTncSubaccount, + }, "Fails with clob match for market in initializing mode": { perpetuals: []*perptypes.Perpetual{ &constants.BtcUsd_100PercentMarginRequirement, @@ -1517,7 +1737,7 @@ func TestProcessProposerOperations(t *testing.T) { constants.ClobPair_Btc_Final_Settlement, }, subaccounts: []satypes.Subaccount{ - // liquidatable: MMR = $5000, TNC = $499 + // liquidatable: MMR = $5000, TNC = -$1. constants.Carl_Num0_1BTC_Short_50499USD, constants.Dave_Num0_1BTC_Long_50000USD, }, @@ -2166,6 +2386,16 @@ func runProcessProposerOperationsTestCase( require.Equal(t, fillAmount, actualFillAmount) } + // Verify the negative TNC subaccount seen block. + seenNegativeTncSubaccountBlock, exists := ks.SubaccountsKeeper.GetNegativeTncSubaccountSeenAtBlock(ctx) + if tc.expectedNegativeTncSubaccountSeen { + require.True(t, exists) + require.Equal(t, uint32(ctx.BlockHeight()), seenNegativeTncSubaccountBlock) + } else { + require.False(t, exists) + require.Equal(t, uint32(0), seenNegativeTncSubaccountBlock) + } + mockIndexerEventManager.AssertExpectations(t) // TODO(CLOB-230) Add more assertions. diff --git a/protocol/x/clob/types/errors.go b/protocol/x/clob/types/errors.go index a9396f1026..b40ad727d3 100644 --- a/protocol/x/clob/types/errors.go +++ b/protocol/x/clob/types/errors.go @@ -283,11 +283,7 @@ var ( 1015, "Invalid delta base and/or quote quantums for insurance fund delta calculation", ) - ErrEmptyDeleveragingFills = errorsmod.Register( - ModuleName, - 1016, - "Deleveraging fills length must be greater than 0", - ) + // TODO: Should the error code be skipped or re-assigned? ErrDeleveragingAgainstSelf = errorsmod.Register( ModuleName, 1017, @@ -444,6 +440,11 @@ var ( 4007, "Order Removal reason is invalid", ) + ErrZeroFillDeleveragingForNonNegativeTncSubaccount = errorsmod.Register( + ModuleName, + 4008, + "Zero-fill deleveraging operation included in block for non-negative TNC subaccount", + ) // Block rate limit errors. ErrInvalidBlockRateLimitConfig = errorsmod.Register( diff --git a/protocol/x/clob/types/expected_keepers.go b/protocol/x/clob/types/expected_keepers.go index 4c610dc499..97557081f5 100644 --- a/protocol/x/clob/types/expected_keepers.go +++ b/protocol/x/clob/types/expected_keepers.go @@ -59,6 +59,10 @@ type SubaccountsKeeper interface { successPerUpdate []satypes.UpdateResult, err error, ) + SetNegativeTncSubaccountSeenAtBlock( + ctx sdk.Context, + blockHeight uint32, + ) TransferFeesToFeeCollectorModule( ctx sdk.Context, assetId uint32, diff --git a/protocol/x/clob/types/match_perpetual_deleveraging.go b/protocol/x/clob/types/match_perpetual_deleveraging.go index afa3c45b60..faf9f6d170 100644 --- a/protocol/x/clob/types/match_perpetual_deleveraging.go +++ b/protocol/x/clob/types/match_perpetual_deleveraging.go @@ -7,7 +7,6 @@ import ( // Validate performs stateless validation on a `MatchPerpetualDeleveraging` object. // It checks the following conditions to be true: // - Validation for all subaccount Ids -// - length of fills to be greater than zero // - For each fill, fill amount must be greater than zero // - Subaccount ids in fills are all unique // - Subaccount ids in fills cannot be the same as the liquidated subaccount id @@ -17,10 +16,8 @@ func (match *MatchPerpetualDeleveraging) Validate() error { return err } + // Note that zero-fill deleveraging operations are valid, iff the subaccount is negative equity. fills := match.GetFills() - if len(fills) == 0 { - return ErrEmptyDeleveragingFills - } seenOffsettingSubacountIds := map[satypes.SubaccountId]struct{}{} for _, fill := range fills { offsettingSubaccountId := fill.GetOffsettingSubaccountId() diff --git a/protocol/x/clob/types/match_perpetual_deleveraging_test.go b/protocol/x/clob/types/match_perpetual_deleveraging_test.go index 52ac36abe3..1c177d6324 100644 --- a/protocol/x/clob/types/match_perpetual_deleveraging_test.go +++ b/protocol/x/clob/types/match_perpetual_deleveraging_test.go @@ -34,7 +34,7 @@ func TestPerformStatelessMatchPerpetualDeleveragingValidation(t *testing.T) { PerpetualId: constants.ClobPair_Eth.MustGetPerpetualId(), Fills: []types.MatchPerpetualDeleveraging_Fill{}, }, - expectedError: types.ErrEmptyDeleveragingFills, + expectedError: nil, }, "Deleveraging fill subaccount id the same as liquidation subaccount id": { match: types.MatchPerpetualDeleveraging{ diff --git a/protocol/x/clob/types/message_proposed_operations_test.go b/protocol/x/clob/types/message_proposed_operations_test.go index 27ea139771..3f5bb7d524 100644 --- a/protocol/x/clob/types/message_proposed_operations_test.go +++ b/protocol/x/clob/types/message_proposed_operations_test.go @@ -474,7 +474,7 @@ func TestValidateAndTransformRawOperations(t *testing.T) { }, ), }, - expectedError: types.ErrEmptyDeleveragingFills, + expectedError: nil, }, // Tests for byte functionality