diff --git a/abci/abci.go b/abci/abci.go index 4bd9d46..6bfde2d 100644 --- a/abci/abci.go +++ b/abci/abci.go @@ -1,6 +1,7 @@ package abci import ( + "bytes" "crypto/sha256" "encoding/hex" "errors" @@ -175,7 +176,38 @@ func (h *ProposalHandler) PrepareProposalHandler() sdk.PrepareProposalHandler { // block proposal verification. func (h *ProposalHandler) ProcessProposalHandler() sdk.ProcessProposalHandler { return func(ctx sdk.Context, req abci.RequestProcessProposal) abci.ResponseProcessProposal { - panic("not implemented") + for index, txBz := range req.Txs { + tx, err := h.txVerifier.ProcessProposalVerifyTx(txBz) + if err != nil { + return abci.ResponseProcessProposal{Status: abci.ResponseProcessProposal_REJECT} + } + + msgAuctionBid, err := mempool.GetMsgAuctionBidFromTx(tx) + if err != nil { + return abci.ResponseProcessProposal{Status: abci.ResponseProcessProposal_REJECT} + } + + if msgAuctionBid != nil { + // Only the first transaction can be an auction bid tx + if index != 0 { + return abci.ResponseProcessProposal{Status: abci.ResponseProcessProposal_REJECT} + } + + // The order of transactions in the block proposal must follow the order of transactions in the bid. + if len(req.Txs) < len(msgAuctionBid.Transactions)+1 { + return abci.ResponseProcessProposal{Status: abci.ResponseProcessProposal_REJECT} + } + + for i, refTxRaw := range msgAuctionBid.Transactions { + if !bytes.Equal(refTxRaw, req.Txs[i+1]) { + return abci.ResponseProcessProposal{Status: abci.ResponseProcessProposal_REJECT} + } + } + } + + } + + return abci.ResponseProcessProposal{Status: abci.ResponseProcessProposal_ACCEPT} } } diff --git a/abci/abci_test.go b/abci/abci_test.go index 2bb4ebf..937ae36 100644 --- a/abci/abci_test.go +++ b/abci/abci_test.go @@ -1,6 +1,7 @@ package abci_test import ( + "bytes" "math/rand" "testing" "time" @@ -15,7 +16,7 @@ import ( "github.com/skip-mev/pob/mempool" "github.com/skip-mev/pob/x/auction/ante" "github.com/skip-mev/pob/x/auction/keeper" - "github.com/skip-mev/pob/x/auction/types" + auctiontypes "github.com/skip-mev/pob/x/auction/types" "github.com/stretchr/testify/suite" ) @@ -50,7 +51,7 @@ type ABCITestSuite struct { nonces map[string]uint64 } -func TestPrepareProposalSuite(t *testing.T) { +func TestABCISuite(t *testing.T) { suite.Run(t, new(ABCITestSuite)) } @@ -58,7 +59,7 @@ func (suite *ABCITestSuite) SetupTest() { // General config suite.encodingConfig = createTestEncodingConfig() suite.random = rand.New(rand.NewSource(time.Now().Unix())) - suite.key = sdk.NewKVStoreKey(types.StoreKey) + suite.key = sdk.NewKVStoreKey(auctiontypes.StoreKey) testCtx := testutil.DefaultContextWithDB(suite.T(), suite.key, sdk.NewTransientStoreKey("transient_test")) suite.ctx = testCtx.Ctx @@ -70,7 +71,7 @@ func (suite *ABCITestSuite) SetupTest() { // Mock keepers set up ctrl := gomock.NewController(suite.T()) suite.accountKeeper = NewMockAccountKeeper(ctrl) - suite.accountKeeper.EXPECT().GetModuleAddress(types.ModuleName).Return(sdk.AccAddress{}).AnyTimes() + suite.accountKeeper.EXPECT().GetModuleAddress(auctiontypes.ModuleName).Return(sdk.AccAddress{}).AnyTimes() suite.bankKeeper = NewMockBankKeeper(ctrl) suite.distrKeeper = NewMockDistributionKeeper(ctrl) suite.stakingKeeper = NewMockStakingKeeper(ctrl) @@ -86,9 +87,9 @@ func (suite *ABCITestSuite) SetupTest() { suite.stakingKeeper, suite.authorityAccount.String(), ) - err := suite.auctionKeeper.SetParams(suite.ctx, types.DefaultParams()) + err := suite.auctionKeeper.SetParams(suite.ctx, auctiontypes.DefaultParams()) suite.Require().NoError(err) - suite.auctionDecorator = ante.NewAuctionDecorator(suite.auctionKeeper, suite.encodingConfig.TxConfig.TxDecoder(), suite.mempool) + suite.auctionDecorator = ante.NewAuctionDecorator(suite.auctionKeeper, suite.encodingConfig.TxConfig.TxDecoder(), suite.encodingConfig.TxConfig.TxEncoder(), suite.mempool) // Accounts set up suite.accounts = RandomAccounts(suite.random, 1) @@ -117,8 +118,18 @@ func (suite *ABCITestSuite) PrepareProposalVerifyTx(tx sdk.Tx) ([]byte, error) { return txBz, nil } -func (suite *ABCITestSuite) ProcessProposalVerifyTx(_ []byte) (sdk.Tx, error) { - return nil, nil +func (suite *ABCITestSuite) ProcessProposalVerifyTx(txBz []byte) (sdk.Tx, error) { + tx, err := suite.encodingConfig.TxConfig.TxDecoder()(txBz) + if err != nil { + return nil, err + } + + _, err = suite.executeAnteHandler(tx) + if err != nil { + return tx, err + } + + return tx, nil } func (suite *ABCITestSuite) executeAnteHandler(tx sdk.Tx) (sdk.Context, error) { @@ -216,6 +227,41 @@ func (suite *ABCITestSuite) createFilledMempool(numNormalTxs, numAuctionTxs, num return totalNumTxs } +func (suite *ABCITestSuite) exportMempool(exportRefTxs bool) [][]byte { + txs := make([][]byte, 0) + seenTxs := make(map[string]bool) + + auctionIterator := suite.mempool.AuctionBidSelect(suite.ctx) + for ; auctionIterator != nil; auctionIterator = auctionIterator.Next() { + auctionTx := auctionIterator.Tx().(*mempool.WrappedBidTx).Tx + txBz, err := suite.encodingConfig.TxConfig.TxEncoder()(auctionTx) + suite.Require().NoError(err) + + txs = append(txs, txBz) + + if exportRefTxs { + for _, refRawTx := range auctionTx.GetMsgs()[0].(*auctiontypes.MsgAuctionBid).GetTransactions() { + txs = append(txs, refRawTx) + seenTxs[string(refRawTx)] = true + } + } + + seenTxs[string(txBz)] = true + } + + iterator := suite.mempool.Select(suite.ctx, nil) + for ; iterator != nil; iterator = iterator.Next() { + txBz, err := suite.encodingConfig.TxConfig.TxEncoder()(iterator.Tx()) + suite.Require().NoError(err) + + if !seenTxs[string(txBz)] { + txs = append(txs, txBz) + } + } + + return txs +} + func (suite *ABCITestSuite) TestPrepareProposal() { var ( // the modified transactions cannot exceed this size @@ -436,7 +482,7 @@ func (suite *ABCITestSuite) TestPrepareProposal() { suite.createFilledMempool(numNormalTxs, numAuctionTxs, numBundledTxs, insertRefTxs) // create a new auction - params := types.Params{ + params := auctiontypes.Params{ MaxBundleSize: maxBundleSize, ReserveFee: reserveFee, MinBuyInFee: minBuyInFee, @@ -444,7 +490,7 @@ func (suite *ABCITestSuite) TestPrepareProposal() { MinBidIncrement: suite.minBidIncrement, } suite.auctionKeeper.SetParams(suite.ctx, params) - suite.auctionDecorator = ante.NewAuctionDecorator(suite.auctionKeeper, suite.encodingConfig.TxConfig.TxDecoder(), suite.mempool) + suite.auctionDecorator = ante.NewAuctionDecorator(suite.auctionKeeper, suite.encodingConfig.TxConfig.TxDecoder(), suite.encodingConfig.TxConfig.TxEncoder(), suite.mempool) handler := suite.proposalHandler.PrepareProposalHandler() res := handler(suite.ctx, abcitypes.RequestPrepareProposal{ @@ -500,6 +546,221 @@ func (suite *ABCITestSuite) TestPrepareProposal() { } } +func (suite *ABCITestSuite) TestProcessProposal() { + var ( + // mempool set up + numNormalTxs = 100 + numAuctionTxs = 1 + numBundledTxs = 3 + insertRefTxs = true + exportRefTxs = true + frontRunningTx sdk.Tx + + // auction set up + maxBundleSize uint32 = 10 + reserveFee = sdk.NewCoins(sdk.NewCoin("foo", sdk.NewInt(1000))) + minBuyInFee = sdk.NewCoins(sdk.NewCoin("foo", sdk.NewInt(1000))) + frontRunningProtection = true + ) + + cases := []struct { + name string + malleate func() + isTopBidValid bool + response abcitypes.ResponseProcessProposal_ProposalStatus + }{ + { + "single normal tx, no auction tx", + func() { + numNormalTxs = 1 + numAuctionTxs = 0 + numBundledTxs = 0 + }, + false, + abcitypes.ResponseProcessProposal_ACCEPT, + }, + { + "single auction tx, no normal txs", + func() { + numNormalTxs = 0 + numAuctionTxs = 1 + numBundledTxs = 0 + }, + true, + abcitypes.ResponseProcessProposal_ACCEPT, + }, + { + "single auction tx, single auction tx", + func() { + numNormalTxs = 1 + numAuctionTxs = 1 + numBundledTxs = 0 + }, + true, + abcitypes.ResponseProcessProposal_ACCEPT, + }, + { + "single auction tx, single auction tx with ref txs", + func() { + numNormalTxs = 1 + numAuctionTxs = 1 + numBundledTxs = 4 + }, + true, + abcitypes.ResponseProcessProposal_ACCEPT, + }, + { + "single auction tx, single auction tx with no ref txs", + func() { + numNormalTxs = 1 + numAuctionTxs = 1 + numBundledTxs = 4 + insertRefTxs = false + exportRefTxs = false + }, + true, + abcitypes.ResponseProcessProposal_REJECT, + }, + { + "multiple auction txs, single normal tx", + func() { + numNormalTxs = 1 + numAuctionTxs = 2 + numBundledTxs = 4 + insertRefTxs = true + exportRefTxs = true + }, + true, + abcitypes.ResponseProcessProposal_REJECT, + }, + { + "single auction txs, multiple normal tx", + func() { + numNormalTxs = 100 + numAuctionTxs = 1 + numBundledTxs = 4 + }, + true, + abcitypes.ResponseProcessProposal_ACCEPT, + }, + { + "single invalid auction tx, multiple normal tx", + func() { + numNormalTxs = 100 + numAuctionTxs = 1 + numBundledTxs = 4 + reserveFee = sdk.NewCoins(sdk.NewCoin("foo", sdk.NewInt(100000000000000000))) + insertRefTxs = true + }, + false, + abcitypes.ResponseProcessProposal_REJECT, + }, + { + "single valid auction txs but missing ref txs", + func() { + numNormalTxs = 0 + numAuctionTxs = 1 + numBundledTxs = 4 + reserveFee = sdk.NewCoins(sdk.NewCoin("foo", sdk.NewInt(1000))) + insertRefTxs = false + exportRefTxs = false + }, + true, + abcitypes.ResponseProcessProposal_REJECT, + }, + { + "single valid auction txs but missing ref txs, with many normal txs", + func() { + numNormalTxs = 100 + numAuctionTxs = 1 + numBundledTxs = 4 + reserveFee = sdk.NewCoins(sdk.NewCoin("foo", sdk.NewInt(1000))) + insertRefTxs = false + exportRefTxs = false + }, + true, + abcitypes.ResponseProcessProposal_REJECT, + }, + { + "auction tx with frontrunning", + func() { + randomAccount := RandomAccounts(suite.random, 1)[0] + bidder := suite.accounts[0] + bid := sdk.NewCoins(sdk.NewCoin("foo", sdk.NewInt(696969696969))) + nonce := suite.nonces[bidder.Address.String()] + frontRunningTx, _ = createAuctionTxWithSigners(suite.encodingConfig.TxConfig, suite.accounts[0], bid, nonce+1, []Account{bidder, randomAccount}) + suite.Require().NotNil(frontRunningTx) + + numNormalTxs = 100 + numAuctionTxs = 1 + numBundledTxs = 4 + insertRefTxs = true + exportRefTxs = true + }, + false, + abcitypes.ResponseProcessProposal_REJECT, + }, + { + "auction tx with frontrunning, but frontrunning protection disabled", + func() { + randomAccount := RandomAccounts(suite.random, 1)[0] + bidder := suite.accounts[0] + bid := sdk.NewCoins(sdk.NewCoin("foo", sdk.NewInt(696969696969))) + nonce := suite.nonces[bidder.Address.String()] + frontRunningTx, _ = createAuctionTxWithSigners(suite.encodingConfig.TxConfig, suite.accounts[0], bid, nonce+1, []Account{bidder, randomAccount}) + suite.Require().NotNil(frontRunningTx) + + numAuctionTxs = 0 + frontRunningProtection = false + }, + true, + abcitypes.ResponseProcessProposal_ACCEPT, + }, + } + + for _, tc := range cases { + suite.Run(tc.name, func() { + suite.SetupTest() // reset + tc.malleate() + + suite.createFilledMempool(numNormalTxs, numAuctionTxs, numBundledTxs, insertRefTxs) + + if frontRunningTx != nil { + suite.Require().NoError(suite.mempool.Insert(suite.ctx, frontRunningTx)) + } + + // create a new auction + params := auctiontypes.Params{ + MaxBundleSize: maxBundleSize, + ReserveFee: reserveFee, + MinBuyInFee: minBuyInFee, + FrontRunningProtection: frontRunningProtection, + MinBidIncrement: suite.minBidIncrement, + } + suite.auctionKeeper.SetParams(suite.ctx, params) + suite.auctionDecorator = ante.NewAuctionDecorator(suite.auctionKeeper, suite.encodingConfig.TxConfig.TxDecoder(), suite.encodingConfig.TxConfig.TxEncoder(), suite.mempool) + suite.Require().Equal(tc.isTopBidValid, suite.isTopBidValid()) + + txs := suite.exportMempool(exportRefTxs) + + if frontRunningTx != nil { + txBz, err := suite.encodingConfig.TxConfig.TxEncoder()(frontRunningTx) + suite.Require().NoError(err) + + suite.Require().True(bytes.Equal(txs[0], txBz)) + } + + handler := suite.proposalHandler.ProcessProposalHandler() + res := handler(suite.ctx, abcitypes.RequestProcessProposal{ + Txs: txs, + }) + + // Check if the response is valid + suite.Require().Equal(tc.response, res.Status) + }) + } +} + // isTopBidValid returns true if the top bid is valid. We purposefully insert invalid // auction transactions into the mempool to test the handlers. func (suite *ABCITestSuite) isTopBidValid() bool { diff --git a/abci/utils_test.go b/abci/utils_test.go index d1b2a53..3dc73b5 100644 --- a/abci/utils_test.go +++ b/abci/utils_test.go @@ -146,3 +146,45 @@ func createMsgAuctionBid(txCfg client.TxConfig, bidder Account, bid sdk.Coins, n return bidMsg, nil } + +func createAuctionTxWithSigners(txCfg client.TxConfig, bidder Account, bid sdk.Coins, nonce uint64, signers []Account) (authsigning.Tx, error) { + bidMsg := &auctiontypes.MsgAuctionBid{ + Bidder: bidder.Address.String(), + Bid: bid, + Transactions: make([][]byte, len(signers)), + } + + for i := 0; i < len(signers); i++ { + randomMsg := createRandomMsgs(signers[i].Address, 1) + randomTx, err := createTx(txCfg, signers[i], 0, randomMsg) + if err != nil { + return nil, err + } + + bz, err := txCfg.TxEncoder()(randomTx) + if err != nil { + return nil, err + } + + bidMsg.Transactions[i] = bz + } + + txBuilder := txCfg.NewTxBuilder() + if err := txBuilder.SetMsgs(bidMsg); err != nil { + return nil, err + } + + sigV2 := signing.SignatureV2{ + PubKey: bidder.PrivKey.PubKey(), + Data: &signing.SingleSignatureData{ + SignMode: txCfg.SignModeHandler().DefaultMode(), + Signature: nil, + }, + Sequence: nonce, + } + if err := txBuilder.SetSignatures(sigV2); err != nil { + return nil, err + } + + return txBuilder.GetTx(), nil +} diff --git a/mempool/tx.go b/mempool/tx.go index 6bda137..f9328fb 100644 --- a/mempool/tx.go +++ b/mempool/tx.go @@ -54,6 +54,10 @@ func GetMsgAuctionBidFromTx(tx sdk.Tx) (*auctiontypes.MsgAuctionBid, error) { // UnwrapBidTx attempts to unwrap a WrappedBidTx from an sdk.Tx if one exists. func UnwrapBidTx(tx sdk.Tx) sdk.Tx { + if tx == nil { + return nil + } + wTx, ok := tx.(*WrappedBidTx) if ok { return wTx.Tx diff --git a/mempool/tx_test.go b/mempool/tx_test.go index 78d8316..0583d9e 100644 --- a/mempool/tx_test.go +++ b/mempool/tx_test.go @@ -3,6 +3,7 @@ package mempool_test import ( "testing" + sdk "github.com/cosmos/cosmos-sdk/types" banktypes "github.com/cosmos/cosmos-sdk/x/bank/types" pobcodec "github.com/skip-mev/pob/codec" "github.com/skip-mev/pob/mempool" @@ -46,3 +47,22 @@ func TestGetMsgAuctionBidFromTx_NoBid(t *testing.T) { require.NoError(t, err) require.Nil(t, msg) } + +func TestGetUnwrappedTx(t *testing.T) { + encCfg := pobcodec.CreateEncodingConfig() + + txBuilder := encCfg.TxConfig.NewTxBuilder() + txBuilder.SetMsgs(&auctiontypes.MsgAuctionBid{}) + tx := txBuilder.GetTx() + + bid := sdk.NewCoins(sdk.NewCoin("foo", sdk.NewInt(1000000))) + wrappedTx := mempool.NewWrappedBidTx(tx, bid) + unWrappedTx := mempool.UnwrapBidTx(wrappedTx) + + unwrappedBz, err := encCfg.TxConfig.TxEncoder()(unWrappedTx) + require.NoError(t, err) + + txBz, err := encCfg.TxConfig.TxEncoder()(tx) + require.NoError(t, err) + require.Equal(t, txBz, unwrappedBz) +} diff --git a/x/auction/ante/ante.go b/x/auction/ante/ante.go index aae4a60..575cbf5 100644 --- a/x/auction/ante/ante.go +++ b/x/auction/ante/ante.go @@ -1,6 +1,8 @@ package ante import ( + "bytes" + "cosmossdk.io/errors" sdk "github.com/cosmos/cosmos-sdk/types" "github.com/skip-mev/pob/mempool" @@ -12,13 +14,15 @@ var _ sdk.AnteDecorator = AuctionDecorator{} type AuctionDecorator struct { auctionKeeper keeper.Keeper txDecoder sdk.TxDecoder + txEncoder sdk.TxEncoder mempool *mempool.AuctionMempool } -func NewAuctionDecorator(ak keeper.Keeper, txDecoder sdk.TxDecoder, mempool *mempool.AuctionMempool) AuctionDecorator { +func NewAuctionDecorator(ak keeper.Keeper, txDecoder sdk.TxDecoder, txEncoder sdk.TxEncoder, mempool *mempool.AuctionMempool) AuctionDecorator { return AuctionDecorator{ auctionKeeper: ak, txDecoder: txDecoder, + txEncoder: txEncoder, mempool: mempool, } } @@ -48,12 +52,23 @@ func (ad AuctionDecorator) AnteHandle(ctx sdk.Context, tx sdk.Tx, simulate bool, transactions[i] = decodedTx } - highestBid, err := ad.GetTopAuctionBid(ctx, tx) + topBid := sdk.NewCoins() + + // If the current transaction is the highest bidding transaction, then the highest bid is empty. + isTopBidTx, err := ad.IsTopBidTx(ctx, tx) if err != nil { - return ctx, errors.Wrap(err, "failed to get highest auction bid") + return ctx, errors.Wrap(err, "failed to check if current transaction is highest bidding transaction") } - if err := ad.auctionKeeper.ValidateAuctionMsg(ctx, bidder, auctionMsg.Bid, highestBid, transactions); err != nil { + if !isTopBidTx { + // Set the top bid to the highest bidding transaction. + topBid, err = ad.GetTopAuctionBid(ctx) + if err != nil { + return ctx, errors.Wrap(err, "failed to get highest auction bid") + } + } + + if err := ad.auctionKeeper.ValidateAuctionMsg(ctx, bidder, auctionMsg.Bid, topBid, transactions); err != nil { return ctx, errors.Wrap(err, "failed to validate auction bid") } } @@ -61,18 +76,33 @@ func (ad AuctionDecorator) AnteHandle(ctx sdk.Context, tx sdk.Tx, simulate bool, return next(ctx, tx, simulate) } -// GetTopAuctionBid returns the highest auction bid if one exists. If the current transaction is the highest -// bidding transaction, then an empty coin set is returned. -func (ad AuctionDecorator) GetTopAuctionBid(ctx sdk.Context, currTx sdk.Tx) (sdk.Coins, error) { +// GetTopAuctionBid returns the highest auction bid if one exists. +func (ad AuctionDecorator) GetTopAuctionBid(ctx sdk.Context) (sdk.Coins, error) { auctionTx := ad.mempool.GetTopAuctionTx(ctx) if auctionTx == nil { return sdk.NewCoins(), nil } - wrappedTx := auctionTx.(*mempool.WrappedBidTx) - if wrappedTx.Tx == currTx { - return sdk.NewCoins(), nil + return auctionTx.(*mempool.WrappedBidTx).GetBid(), nil +} + +// IsTopBidTx returns true if the transaction inputted is the highest bidding auction transaction in the mempool. +func (ad AuctionDecorator) IsTopBidTx(ctx sdk.Context, tx sdk.Tx) (bool, error) { + auctionTx := ad.mempool.GetTopAuctionTx(ctx) + if auctionTx == nil { + return false, nil } - return wrappedTx.GetBid(), nil + topBidTx := mempool.UnwrapBidTx(auctionTx) + topBidBz, err := ad.txEncoder(topBidTx) + if err != nil { + return false, err + } + + currentTxBz, err := ad.txEncoder(tx) + if err != nil { + return false, err + } + + return bytes.Equal(topBidBz, currentTxBz), nil }