diff --git a/protocol/x/clob/keeper/clob_pair.go b/protocol/x/clob/keeper/clob_pair.go index a562bf552a..b4a7f89972 100644 --- a/protocol/x/clob/keeper/clob_pair.go +++ b/protocol/x/clob/keeper/clob_pair.go @@ -468,13 +468,19 @@ func (k Keeper) UpdateClobPair( oldStatus := oldClobPair.Status newStatus := clobPair.Status - if oldStatus != newStatus && !types.IsSupportedClobPairStatusTransition(oldStatus, newStatus) { - return errorsmod.Wrapf( - types.ErrInvalidClobPairStatusTransition, - "Cannot transition from status %+v to status %+v", - oldStatus, - newStatus, - ) + if oldStatus != newStatus { + if !types.IsSupportedClobPairStatusTransition(oldStatus, newStatus) { + return errorsmod.Wrapf( + types.ErrInvalidClobPairStatusTransition, + "Cannot transition from status %+v to status %+v", + oldStatus, + newStatus, + ) + } + + if newStatus == types.ClobPair_STATUS_FINAL_SETTLEMENT { + k.mustEnterFinalSettlement(ctx, clobPair.GetClobPairId()) + } } if err := k.validateClobPair(ctx, &clobPair); err != nil { @@ -624,7 +630,7 @@ func (k Keeper) IsPerpetualClobPairActive( if !found { return false, errorsmod.Wrapf( types.ErrInvalidClob, - "GetPerpetualClobPairStatus: did not find clob pair with id = %d", + "IsPerpetualClobPairActive: did not find clob pair with id = %d", clobPairId, ) } diff --git a/protocol/x/clob/keeper/clob_pair_test.go b/protocol/x/clob/keeper/clob_pair_test.go index 449af51299..3005f4346f 100644 --- a/protocol/x/clob/keeper/clob_pair_test.go +++ b/protocol/x/clob/keeper/clob_pair_test.go @@ -7,6 +7,7 @@ import ( indexerevents "github.com/dydxprotocol/v4-chain/protocol/indexer/events" "github.com/dydxprotocol/v4-chain/protocol/indexer/indexer_manager" + indexershared "github.com/dydxprotocol/v4-chain/protocol/indexer/shared" "github.com/dydxprotocol/v4-chain/protocol/lib" "github.com/cosmos/cosmos-sdk/codec" @@ -19,6 +20,7 @@ import ( "github.com/dydxprotocol/v4-chain/protocol/testutil/nullify" perptest "github.com/dydxprotocol/v4-chain/protocol/testutil/perpetuals" pricestest "github.com/dydxprotocol/v4-chain/protocol/testutil/prices" + "github.com/dydxprotocol/v4-chain/protocol/x/clob/keeper" "github.com/dydxprotocol/v4-chain/protocol/x/clob/memclob" "github.com/dydxprotocol/v4-chain/protocol/x/clob/types" "github.com/dydxprotocol/v4-chain/protocol/x/perpetuals" @@ -597,6 +599,121 @@ func TestClobPairGetAll(t *testing.T) { ) } +func TestUpdateClobPair_FinalSettlement(t *testing.T) { + memClob := memclob.NewMemClobPriceTimePriority(false) + mockIndexerEventManager := &mocks.IndexerEventManager{} + ks := keepertest.NewClobKeepersTestContext(t, memClob, &mocks.BankKeeper{}, mockIndexerEventManager) + prices.InitGenesis(ks.Ctx, *ks.PricesKeeper, constants.Prices_DefaultGenesisState) + perpetuals.InitGenesis(ks.Ctx, *ks.PerpetualsKeeper, constants.Perpetuals_DefaultGenesisState) + + clobPair := constants.ClobPair_Btc + mockIndexerEventManager.On("AddTxnEvent", + ks.Ctx, + indexerevents.SubtypePerpetualMarket, + indexerevents.PerpetualMarketEventVersion, + indexer_manager.GetBytes( + indexerevents.NewPerpetualMarketCreateEvent( + 0, + 0, + constants.Perpetuals_DefaultGenesisState.Perpetuals[0].Params.Ticker, + constants.Perpetuals_DefaultGenesisState.Perpetuals[0].Params.MarketId, + types.ClobPair_STATUS_ACTIVE, + clobPair.QuantumConversionExponent, + constants.Perpetuals_DefaultGenesisState.Perpetuals[0].Params.AtomicResolution, + clobPair.SubticksPerTick, + clobPair.StepBaseQuantums, + constants.Perpetuals_DefaultGenesisState.Perpetuals[0].Params.LiquidityTier, + ), + ), + ).Once().Return() + + _, err := ks.ClobKeeper.CreatePerpetualClobPair( + ks.Ctx, + clobPair.Id, + clobtest.MustPerpetualId(clobPair), + satypes.BaseQuantums(clobPair.StepBaseQuantums), + clobPair.QuantumConversionExponent, + clobPair.SubticksPerTick, + types.ClobPair_STATUS_ACTIVE, + ) + require.NoError(t, err) + + mockIndexerEventManager.On("AddTxnEvent", + ks.Ctx, + indexerevents.SubtypeUpdateClobPair, + indexerevents.UpdateClobPairEventVersion, + indexer_manager.GetBytes( + indexerevents.NewUpdateClobPairEvent( + clobPair.GetClobPairId(), + types.ClobPair_STATUS_FINAL_SETTLEMENT, + clobPair.QuantumConversionExponent, + types.SubticksPerTick(clobPair.GetSubticksPerTick()), + satypes.BaseQuantums(clobPair.GetStepBaseQuantums()), + ), + ), + ).Once().Return() + + statefulOrders := []types.Order{ + constants.LongTermOrder_Alice_Num0_Id0_Clob0_Buy100_Price10_GTBT15, + constants.LongTermOrder_Bob_Num0_Id0_Clob0_Buy25_Price30_GTBT10, + constants.LongTermOrder_Alice_Num0_Id0_Clob1_Buy5_Price10_GTBT5, // different clob pair + } + for _, order := range statefulOrders { + ks.ClobKeeper.SetLongTermOrderPlacement(ks.Ctx, order, 5) + ks.ClobKeeper.MustAddOrderToStatefulOrdersTimeSlice( + ks.Ctx, + order.MustGetUnixGoodTilBlockTime(), + order.GetOrderId(), + ) + + if order.OrderId.ClobPairId == 0 { + mockIndexerEventManager.On("AddTxnEvent", + ks.Ctx, + indexerevents.SubtypeStatefulOrder, + indexerevents.StatefulOrderEventVersion, + indexer_manager.GetBytes( + indexerevents.NewStatefulOrderRemovalEvent( + order.OrderId, + indexershared.OrderRemovalReason_ORDER_REMOVAL_REASON_FINAL_SETTLEMENT, + ), + ), + ).Once().Return() + } + } + + ks.ClobKeeper.UntriggeredConditionalOrders = map[types.ClobPairId]*keeper.UntriggeredConditionalOrders{ + 0: {}, // leaving blank, orders here don't matter in particular since we clear the whole key + } + + clobPair.Status = types.ClobPair_STATUS_FINAL_SETTLEMENT + err = ks.ClobKeeper.UpdateClobPair(ks.Ctx, clobPair) + require.NoError(t, err) + + // Verify indexer expectations. + mockIndexerEventManager.AssertExpectations(t) + + // Verify stateful orders are removed from state. + for _, order := range statefulOrders { + _, found := ks.ClobKeeper.GetLongTermOrderPlacement(ks.Ctx, order.OrderId) + require.Equal(t, order.OrderId.ClobPairId != 0, found) + } + + // Verify ProcessProposerMatchesEvents.RemovedStatefulOrderIds is populated correctly. + ppme := ks.ClobKeeper.GetProcessProposerMatchesEvents(ks.Ctx) + require.Equal( + t, + []types.OrderId{ + constants.LongTermOrder_Alice_Num0_Id0_Clob0_Buy100_Price10_GTBT15.OrderId, + constants.LongTermOrder_Bob_Num0_Id0_Clob0_Buy25_Price30_GTBT10.OrderId, + }, + ppme.RemovedStatefulOrderIds, + ) + + // Verify UntriggeredConditionalOrders is cleared. + _, found := ks.ClobKeeper.UntriggeredConditionalOrders[0] + require.False(t, found) +} + func TestUpdateClobPair(t *testing.T) { testCases := map[string]struct { setup func(t *testing.T, ks keepertest.ClobKeepersTestContext, manager *mocks.IndexerEventManager) diff --git a/protocol/x/clob/keeper/final_settlement.go b/protocol/x/clob/keeper/final_settlement.go new file mode 100644 index 0000000000..0660e203c9 --- /dev/null +++ b/protocol/x/clob/keeper/final_settlement.go @@ -0,0 +1,70 @@ +package keeper + +import ( + sdk "github.com/cosmos/cosmos-sdk/types" + indexerevents "github.com/dydxprotocol/v4-chain/protocol/indexer/events" + "github.com/dydxprotocol/v4-chain/protocol/indexer/indexer_manager" + indexershared "github.com/dydxprotocol/v4-chain/protocol/indexer/shared" + "github.com/dydxprotocol/v4-chain/protocol/x/clob/types" +) + +// MustEnterFinalSettlement holds logic executed when a market transitions to FINAL_SETTLEMENT status. +// This function will forcefully cancel all stateful open orders for the clob pair. +func (k Keeper) mustEnterFinalSettlement(ctx sdk.Context, clobPairId types.ClobPairId) { + // Forcefully cancel all stateful orders from state for this clob pair + k.mustCancelStatefulOrdersForFinalSettlement(ctx, clobPairId) + + // Delete untriggered conditional orders for this clob pair from memory + delete(k.UntriggeredConditionalOrders, clobPairId) +} + +// mustCancelStatefulOrdersForFinalSettlement forcefully cancels all stateful orders +// for the provided ClobPair. These orders will be removed from the memclob in PrepareCheckState. +func (k Keeper) mustCancelStatefulOrdersForFinalSettlement(ctx sdk.Context, clobPairId types.ClobPairId) { + statefulOrders := k.GetAllStatefulOrders(ctx) + processProposerMatchesEvents := k.GetProcessProposerMatchesEvents(ctx) + + // This logic is executed in EndBlocker and should not panic. This would be unexpected, + // but if it happens we would rather recover and continue if an order fails to be removed from state + // rather than halt the chain. + safelyRemoveStatefulOrder := func(ctx sdk.Context, orderId types.OrderId) { + defer func() { + if r := recover(); r != nil { + k.Logger(ctx).Error( + "mustCancelStatefulOrdersForFinalSettlement: Failed to remove stateful order with OrderId %+v: %v", + orderId, + r, + ) + } + }() + k.MustRemoveStatefulOrder(ctx, orderId) + } + + for _, order := range statefulOrders { + if order.GetClobPairId() == clobPairId { + // Remove from state, recovering from panic if necessary + safelyRemoveStatefulOrder(ctx, order.OrderId) + + // Append to RemovedStatefulOrderIds so this order gets removed + // from the memclob in PrepareCheckState during the PurgeInvalidMemclobState step + processProposerMatchesEvents.RemovedStatefulOrderIds = append( + processProposerMatchesEvents.RemovedStatefulOrderIds, + order.OrderId, + ) + + k.GetIndexerEventManager().AddTxnEvent( + ctx, + indexerevents.SubtypeStatefulOrder, + indexerevents.StatefulOrderEventVersion, + indexer_manager.GetBytes( + indexerevents.NewStatefulOrderRemovalEvent( + order.OrderId, + indexershared.OrderRemovalReason_ORDER_REMOVAL_REASON_FINAL_SETTLEMENT, + ), + ), + ) + } + } + + k.MustSetProcessProposerMatchesEvents(ctx, processProposerMatchesEvents) +} diff --git a/protocol/x/clob/keeper/process_operations_test.go b/protocol/x/clob/keeper/process_operations_test.go index 148e8e822d..d856368ce2 100644 --- a/protocol/x/clob/keeper/process_operations_test.go +++ b/protocol/x/clob/keeper/process_operations_test.go @@ -1437,7 +1437,7 @@ func TestProcessProposerOperations(t *testing.T) { rawOperations: []types.OperationRaw{ clobtest.NewOrderRemovalOperationRaw( constants.LongTermOrder_Bob_Num0_Id0_Clob0_Buy25_Price30_GTBT10.OrderId, - types.OrderRemoval_REMOVAL_REASON_FULLY_FILLED, + types.OrderRemoval_REMOVAL_REASON_INVALID_SELF_TRADE, ), }, expectedError: types.ErrOperationConflictsWithClobPairStatus, @@ -1505,8 +1505,49 @@ func TestProcessProposerOperations(t *testing.T) { }, expectedError: types.ErrOperationConflictsWithClobPairStatus, }, + "Succeeds with ClobMatch_MatchPerpetualDeleveraging, IsFinalSettlement is false for market in final settlement": { + perpetuals: []*perptypes.Perpetual{ + &constants.BtcUsd_100PercentMarginRequirement, + }, + perpetualFeeParams: &constants.PerpetualFeeParams, + clobPairs: []types.ClobPair{ + constants.ClobPair_Btc_Final_Settlement, + }, + subaccounts: []satypes.Subaccount{ + // liquidatable: MMR = $5000, TNC = $499 + 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: 100_000_000, + }, + }, + }, + ), + }, + expectedProcessProposerMatchesEvents: types.ProcessProposerMatchesEvents{ + BlockHeight: blockHeight, + }, + expectedQuoteBalances: map[satypes.SubaccountId]int64{ + constants.Carl_Num0: 0, + constants.Dave_Num0: constants.Usdc_Asset_100_499.GetBigQuantums().Int64(), + }, + expectedPerpetualPositions: map[satypes.SubaccountId][]*satypes.PerpetualPosition{ + constants.Carl_Num0: {}, + constants.Dave_Num0: {}, + }, + }, // "Succeeds with ClobMatch_MatchPerpetualDeleveraging, IsFinalSettlement is true for market in final settlement": {}, - // "Succeeds with ClobMatch_MatchPerpetualDeleveraging, IsFinalSettlement is false for market in final settlement": {}, } for name, tc := range tests { diff --git a/protocol/x/clob/types/clob_pair.go b/protocol/x/clob/types/clob_pair.go index 1376b524ab..064d484f9f 100644 --- a/protocol/x/clob/types/clob_pair.go +++ b/protocol/x/clob/types/clob_pair.go @@ -10,9 +10,15 @@ import ( // may be transitioned to from this state. Note the keys of this map may be // a subset of the types defined in the proto for ClobPair_Status. var SupportedClobPairStatusTransitions = map[ClobPair_Status]map[ClobPair_Status]struct{}{ - ClobPair_STATUS_ACTIVE: {}, + ClobPair_STATUS_ACTIVE: { + ClobPair_STATUS_FINAL_SETTLEMENT: struct{}{}, + }, ClobPair_STATUS_INITIALIZING: { - ClobPair_STATUS_ACTIVE: struct{}{}, + ClobPair_STATUS_ACTIVE: struct{}{}, + ClobPair_STATUS_FINAL_SETTLEMENT: struct{}{}, + }, + ClobPair_STATUS_FINAL_SETTLEMENT: { + ClobPair_STATUS_INITIALIZING: struct{}{}, }, } diff --git a/protocol/x/clob/types/clob_pair_test.go b/protocol/x/clob/types/clob_pair_test.go index 8050c51f90..9065372d1c 100644 --- a/protocol/x/clob/types/clob_pair_test.go +++ b/protocol/x/clob/types/clob_pair_test.go @@ -42,6 +42,7 @@ func TestIsSupportedClobPairStatus_Supported(t *testing.T) { // these are the only two supported statuses require.True(t, types.IsSupportedClobPairStatus(types.ClobPair_STATUS_ACTIVE)) require.True(t, types.IsSupportedClobPairStatus(types.ClobPair_STATUS_INITIALIZING)) + require.True(t, types.IsSupportedClobPairStatus(types.ClobPair_STATUS_FINAL_SETTLEMENT)) } func TestIsSupportedClobPairStatus_Unsupported(t *testing.T) { @@ -56,10 +57,18 @@ func TestIsSupportedClobPairStatus_Unsupported(t *testing.T) { } func TestIsSupportedClobPairStatusTransition_Supported(t *testing.T) { - // only supported transition require.True(t, types.IsSupportedClobPairStatusTransition( types.ClobPair_STATUS_INITIALIZING, types.ClobPair_STATUS_ACTIVE, )) + require.True(t, types.IsSupportedClobPairStatusTransition( + types.ClobPair_STATUS_INITIALIZING, types.ClobPair_STATUS_FINAL_SETTLEMENT, + )) + require.True(t, types.IsSupportedClobPairStatusTransition( + types.ClobPair_STATUS_ACTIVE, types.ClobPair_STATUS_FINAL_SETTLEMENT, + )) + require.True(t, types.IsSupportedClobPairStatusTransition( + types.ClobPair_STATUS_FINAL_SETTLEMENT, types.ClobPair_STATUS_INITIALIZING, + )) } func TestIsSupportedClobPairStatusTransition_Unsupported(t *testing.T) { @@ -77,10 +86,55 @@ func TestIsSupportedClobPairStatusTransition_Unsupported(t *testing.T) { // iterate over all permutations of clob pair statuses for _, fromClobPairStatus := range types.ClobPair_Status_value { for _, toClobPairStatus := range types.ClobPair_Status_value { - if fromClobPairStatus == int32(types.ClobPair_STATUS_INITIALIZING) && - toClobPairStatus == int32(types.ClobPair_STATUS_ACTIVE) { - continue - } else { + switch fromClobPairStatus { + case int32(types.ClobPair_STATUS_INITIALIZING): + { + switch toClobPairStatus { + case int32(types.ClobPair_STATUS_ACTIVE): + fallthrough + case int32(types.ClobPair_STATUS_FINAL_SETTLEMENT): + continue + default: + require.False( + t, + types.IsSupportedClobPairStatusTransition( + types.ClobPair_Status(fromClobPairStatus), + types.ClobPair_Status(toClobPairStatus), + ), + ) + } + } + case int32(types.ClobPair_STATUS_ACTIVE): + { + switch toClobPairStatus { + case int32(types.ClobPair_STATUS_FINAL_SETTLEMENT): + continue + default: + require.False( + t, + types.IsSupportedClobPairStatusTransition( + types.ClobPair_Status(fromClobPairStatus), + types.ClobPair_Status(toClobPairStatus), + ), + ) + } + } + case int32(types.ClobPair_STATUS_FINAL_SETTLEMENT): + { + switch toClobPairStatus { + case int32(types.ClobPair_STATUS_INITIALIZING): + continue + default: + require.False( + t, + types.IsSupportedClobPairStatusTransition( + types.ClobPair_Status(fromClobPairStatus), + types.ClobPair_Status(toClobPairStatus), + ), + ) + } + } + default: require.False( t, types.IsSupportedClobPairStatusTransition(