diff --git a/protocol/x/clob/types/constants.go b/protocol/x/clob/types/constants.go index c0eaf75392..069e7646e7 100644 --- a/protocol/x/clob/types/constants.go +++ b/protocol/x/clob/types/constants.go @@ -8,6 +8,10 @@ import ( // `MsgPlaceOrder` or `MsgCancelOrder` message will be considered valid by the validator. const ShortBlockWindow uint32 = 20 +// MaxMsgBatchCancelBatchSize represents the maximum number of cancels that a MsgBatchCancel +// can have in one Msg. +const MaxMsgBatchCancelBatchSize uint32 = 100 + // StatefulOrderTimeWindow represents the maximum amount of time in seconds past the current block time that a // long-term/conditional `MsgPlaceOrder` message will be considered valid by the validator. const StatefulOrderTimeWindow time.Duration = 95 * 24 * time.Hour // 95 days. diff --git a/protocol/x/clob/types/errors.go b/protocol/x/clob/types/errors.go index 5f2ec04f12..792f0a9451 100644 --- a/protocol/x/clob/types/errors.go +++ b/protocol/x/clob/types/errors.go @@ -206,6 +206,11 @@ var ( 44, "invalid time in force", ) + ErrInvalidBatchCancel = errorsmod.Register( + ModuleName, + 45, + "Invalid batch cancel message", + ) // Liquidations errors. ErrInvalidLiquidationsConfig = errorsmod.Register( diff --git a/protocol/x/clob/types/message_batch_cancel.go b/protocol/x/clob/types/message_batch_cancel.go new file mode 100644 index 0000000000..675f348577 --- /dev/null +++ b/protocol/x/clob/types/message_batch_cancel.go @@ -0,0 +1,71 @@ +package types + +import ( + errorsmod "cosmossdk.io/errors" + + sdk "github.com/cosmos/cosmos-sdk/types" + satypes "github.com/dydxprotocol/v4-chain/protocol/x/subaccounts/types" +) + +var _ sdk.Msg = &MsgBatchCancel{} + +// NewMsgBatchCancel constructs a MsgBatchCancel. +func NewMsgBatchCancel( + subaccountId satypes.SubaccountId, + cancelBatch []OrderBatch, + goodTilBlock uint32, +) *MsgBatchCancel { + return &MsgBatchCancel{ + SubaccountId: subaccountId, + ShortTermCancels: cancelBatch, + GoodTilBlock: goodTilBlock, + } +} + +// ValidateBasic performs stateless validation for the `MsgBatchCancel` msg. +func (msg *MsgBatchCancel) ValidateBasic() (err error) { + subaccountId := msg.GetSubaccountId() + if err := subaccountId.Validate(); err != nil { + return err + } + + cancelBatches := msg.GetShortTermCancels() + if len(cancelBatches) == 0 { + return errorsmod.Wrapf( + ErrInvalidBatchCancel, + "Batch cancel cannot have zero orders specified.", + ) + } + totalNumberCancels := 0 + for _, cancelBatch := range cancelBatches { + numClientIds := len(cancelBatch.GetClientIds()) + if numClientIds == 0 { + return errorsmod.Wrapf( + ErrInvalidBatchCancel, + "Order Batch cannot have zero client ids.", + ) + } + totalNumberCancels += numClientIds + seenClientIds := map[uint32]struct{}{} + for _, clientId := range cancelBatch.GetClientIds() { + if _, seen := seenClientIds[clientId]; seen { + return errorsmod.Wrapf( + ErrInvalidBatchCancel, + "Batch cancel cannot have duplicate cancels. Duplicate clob pair id: %+v, client id: %+v", + cancelBatch.GetClobPairId(), + clientId, + ) + } + seenClientIds[clientId] = struct{}{} + } + } + if uint32(totalNumberCancels) > MaxMsgBatchCancelBatchSize { + return errorsmod.Wrapf( + ErrInvalidBatchCancel, + "Batch cancel cannot have over %+v orders. Order count: %+v", + MaxMsgBatchCancelBatchSize, + totalNumberCancels, + ) + } + return nil +} diff --git a/protocol/x/clob/types/message_batch_cancel_test.go b/protocol/x/clob/types/message_batch_cancel_test.go new file mode 100644 index 0000000000..504ee7c992 --- /dev/null +++ b/protocol/x/clob/types/message_batch_cancel_test.go @@ -0,0 +1,144 @@ +package types_test + +import ( + "testing" + + "github.com/dydxprotocol/v4-chain/protocol/testutil/constants" + "github.com/dydxprotocol/v4-chain/protocol/x/clob/types" + satypes "github.com/dydxprotocol/v4-chain/protocol/x/subaccounts/types" + "github.com/stretchr/testify/require" +) + +func TestMsgBatchCancel_ValidateBasic(t *testing.T) { + oneOverMax := []uint32{} + for i := uint32(0); i < types.MaxMsgBatchCancelBatchSize+1; i++ { + oneOverMax = append(oneOverMax, i) + } + + tests := map[string]struct { + msg types.MsgBatchCancel + err error + }{ + "invalid subaccount": { + msg: *types.NewMsgBatchCancel( + constants.InvalidSubaccountIdNumber, + []types.OrderBatch{ + { + ClobPairId: 0, + ClientIds: []uint32{ + 0, 1, 2, 3, + }, + }, + }, + 10, + ), + err: satypes.ErrInvalidSubaccountIdNumber, + }, + "over 100 cancels in for one clob pair id": { + msg: *types.NewMsgBatchCancel( + constants.Alice_Num0, + []types.OrderBatch{ + { + ClobPairId: 0, + ClientIds: oneOverMax, + }, + }, + 10, + ), + err: types.ErrInvalidBatchCancel, + }, + "over 100 cancels split over two clob pair id": { + msg: *types.NewMsgBatchCancel( + constants.Alice_Num0, + []types.OrderBatch{ + { + ClobPairId: 0, + ClientIds: oneOverMax[:types.MaxMsgBatchCancelBatchSize/2+2], + }, + { + ClobPairId: 1, + ClientIds: oneOverMax[:types.MaxMsgBatchCancelBatchSize/2+2], + }, + }, + 10, + ), + err: types.ErrInvalidBatchCancel, + }, + "success: two clob pair id, 100 cancels": { + msg: *types.NewMsgBatchCancel( + constants.Alice_Num0, + []types.OrderBatch{ + { + ClobPairId: 0, + ClientIds: oneOverMax[:types.MaxMsgBatchCancelBatchSize/2], + }, + { + ClobPairId: 1, + ClientIds: oneOverMax[:types.MaxMsgBatchCancelBatchSize/2], + }, + }, + 10, + ), + err: nil, + }, + "success: one clob pair id, 100 cancels": { + msg: *types.NewMsgBatchCancel( + constants.Alice_Num0, + []types.OrderBatch{ + { + ClobPairId: 0, + ClientIds: oneOverMax[:types.MaxMsgBatchCancelBatchSize], + }, + }, + 10, + ), + err: nil, + }, + "duplicate clob pair ids": { + msg: *types.NewMsgBatchCancel( + constants.Alice_Num0, + []types.OrderBatch{ + { + ClobPairId: 0, + ClientIds: []uint32{ + 0, 1, 2, 3, 1, + }, + }, + }, + 10, + ), + err: types.ErrInvalidBatchCancel, + }, + "zero batches in cancel batch": { + msg: *types.NewMsgBatchCancel( + constants.Alice_Num0, + []types.OrderBatch{}, + 10, + ), + err: types.ErrInvalidBatchCancel, + }, + "zero client ids in cancel batch": { + msg: *types.NewMsgBatchCancel( + constants.Alice_Num0, + []types.OrderBatch{ + { + ClobPairId: 0, + ClientIds: []uint32{}, + }, + }, + 10, + ), + err: types.ErrInvalidBatchCancel, + }, + } + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + err := tc.msg.ValidateBasic() + if tc.err != nil { + require.ErrorIs(t, err, tc.err) + return + } + require.NoError(t, err) + }) + } +}