diff --git a/go.mod b/go.mod index e1f11b3..6fa67c4 100644 --- a/go.mod +++ b/go.mod @@ -9,6 +9,7 @@ require ( github.com/cosmos/cosmos-proto v1.0.0-beta.2 github.com/cosmos/cosmos-sdk v0.47.0-rc3.0.20230308212038-818f6a047eeb github.com/cosmos/gogoproto v1.4.6 + github.com/golang/mock v1.6.0 github.com/golang/protobuf v1.5.2 github.com/gorilla/mux v1.8.0 github.com/grpc-ecosystem/grpc-gateway v1.16.0 diff --git a/go.sum b/go.sum index d325b79..c1f6f04 100644 --- a/go.sum +++ b/go.sum @@ -209,6 +209,7 @@ github.com/golang/mock v1.4.1/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt github.com/golang/mock v1.4.3/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4= github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc= +github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.3.0/go.mod h1:Qd/q+1AKNOZr9uGQzbzCmRO6sUih6GTPZv6a1/R87v0= github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= @@ -489,6 +490,7 @@ github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9de github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= github.com/zondax/hid v0.9.1 h1:gQe66rtmyZ8VeGFcOpbuH3r7erYtNEAezCAYu8LdkJo= github.com/zondax/hid v0.9.1/go.mod h1:l5wttcP0jwtdLjqjMMWFVEE7d1zO0jvSPA9OPZxWpEM= github.com/zondax/ledger-go v0.14.0 h1:dlMC7aO8Wss1CxBq2I96kZ69Nh1ligzbs8UWOtq/AsA= @@ -552,6 +554,7 @@ golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.4.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -588,6 +591,7 @@ golang.org/x/net v0.0.0-20201031054903-ff519b6c9102/go.mod h1:sp8m0HH+o8qH0wwXwY golang.org/x/net v0.0.0-20201209123823-ac852fbbde11/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20201224014010-6772e930b67b/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= golang.org/x/net v0.0.0-20210525063256-abc453219eb5/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= @@ -614,6 +618,7 @@ golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -662,8 +667,10 @@ golang.org/x/sys v0.0.0-20210104204734-6f8348627aad/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210225134936-a50acf3fe073/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423185535-09eb48e85fd7/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210603081109-ebe580a85c40/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210819135213-f52c844e1c1c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -740,6 +747,7 @@ golang.org/x/tools v0.0.0-20210105154028-b0ab187a4818/go.mod h1:emZCQorbCU4vsT4f golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.0.0-20210108195828-e2f9c7f1fc8e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0= +golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/proto/pob/auction/v1/genesis.proto b/proto/pob/auction/v1/genesis.proto index b8f044c..f82b7b0 100644 --- a/proto/pob/auction/v1/genesis.proto +++ b/proto/pob/auction/v1/genesis.proto @@ -20,13 +20,13 @@ message Params { // escrow_account_address is the address of the account that will hold the // funds for the auctions. string escrow_account_address = 2; - // reserve_fee specifies a fee that the bidder must pay to enter the auction. + // reserve_fee specifies the bid floor for the auction. repeated cosmos.base.v1beta1.Coin reserve_fee = 3 [ (gogoproto.nullable) = false, (amino.dont_omitempty) = true, (gogoproto.castrepeated) = "github.com/cosmos/cosmos-sdk/types.Coins" ]; - // min_buy_in_fee specifies the bid floor for the auction. + // min_buy_in_fee specifies the fee that the bidder must pay to enter the auction. repeated cosmos.base.v1beta1.Coin min_buy_in_fee = 4 [ (gogoproto.nullable) = false, (amino.dont_omitempty) = true, diff --git a/x/auction/ante/ante.go b/x/auction/ante/ante.go new file mode 100644 index 0000000..2db0029 --- /dev/null +++ b/x/auction/ante/ante.go @@ -0,0 +1,55 @@ +package ante + +import ( + "cosmossdk.io/errors" + sdk "github.com/cosmos/cosmos-sdk/types" + "github.com/skip-mev/pob/mempool" + "github.com/skip-mev/pob/x/auction/keeper" +) + +var _ sdk.AnteDecorator = AuctionDecorator{} + +type AuctionDecorator struct { + auctionKeeper keeper.Keeper + txDecoder sdk.TxDecoder +} + +func NewAuctionDecorator(ak keeper.Keeper, txDecoder sdk.TxDecoder) AuctionDecorator { + return AuctionDecorator{ + auctionKeeper: ak, + txDecoder: txDecoder, + } +} + +// AnteHandle validates that the auction bid is valid if one exists. If valid it will deduct the entrance fee from the +// bidder's account. +func (ad AuctionDecorator) AnteHandle(ctx sdk.Context, tx sdk.Tx, simulate bool, next sdk.AnteHandler) (sdk.Context, error) { + auctionMsg, err := mempool.GetMsgAuctionBidFromTx(tx) + if err != nil { + return ctx, err + } + + // Validate the auction bid if one exists. + if auctionMsg != nil { + bidder, err := sdk.AccAddressFromBech32(auctionMsg.Bidder) + if err != nil { + return ctx, errors.Wrapf(err, "invalid bidder address (%s)", auctionMsg.Bidder) + } + + transactions := make([]sdk.Tx, len(auctionMsg.Transactions)) + for i, tx := range auctionMsg.Transactions { + decodedTx, err := ad.txDecoder(tx) + if err != nil { + return ctx, errors.Wrapf(err, "failed to decode transaction (%s)", tx) + } + + transactions[i] = decodedTx + } + + if err := ad.auctionKeeper.ValidateAuctionMsg(ctx, bidder, auctionMsg.Bid, transactions); err != nil { + return ctx, errors.Wrap(err, "failed to validate auction bid") + } + } + + return next(ctx, tx, simulate) +} diff --git a/x/auction/keeper/auction.go b/x/auction/keeper/auction.go new file mode 100644 index 0000000..4257154 --- /dev/null +++ b/x/auction/keeper/auction.go @@ -0,0 +1,139 @@ +package keeper + +import ( + "fmt" + + sdk "github.com/cosmos/cosmos-sdk/types" +) + +// ValidateAuctionMsg validates that the MsgAuctionBid is valid. It checks that the bidder has sufficient funds to bid the +// amount specified in the message, that the bundle size is not greater than the max bundle size, and that the bundle +// transactions are valid. +func (k Keeper) ValidateAuctionMsg(ctx sdk.Context, bidder sdk.AccAddress, bid sdk.Coins, transactions []sdk.Tx) error { + // Validate the bundle size. + maxBundleSize, err := k.GetMaxBundleSize(ctx) + if err != nil { + return err + } + + if uint32(len(transactions)) > maxBundleSize { + return fmt.Errorf("bundle size (%d) exceeds max bundle size (%d)", len(transactions), maxBundleSize) + } + + // Validate the bid amount. + if err := k.ValidateAuctionBid(ctx, bidder, bid); err != nil { + return err + } + + // Validate the bundle of transactions. + if err := k.ValidateAuctionBundle(ctx, bidder, transactions); err != nil { + return err + } + + return nil +} + +// ValidateAuctionBid validates that the bidder has sufficient funds to participate in the auction. +func (k Keeper) ValidateAuctionBid(ctx sdk.Context, bidder sdk.AccAddress, bid sdk.Coins) error { + // Get the bid floor. + reserveFee, err := k.GetReserveFee(ctx) + if err != nil { + return err + } + + if !bid.IsAllGTE(reserveFee) { + return fmt.Errorf("bid amount (%s) is less than the reserve fee (%s)", bid, reserveFee) + } + + // Get the pay-to-play fee. + minBuyInFee, err := k.GetMinBuyInFee(ctx) + if err != nil { + return err + } + + // Ensure the bidder has enough funds to cover all the inclusion fees. + minBalance := bid.Add(minBuyInFee...) + balances := k.bankkeeper.GetAllBalances(ctx, bidder) + if !balances.IsAllGTE(minBalance) { + return fmt.Errorf("insufficient funds to bid %s (reserve fee + bid) with balance %s", minBalance, balances) + } + + return nil +} + +// ValidateAuctionBundle validates the ordering of the referenced transactions. Bundles are valid if +// 1. all of the transactions are signed by the signer. +// 2. some subset of contiguous transactions starting from the first tx are signed by the same signer, and all other tranasctions +// are signed by the bidder. +// +// example: +// 1. valid: [tx1, tx2, tx3] where tx1 is signed by the signer 1 and tx2 and tx3 are signed by the bidder. +// 2. valid: [tx1, tx2, tx3, tx4] where tx1 - tx4 are signed by the bidder. +// 3. invalid: [tx1, tx2, tx3] where tx1 and tx3 are signed by the bidder and tx2 is signed by some other signer. (possible sandwich attack) +// 4. invalid: [tx1, tx2, tx3] where tx1 is signed by the bidder, and tx2 - tx3 are signed by some other signer. (possible front-running attack) +func (k Keeper) ValidateAuctionBundle(ctx sdk.Context, bidder sdk.AccAddress, transactions []sdk.Tx) error { + if len(transactions) <= 1 { + return nil + } + + // prevSigners is used to track whether the signers of the current transaction overlap. + prevSigners, err := k.getTxSigners(transactions[0]) + if err != nil { + return err + } + seenBidder := prevSigners[bidder.String()] + + // Check that all subsequent transactions are signed by either + // 1. the same party as the first transaction + // 2. the same party for some arbitrary number of txs and then are all remaining txs are signed by the bidder. + for _, txbytes := range transactions[1:] { + txSigners, err := k.getTxSigners(txbytes) + if err != nil { + return err + } + + // Filter the signers to only those that signed the current transaction. + filterSigners(prevSigners, txSigners) + + // If there are no overlapping signers from the previous tx and the bidder address has not been seen, then the bundle can still be valid + // as long as all subsequent transactions are signed by the bidder. + if len(prevSigners) == 0 { + if seenBidder { + return fmt.Errorf("bundle contains transactions signed by multiple parties. possible front-running or sandwich attack.") + } else { + seenBidder = true + prevSigners = map[string]bool{bidder.String(): true} + filterSigners(prevSigners, txSigners) + + if len(prevSigners) == 0 { + return fmt.Errorf("bundle contains transactions signed by multiple parties. possible front-running or sandwich attack.") + } + } + } + } + + return nil +} + +// getTxSigners returns the signers of a transaction. +func (k Keeper) getTxSigners(tx sdk.Tx) (map[string]bool, error) { + signers := make(map[string]bool, 0) + for _, msg := range tx.GetMsgs() { + for _, signer := range msg.GetSigners() { + // TODO: check for multi-sig accounts + // https://github.com/skip-mev/pob/issues/14 + signers[signer.String()] = true + } + } + + return signers, nil +} + +// filterSigners removes any signers from the currentSigners map that are not in the txSigners map. +func filterSigners(currentSigners, txSigners map[string]bool) { + for signer := range currentSigners { + if _, ok := txSigners[signer]; !ok { + delete(currentSigners, signer) + } + } +} diff --git a/x/auction/keeper/auction_test.go b/x/auction/keeper/auction_test.go new file mode 100644 index 0000000..68187f3 --- /dev/null +++ b/x/auction/keeper/auction_test.go @@ -0,0 +1,303 @@ +package keeper_test + +import ( + "math/rand" + "time" + + "github.com/cosmos/cosmos-sdk/client" + sdk "github.com/cosmos/cosmos-sdk/types" + "github.com/cosmos/cosmos-sdk/types/tx/signing" + authsigning "github.com/cosmos/cosmos-sdk/x/auth/signing" + banktypes "github.com/cosmos/cosmos-sdk/x/bank/types" + "github.com/skip-mev/pob/x/auction/keeper" + auctiontypes "github.com/skip-mev/pob/x/auction/types" +) + +func (suite *IntegrationTestSuite) TestValidateAuctionMsg() { + var ( + // Tx building variables + accounts []Account = []Account{} // tracks the order of signers in the bundle + balance sdk.Coins = sdk.NewCoins(sdk.NewCoin("foo", sdk.NewInt(10000))) + bid sdk.Coins = sdk.NewCoins(sdk.NewCoin("foo", sdk.NewInt(1000))) + + // Auction params + maxBundleSize uint32 = 10 + reserveFee sdk.Coins = sdk.NewCoins(sdk.NewCoin("foo", sdk.NewInt(1000))) + minBuyInFee sdk.Coins = sdk.NewCoins(sdk.NewCoin("foo", sdk.NewInt(1000))) + escrowAddress sdk.AccAddress = sdk.AccAddress([]byte("escrow")) + ) + + rnd := rand.New(rand.NewSource(time.Now().Unix())) + bidder := RandomAccounts(rnd, 1)[0] + + cases := []struct { + name string + malleate func() + pass bool + }{ + { + "insufficient bid amount", + func() { + bid = sdk.NewCoins() + }, + false, + }, + { + "insufficient balance", + func() { + bid = sdk.NewCoins(sdk.NewCoin("foo", sdk.NewInt(1000))) + balance = sdk.NewCoins() + }, + false, + }, + { + "bid amount equals the balance (not accounting for the reserve fee)", + func() { + balance = sdk.NewCoins(sdk.NewCoin("foo", sdk.NewInt(2000))) + bid = sdk.NewCoins(sdk.NewCoin("foo", sdk.NewInt(2000))) + }, + false, + }, + { + "too many transactions in the bundle", + func() { + // reset the balance and bid to their original values + bid = sdk.NewCoins(sdk.NewCoin("foo", sdk.NewInt(1000))) + balance = sdk.NewCoins(sdk.NewCoin("foo", sdk.NewInt(10000))) + accounts = RandomAccounts(rnd, int(maxBundleSize+1)) + }, + false, + }, + { + "frontrunning bundle", + func() { + randomAccount := RandomAccounts(rnd, 1)[0] + accounts = []Account{bidder, randomAccount} + }, + false, + }, + { + "sandwiching bundle", + func() { + randomAccount := RandomAccounts(rnd, 1)[0] + accounts = []Account{bidder, randomAccount, bidder} + }, + false, + }, + { + "valid bundle", + func() { + randomAccount := RandomAccounts(rnd, 1)[0] + accounts = []Account{randomAccount, randomAccount, bidder, bidder, bidder} + }, + true, + }, + { + "valid bundle with only bidder txs", + func() { + accounts = []Account{bidder, bidder, bidder, bidder} + }, + true, + }, + { + "valid bundle with only random txs from single same user", + func() { + randomAccount := RandomAccounts(rnd, 1)[0] + accounts = []Account{randomAccount, randomAccount, randomAccount, randomAccount} + }, + true, + }, + { + "invalid bundle with random accounts", + func() { + accounts = RandomAccounts(rnd, 2) + }, + false, + }, + } + + for _, tc := range cases { + suite.Run(tc.name, func() { + suite.SetupTest() // reset + + tc.malleate() + + // Set up the new auction keeper with mocks customized for this test case + suite.bankKeeper.EXPECT().GetAllBalances(suite.ctx, bidder.Address).Return(balance).AnyTimes() + suite.bankKeeper.EXPECT().SendCoins(suite.ctx, bidder.Address, escrowAddress, reserveFee).Return(nil).AnyTimes() + + suite.auctionKeeper = keeper.NewKeeper( + suite.encCfg.Codec, + suite.key, + suite.accountKeeper, + suite.bankKeeper, + suite.authorityAccount.String(), + ) + params := auctiontypes.Params{ + MaxBundleSize: maxBundleSize, + ReserveFee: reserveFee, + MinBuyInFee: minBuyInFee, + EscrowAccountAddress: escrowAddress.String(), + } + suite.auctionKeeper.SetParams(suite.ctx, params) + + // Create the bundle of transactions ordered by accounts + bundle := make([]sdk.Tx, 0) + for _, acc := range accounts { + tx, err := createRandomTx(suite.encCfg.TxConfig, acc, 0, 1) + suite.Require().NoError(err) + bundle = append(bundle, tx) + } + + err := suite.auctionKeeper.ValidateAuctionMsg(suite.ctx, bidder.Address, bid, bundle) + if tc.pass { + suite.Require().NoError(err) + } else { + suite.Require().Error(err) + } + }) + } +} + +func (suite *IntegrationTestSuite) TestValidateBundle() { + var ( + // TODO: Update this to be multi-dimensional to test multi-sig + // https://github.com/skip-mev/pob/issues/14 + accounts []Account // tracks the order of signers in the bundle + ) + + rng := rand.New(rand.NewSource(time.Now().Unix())) + bidder := RandomAccounts(rng, 1)[0] + + cases := []struct { + name string + malleate func() + pass bool + }{ + { + "valid empty bundle", + func() { + accounts = make([]Account, 0) + }, + true, + }, + { + "valid single tx bundle", + func() { + accounts = []Account{bidder} + }, + true, + }, + { + "valid multi-tx bundle by same account", + func() { + accounts = []Account{bidder, bidder, bidder, bidder} + }, + true, + }, + { + "valid single-tx bundle by a different account", + func() { + randomAccount := RandomAccounts(rng, 1)[0] + accounts = []Account{randomAccount} + }, + true, + }, + { + "valid multi-tx bundle by a different accounts", + func() { + randomAccount := RandomAccounts(rng, 1)[0] + accounts = []Account{randomAccount, bidder} + }, + true, + }, + { + "invalid frontrunning bundle", + func() { + randomAccount := RandomAccounts(rng, 1)[0] + accounts = []Account{bidder, randomAccount} + }, + false, + }, + { + "invalid sandwiching bundle", + func() { + randomAccount := RandomAccounts(rng, 1)[0] + accounts = []Account{bidder, randomAccount, bidder} + }, + false, + }, + { + "invalid multi account bundle", + func() { + accounts = RandomAccounts(rng, 3) + }, + false, + }, + { + "invalid multi account bundle without bidder", + func() { + randomAccount1 := RandomAccounts(rng, 1)[0] + randomAccount2 := RandomAccounts(rng, 1)[0] + accounts = []Account{randomAccount1, randomAccount2} + }, + false, + }, + } + + for _, tc := range cases { + suite.Run(tc.name, func() { + suite.SetupTest() // reset + + // Malleate the test case + tc.malleate() + + // Create the bundle of transactions ordered by accounts + bundle := make([]sdk.Tx, 0) + for _, acc := range accounts { + // Create a random tx + tx, err := createRandomTx(suite.encCfg.TxConfig, acc, 0, 1) + suite.Require().NoError(err) + bundle = append(bundle, tx) + } + + // Validate the bundle + err := suite.auctionKeeper.ValidateAuctionBundle(suite.ctx, bidder.Address, bundle) + if tc.pass { + suite.Require().NoError(err) + } else { + suite.Require().Error(err) + } + }) + } +} + +// createRandomTx creates a random transaction with a given account, nonce, and number of messages. +func createRandomTx(txCfg client.TxConfig, account Account, nonce, numberMsgs uint64) (authsigning.Tx, error) { + msgs := make([]sdk.Msg, numberMsgs) + for i := 0; i < int(numberMsgs); i++ { + msgs[i] = &banktypes.MsgSend{ + FromAddress: account.Address.String(), + ToAddress: account.Address.String(), + } + } + + txBuilder := txCfg.NewTxBuilder() + if err := txBuilder.SetMsgs(msgs...); err != nil { + return nil, err + } + + sigV2 := signing.SignatureV2{ + PubKey: account.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/x/auction/keeper/keeper.go b/x/auction/keeper/keeper.go index 83fc7f4..269e94e 100644 --- a/x/auction/keeper/keeper.go +++ b/x/auction/keeper/keeper.go @@ -120,7 +120,7 @@ func (k Keeper) GetReserveFee(ctx sdk.Context) (sdk.Coins, error) { return params.ReserveFee, nil } -// GetMinBuyInFee returns the bid floor for an auction. +// GetMinBuyInFee returns the fee that the bidder must pay to enter the auction. func (k Keeper) GetMinBuyInFee(ctx sdk.Context) (sdk.Coins, error) { params, err := k.GetParams(ctx) if err != nil { diff --git a/x/auction/keeper/keeper_test.go b/x/auction/keeper/keeper_test.go new file mode 100644 index 0000000..45ad2ed --- /dev/null +++ b/x/auction/keeper/keeper_test.go @@ -0,0 +1,219 @@ +package keeper_test + +import ( + "math/rand" + "reflect" + "testing" + + "github.com/cosmos/cosmos-sdk/client" + "github.com/cosmos/cosmos-sdk/codec" + codectypes "github.com/cosmos/cosmos-sdk/codec/types" + cryptocodec "github.com/cosmos/cosmos-sdk/crypto/codec" + "github.com/cosmos/cosmos-sdk/crypto/keys/ed25519" + "github.com/cosmos/cosmos-sdk/crypto/keys/secp256k1" + cryptotypes "github.com/cosmos/cosmos-sdk/crypto/types" + storetypes "github.com/cosmos/cosmos-sdk/store/types" + "github.com/cosmos/cosmos-sdk/testutil" + sdk "github.com/cosmos/cosmos-sdk/types" + "github.com/cosmos/cosmos-sdk/x/auth/tx" + banktypes "github.com/cosmos/cosmos-sdk/x/bank/types" + "github.com/golang/mock/gomock" + "github.com/skip-mev/pob/x/auction/ante" + "github.com/skip-mev/pob/x/auction/keeper" + "github.com/skip-mev/pob/x/auction/types" + + "github.com/stretchr/testify/suite" +) + +type IntegrationTestSuite struct { + suite.Suite + + auctionKeeper keeper.Keeper + bankKeeper *MockBankKeeper + accountKeeper *MockAccountKeeper + encCfg encodingConfig + AuctionDecorator sdk.AnteDecorator + ctx sdk.Context + msgServer types.MsgServer + key *storetypes.KVStoreKey + authorityAccount sdk.AccAddress +} + +func TestKeeperTestSuite(t *testing.T) { + suite.Run(t, new(IntegrationTestSuite)) +} + +func (suite *IntegrationTestSuite) SetupTest() { + suite.encCfg = createTestEncodingConfig() + suite.key = sdk.NewKVStoreKey(types.StoreKey) + testCtx := testutil.DefaultContextWithDB(suite.T(), suite.key, sdk.NewTransientStoreKey("transient_test")) + suite.ctx = testCtx.Ctx + + // Auction Keeper setup + ctrl := gomock.NewController(suite.T()) + suite.accountKeeper = NewMockAccountKeeper(ctrl) + suite.accountKeeper.EXPECT().GetModuleAddress(types.ModuleName).Return(sdk.AccAddress{}).AnyTimes() + suite.bankKeeper = NewMockBankKeeper(ctrl) + suite.authorityAccount = sdk.AccAddress([]byte("authority")) + suite.auctionKeeper = keeper.NewKeeper(suite.encCfg.Codec, suite.key, suite.accountKeeper, suite.bankKeeper, suite.authorityAccount.String()) + + err := suite.auctionKeeper.SetParams(suite.ctx, types.DefaultParams()) + suite.Require().NoError(err) + + suite.AuctionDecorator = ante.NewAuctionDecorator(suite.auctionKeeper, suite.encCfg.TxConfig.TxDecoder()) + suite.msgServer = keeper.NewMsgServerImpl(suite.auctionKeeper) +} + +type encodingConfig struct { + InterfaceRegistry codectypes.InterfaceRegistry + Codec codec.Codec + TxConfig client.TxConfig + Amino *codec.LegacyAmino +} + +func createTestEncodingConfig() encodingConfig { + cdc := codec.NewLegacyAmino() + interfaceRegistry := codectypes.NewInterfaceRegistry() + + banktypes.RegisterInterfaces(interfaceRegistry) + cryptocodec.RegisterInterfaces(interfaceRegistry) + + codec := codec.NewProtoCodec(interfaceRegistry) + + return encodingConfig{ + InterfaceRegistry: interfaceRegistry, + Codec: codec, + TxConfig: tx.NewTxConfig(codec, tx.DefaultSignModes), + Amino: cdc, + } +} + +type Account struct { + PrivKey cryptotypes.PrivKey + PubKey cryptotypes.PubKey + Address sdk.AccAddress + ConsKey cryptotypes.PrivKey +} + +func (acc Account) Equals(acc2 Account) bool { + return acc.Address.Equals(acc2.Address) +} + +// RandomAccounts returns a slice of n random accounts. +func RandomAccounts(r *rand.Rand, n int) []Account { + accs := make([]Account, n) + + for i := 0; i < n; i++ { + pkSeed := make([]byte, 15) + r.Read(pkSeed) + + accs[i].PrivKey = secp256k1.GenPrivKeyFromSecret(pkSeed) + accs[i].PubKey = accs[i].PrivKey.PubKey() + accs[i].Address = sdk.AccAddress(accs[i].PubKey.Address()) + + accs[i].ConsKey = ed25519.GenPrivKeyFromSecret(pkSeed) + } + + return accs +} + +// MockAccountKeeper is a mock of AccountKeeper interface. +type MockAccountKeeper struct { + ctrl *gomock.Controller + recorder *MockAccountKeeperMockRecorder +} + +// MockAccountKeeperMockRecorder is the mock recorder for MockAccountKeeper. +type MockAccountKeeperMockRecorder struct { + mock *MockAccountKeeper +} + +// NewMockAccountKeeper creates a new mock instance. +func NewMockAccountKeeper(ctrl *gomock.Controller) *MockAccountKeeper { + mock := &MockAccountKeeper{ctrl: ctrl} + mock.recorder = &MockAccountKeeperMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockAccountKeeper) EXPECT() *MockAccountKeeperMockRecorder { + return m.recorder +} + +// GetModuleAddress mocks base method. +func (m *MockAccountKeeper) GetModuleAddress(name string) sdk.AccAddress { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetModuleAddress", name) + ret0, _ := ret[0].(sdk.AccAddress) + return ret0 +} + +// GetModuleAddress indicates an expected call of GetModuleAddress. +func (mr *MockAccountKeeperMockRecorder) GetModuleAddress(name interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetModuleAddress", reflect.TypeOf((*MockAccountKeeper)(nil).GetModuleAddress), name) +} + +// MockBankKeeper is a mock of BankKeeper interface. +type MockBankKeeper struct { + ctrl *gomock.Controller + recorder *MockBankKeeperMockRecorder +} + +// MockBankKeeperMockRecorder is the mock recorder for MockBankKeeper. +type MockBankKeeperMockRecorder struct { + mock *MockBankKeeper +} + +// NewMockBankKeeper creates a new mock instance. +func NewMockBankKeeper(ctrl *gomock.Controller) *MockBankKeeper { + mock := &MockBankKeeper{ctrl: ctrl} + mock.recorder = &MockBankKeeperMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockBankKeeper) EXPECT() *MockBankKeeperMockRecorder { + return m.recorder +} + +// GetAllBalances mocks base method. +func (m *MockBankKeeper) GetAllBalances(ctx sdk.Context, addr sdk.AccAddress) sdk.Coins { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAllBalances", ctx, addr) + ret0 := ret[0].(sdk.Coins) + return ret0 +} + +// GetAllBalances indicates an expected call of GetAllBalances. +func (mr *MockBankKeeperMockRecorder) GetAllBalances(ctx, addr interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAllBalances", reflect.TypeOf((*MockBankKeeper)(nil).GetAllBalances), ctx, addr) +} + +// SendCoins mocks base method. +func (m *MockBankKeeper) SendCoins(ctx sdk.Context, fromAddr sdk.AccAddress, toAddr sdk.AccAddress, amt sdk.Coins) error { + m.ctrl.T.Helper() + m.ctrl.Call(m, "SendCoins", ctx, fromAddr, toAddr, amt) + return nil +} + +// SendCoins indicates an expected call of SendCoins. +func (mr *MockBankKeeperMockRecorder) SendCoins(ctx, fromAddr, toAddr, amt interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SendCoins", reflect.TypeOf((*MockBankKeeper)(nil).SendCoins), ctx, fromAddr, toAddr, amt) +} + +// SendCoinsFromAccountToModule mocks base method. +func (m *MockBankKeeper) SendCoinsFromAccountToModule(ctx sdk.Context, senderAddr sdk.AccAddress, recipientModule string, amt sdk.Coins) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SendCoinsFromAccountToModule", ctx, senderAddr, recipientModule, amt) + ret0, _ := ret[0].(error) + return ret0 +} + +// SendCoinsFromAccountToModule indicates an expected call of SendCoinsFromAccountToModule. +func (mr *MockBankKeeperMockRecorder) SendCoinsFromAccountToModule(ctx, senderAddr, recipientModule, amt interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SendCoinsFromAccountToModule", reflect.TypeOf((*MockBankKeeper)(nil).SendCoinsFromAccountToModule), ctx, senderAddr, recipientModule, amt) +} diff --git a/x/auction/types/expected_keepers.go b/x/auction/types/expected_keepers.go index f2b1616..6522c64 100644 --- a/x/auction/types/expected_keepers.go +++ b/x/auction/types/expected_keepers.go @@ -13,4 +13,5 @@ type AccountKeeper interface { type BankKeeper interface { SendCoins(ctx sdk.Context, fromAddr sdk.AccAddress, toAddr sdk.AccAddress, amt sdk.Coins) error SendCoinsFromAccountToModule(ctx sdk.Context, senderAddr sdk.AccAddress, recipientModule string, amt sdk.Coins) error + GetAllBalances(ctx sdk.Context, addr sdk.AccAddress) sdk.Coins } diff --git a/x/auction/types/genesis.pb.go b/x/auction/types/genesis.pb.go index d3879de..58a69bf 100644 --- a/x/auction/types/genesis.pb.go +++ b/x/auction/types/genesis.pb.go @@ -79,9 +79,9 @@ type Params struct { // escrow_account_address is the address of the account that will hold the // funds for the auctions. EscrowAccountAddress string `protobuf:"bytes,2,opt,name=escrow_account_address,json=escrowAccountAddress,proto3" json:"escrow_account_address,omitempty"` - // reserve_fee specifies a fee that the bidder must pay to enter the auction. + // reserve_fee specifies the bid floor for the auction. ReserveFee github_com_cosmos_cosmos_sdk_types.Coins `protobuf:"bytes,3,rep,name=reserve_fee,json=reserveFee,proto3,castrepeated=github.com/cosmos/cosmos-sdk/types.Coins" json:"reserve_fee"` - // min_buy_in_fee specifies the bid floor for the auction. + // min_buy_in_fee specifies the fee that the bidder must pay to enter the auction. MinBuyInFee github_com_cosmos_cosmos_sdk_types.Coins `protobuf:"bytes,4,rep,name=min_buy_in_fee,json=minBuyInFee,proto3,castrepeated=github.com/cosmos/cosmos-sdk/types.Coins" json:"min_buy_in_fee"` // min_bid_increment specifies the minimum amount that the next bid must be // greater than the previous bid.