diff --git a/.golangci.yml b/.golangci.yml index a001bf3..73be3d7 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -4,6 +4,8 @@ run: sort-results: true allow-parallel-runners: true concurrency: 4 + skip-dirs: + - tests/e2e linters: disable-all: true diff --git a/tests/e2e/e2e_setup_test.go b/tests/e2e/e2e_setup_test.go index 4be1fd2..0221c87 100644 --- a/tests/e2e/e2e_setup_test.go +++ b/tests/e2e/e2e_setup_test.go @@ -14,6 +14,7 @@ import ( cometcfg "github.com/cometbft/cometbft/config" cometjson "github.com/cometbft/cometbft/libs/json" rpchttp "github.com/cometbft/cometbft/rpc/client/http" + "github.com/cosmos/cosmos-sdk/crypto/keys/secp256k1" "github.com/cosmos/cosmos-sdk/server" srvconfig "github.com/cosmos/cosmos-sdk/server/config" sdk "github.com/cosmos/cosmos-sdk/types" @@ -29,22 +30,29 @@ import ( ) var ( - numValidators = 3 + numValidators = 4 minGasPrice = sdk.NewDecCoinFromDec(app.BondDenom, sdk.MustNewDecFromStr("0.02")).String() - initBalanceStr = sdk.NewInt64Coin(app.BondDenom, 510000000000).String() + initBalanceStr = sdk.NewInt64Coin(app.BondDenom, 1000000000000000000).String() stakeAmount, _ = sdk.NewIntFromString("100000000000") stakeAmountCoin = sdk.NewCoin(app.BondDenom, stakeAmount) ) -type IntegrationTestSuite struct { - suite.Suite +type ( + TestAccount struct { + PrivateKey *secp256k1.PrivKey + Address sdk.AccAddress + } - tmpDirs []string - chain *chain - dkrPool *dockertest.Pool - dkrNet *dockertest.Network - valResources []*dockertest.Resource -} + IntegrationTestSuite struct { + suite.Suite + + tmpDirs []string + chain *chain + dkrPool *dockertest.Pool + dkrNet *dockertest.Network + valResources []*dockertest.Resource + } +) func TestIntegrationTestSuite(t *testing.T) { suite.Run(t, new(IntegrationTestSuite)) @@ -109,11 +117,12 @@ func (s *IntegrationTestSuite) initNodes() { // Define the builder module parameters params := types.Params{ - MaxBundleSize: 5, - EscrowAccountAddress: "cosmos14j5j2lsx7629590jvpk3vj0xe9w8203jf4yknk", - ReserveFee: sdk.NewCoin(app.BondDenom, sdk.NewInt(1000000)), - MinBidIncrement: sdk.NewCoin(app.BondDenom, sdk.NewInt(1000000)), - ProposerFee: sdk.NewDecWithPrec(1, 2), + MaxBundleSize: 5, + EscrowAccountAddress: "cosmos14j5j2lsx7629590jvpk3vj0xe9w8203jf4yknk", + ReserveFee: sdk.NewCoin(app.BondDenom, sdk.NewInt(1000000)), + MinBidIncrement: sdk.NewCoin(app.BondDenom, sdk.NewInt(1000000)), + ProposerFee: sdk.NewDecWithPrec(1, 2), + FrontRunningProtection: true, } for _, val := range s.chain.validators { @@ -232,6 +241,8 @@ func (s *IntegrationTestSuite) initValidatorConfigs() { appConfig := srvconfig.DefaultConfig() appConfig.API.Enable = true appConfig.MinGasPrices = minGasPrice + appConfig.API.Address = "tcp://0.0.0.0:1317" + appConfig.GRPC.Address = "0.0.0.0:9090" srvconfig.WriteConfigFile(appCfgPath, appConfig) } diff --git a/tests/e2e/e2e_test.go b/tests/e2e/e2e_test.go index 1557fdd..e450988 100644 --- a/tests/e2e/e2e_test.go +++ b/tests/e2e/e2e_test.go @@ -2,6 +2,455 @@ package e2e -func (s *IntegrationTestSuite) TestTmp() { - s.Require().True(true) +import ( + sdk "github.com/cosmos/cosmos-sdk/types" + "github.com/skip-mev/pob/tests/app" +) + +func (s *IntegrationTestSuite) TestGetBuilderParams() { + params := s.queryBuilderParams() + s.Require().NotNil(params) +} + +// TestBundles tests the execution of various auction bids. There are a few invarients that are +// tested: +// 1. The order of transactions in a bundle is preserved when bids are valid. +// 2. All transactions execute as expected. +// 3. The balance of the escrow account should be updated correctly. +// 4. Top of block bids will be included in block proposals before other transactions that are +// included in the same block. +func (s *IntegrationTestSuite) TestBundles() { + // Create the accounts that will create transactions to be included in bundles + initBalance := sdk.NewInt64Coin(app.BondDenom, 10000000000) + numAccounts := 3 + accounts := s.createTestAccounts(numAccounts, initBalance) + + // basic send amount + defaultSendAmount := sdk.NewCoins(sdk.NewCoin(app.BondDenom, sdk.NewInt(10))) + + // auction parameters + params := s.queryBuilderParams() + reserveFee := params.ReserveFee + minBidIncrement := params.MinBidIncrement + maxBundleSize := params.MaxBundleSize + escrowAddress := params.EscrowAccountAddress + + testCases := []struct { + name string + test func() + }{ + { + name: "Valid auction bid", + test: func() { + // Create a bundle with a single transaction + bundle := []string{ + s.createMsgSendTx(accounts[0], accounts[1].Address.String(), defaultSendAmount, 0, 1000), + } + + // Create a bid transaction that includes the bundle and is valid + bid := reserveFee + height := s.queryCurrentHeight() + bidTxHash := s.execAuctionBidTx(0, bid, height+1, bundle) + s.displayExpectedBundle("Valid auction bid", bidTxHash, bundle) + + // Wait for a block to be created + s.waitForABlock() + + // Ensure that the block was correctly created and executed in the order expected + bundleHashes := s.bundleToTxHashes(bundle) + expectedExecution := map[string]bool{ + bidTxHash: true, + bundleHashes[0]: true, + } + s.verifyBlock(height+1, bidTxHash, bundleHashes, expectedExecution) + + // Ensure that the escrow account has the correct balance + expectedEscrowFee := s.calculateProposerEscrowSplit(bid) + s.Require().Equal(expectedEscrowFee, s.queryBalanceOf(escrowAddress, app.BondDenom)) + }, + }, + { + name: "invalid auction bid with a bid smaller than the reserve fee", + test: func() { + // Get escrow account balance to ensure that it is not changed + escrowBalance := s.queryBalanceOf(escrowAddress, app.BondDenom) + + // Create a bundle with a single transaction (this should not be included in the block proposal) + bundle := []string{ + s.createMsgSendTx(accounts[0], accounts[1].Address.String(), defaultSendAmount, 0, 1000), + } + + // Create a bid transaction that includes a bid that is smaller than the reserve fee + bid := reserveFee.Sub(sdk.NewInt64Coin(app.BondDenom, 1)) + height := s.queryCurrentHeight() + bidTxHash := s.execAuctionBidTx(0, bid, height+1, bundle) + s.displayExpectedBundle("invalid bid", bidTxHash, bundle) + + // Wait for a block to be created + s.waitForABlock() + + // Ensure that no transactions were executed + bundleHashes := s.bundleToTxHashes(bundle) + expectedExecution := map[string]bool{ + bidTxHash: false, + bundleHashes[0]: false, + } + s.verifyBlock(height+1, bidTxHash, bundleHashes, expectedExecution) + + // Ensure that the escrow account has the correct balance + s.Require().Equal(escrowBalance, s.queryBalanceOf(escrowAddress, app.BondDenom)) + }, + }, + { + name: "invalid auction bid with too many transactions in the bundle", + test: func() { + // Get escrow account balance to ensure that it is not changed + escrowBalance := s.queryBalanceOf(escrowAddress, app.BondDenom) + + // Create a bundle with too many transactions + bundle := []string{} + for i := 0; i < int(maxBundleSize)+1; i++ { + bundle = append(bundle, s.createMsgSendTx(accounts[0], accounts[1].Address.String(), defaultSendAmount, i, 1000)) + } + + // Create a bid transaction that includes the bundle + bid := reserveFee + height := s.queryCurrentHeight() + bidTxHash := s.execAuctionBidTx(0, bid, height+1, bundle) + s.displayExpectedBundle("invalid bid", bidTxHash, bundle) + + // Wait for a block to be created + s.waitForABlock() + + // Ensure that no transactions were executed + expectedExecution := map[string]bool{ + bidTxHash: false, + } + + bundleHashes := s.bundleToTxHashes(bundle) + for _, hash := range bundleHashes { + expectedExecution[hash] = false + } + s.verifyBlock(height+1, bidTxHash, bundleHashes, expectedExecution) + + // Ensure that the escrow account has the correct balance + s.Require().Equal(escrowBalance, s.queryBalanceOf(escrowAddress, app.BondDenom)) + }, + }, + { + name: "invalid auction bid that has an invalid timeout", + test: func() { + // Get escrow account balance to ensure that it is not changed + escrowBalance := s.queryBalanceOf(escrowAddress, app.BondDenom) + + // Create a bundle with a single transaction + bundle := []string{ + s.createMsgSendTx(accounts[0], accounts[1].Address.String(), defaultSendAmount, 0, 1000), + } + + // Create a bid transaction that includes the bundle and has a bad timeout + bid := reserveFee + height := s.queryCurrentHeight() + bidTxHash := s.execAuctionBidTx(0, bid, height, bundle) + s.displayExpectedBundle("invalid bid", bidTxHash, bundle) + + // Wait for a block to be created + s.waitForABlock() + + // Ensure that no transactions were executed + bundleHashes := s.bundleToTxHashes(bundle) + expectedExecution := map[string]bool{ + bidTxHash: false, + bundleHashes[0]: false, + } + s.verifyBlock(height+1, bidTxHash, bundleHashes, expectedExecution) + + // Ensure that the escrow account has the correct balance + s.Require().Equal(escrowBalance, s.queryBalanceOf(escrowAddress, app.BondDenom)) + }, + }, + { + name: "Multiple transactions with second bid being smaller than min bid increment", + test: func() { + // Get escrow account balance + escrowBalance := s.queryBalanceOf(escrowAddress, app.BondDenom) + + // Create a bundle with a single transaction + bundle := []string{ + s.createMsgSendTx(accounts[0], accounts[1].Address.String(), defaultSendAmount, 0, 1000), + } + + // Create a bid transaction that includes the bundle and is valid + bid := reserveFee + height := s.queryCurrentHeight() + bidTxHash := s.execAuctionBidTx(0, bid, height+1, bundle) + s.displayExpectedBundle("bid 1", bidTxHash, bundle) + + // Create a second bid transaction that includes the bundle and is valid (but smaller than the min bid increment) + badBid := reserveFee.Add(sdk.NewInt64Coin(app.BondDenom, 10)) + bidTxHash2 := s.execAuctionBidTx(0, badBid, height+1, bundle) + s.displayExpectedBundle("bid 2", bidTxHash2, bundle) + + // Wait for a block to be created + s.waitForABlock() + + // Ensure only the first bid was executed + bundleHashes := s.bundleToTxHashes(bundle) + expectedExecution := map[string]bool{ + bidTxHash: true, + bundleHashes[0]: true, + bidTxHash2: false, + } + s.verifyBlock(height+1, bidTxHash, bundleHashes, expectedExecution) + + // Ensure that the escrow account has the correct balance + expectedEscrowFee := s.calculateProposerEscrowSplit(bid) + s.Require().Equal(expectedEscrowFee.Add(escrowBalance), s.queryBalanceOf(escrowAddress, app.BondDenom)) + }, + }, + { + name: "Multiple transactions with increasing bids but first bid has same bundle so it should fail", + test: func() { + // Get escrow account balance + escrowBalance := s.queryBalanceOf(escrowAddress, app.BondDenom) + + // Create a bundle with a single transaction + bundle := []string{ + s.createMsgSendTx(accounts[0], accounts[1].Address.String(), defaultSendAmount, 0, 1000), + } + + // Create a bid transaction that includes the bundle and is valid + bid := reserveFee + height := s.queryCurrentHeight() + bidTxHash := s.execAuctionBidTx(0, bid, height+2, bundle) + s.displayExpectedBundle("bid 1", bidTxHash, bundle) + + // Create a second bid transaction that includes the bundle and is valid + bid2 := reserveFee.Add(minBidIncrement) + bidTxHash2 := s.execAuctionBidTx(1, bid2, height+1, bundle) + s.displayExpectedBundle("bid 2", bidTxHash2, bundle) + + // Wait for a block to be created + s.waitForABlock() + + // Ensure only the second bid was executed + bundleHashes := s.bundleToTxHashes(bundle) + expectedExecution := map[string]bool{ + bidTxHash: false, + bundleHashes[0]: true, + bidTxHash2: true, + } + s.verifyBlock(height+1, bidTxHash2, bundleHashes, expectedExecution) + + // Wait for a block to be created and ensure that the first bid was not executed + s.waitForABlock() + s.verifyBlock(height+2, bidTxHash, bundleHashes, expectedExecution) + + // Ensure that the escrow account has the correct balance + expectedEscrowFee := s.calculateProposerEscrowSplit(bid2) + s.Require().Equal(expectedEscrowFee.Add(escrowBalance), s.queryBalanceOf(escrowAddress, app.BondDenom)) + }, + }, + { + name: "Multiple transactions with increasing bids and different bundles (both should execute)", + test: func() { + // Get escrow account balance + escrowBalance := s.queryBalanceOf(escrowAddress, app.BondDenom) + + // Create a bundle with a single transaction + firstBundle := []string{ + s.createMsgSendTx(accounts[0], accounts[1].Address.String(), defaultSendAmount, 0, 1000), + } + + // Create a bundle with a single transaction + secondBundle := []string{ + s.createMsgSendTx(accounts[1], accounts[0].Address.String(), defaultSendAmount, 0, 1000), + } + + // Create a bid transaction that includes the bundle and is valid + bid := reserveFee + height := s.queryCurrentHeight() + bidTxHash := s.execAuctionBidTx(0, bid, height+2, firstBundle) // height+2 to ensure it is executed after the second bid + s.displayExpectedBundle("bid 1", bidTxHash, firstBundle) + + // Create a second bid transaction that includes the bundle and is valid + bid2 := reserveFee.Add(minBidIncrement) + bidTxHash2 := s.execAuctionBidTx(1, bid2, height+1, secondBundle) + s.displayExpectedBundle("bid 2", bidTxHash2, secondBundle) + + // Wait for a block to be created + s.waitForABlock() + + // Ensure only the second bid was executed + firstBundleHashes := s.bundleToTxHashes(firstBundle) + secondBundleHashes := s.bundleToTxHashes(secondBundle) + expectedExecution := map[string]bool{ + bidTxHash2: true, + secondBundleHashes[0]: true, + } + s.verifyBlock(height+1, bidTxHash2, secondBundleHashes, expectedExecution) + + // Wait for a block to be created and ensure that the second bid is executed + s.waitForABlock() + expectedExecution[bidTxHash] = true + expectedExecution[firstBundleHashes[0]] = true + s.verifyBlock(height+2, bidTxHash, firstBundleHashes, expectedExecution) + + // Ensure that the escrow account has the correct balance + expectedEscrowFee := s.calculateProposerEscrowSplit(bid.Add(bid2)) + s.Require().Equal(expectedEscrowFee.Add(escrowBalance), s.queryBalanceOf(escrowAddress, app.BondDenom)) + }, + }, + { + name: "Invalid bid that includes an invalid bundle tx", + test: func() { + // Get escrow account balance to ensure that it is not changed + escrowBalance := s.queryBalanceOf(escrowAddress, app.BondDenom) + + // Create a bundle with a single transaction that is invalid (sequence number is wrong) + bundle := []string{ + s.createMsgSendTx(accounts[0], accounts[1].Address.String(), defaultSendAmount, 1000, 1000), + } + + // Create a bid transaction that includes the bundle and is valid + bid := reserveFee + height := s.queryCurrentHeight() + bidTxHash := s.execAuctionBidTx(0, bid, height+1, bundle) + s.displayExpectedBundle("bad bid", bidTxHash, bundle) + + // Wait for a block to be created + s.waitForABlock() + + bundleHashes := s.bundleToTxHashes(bundle) + expectedExecution := map[string]bool{ + bidTxHash: false, + bundleHashes[0]: false, + } + s.verifyBlock(height+1, bidTxHash, bundleHashes, expectedExecution) + + // Ensure that the escrow account has the correct balance + s.Require().Equal(escrowBalance, s.queryBalanceOf(escrowAddress, app.BondDenom)) + }, + }, + { + name: "Invalid bid that is attempting to front-run/sandwich", + test: func() { + // Get escrow account balance to ensure that it is not changed + escrowBalance := s.queryBalanceOf(escrowAddress, app.BondDenom) + + // Create a front-running bundle + bundle := []string{ + s.createMsgSendTx(accounts[0], accounts[1].Address.String(), defaultSendAmount, 0, 1000), + s.createMsgSendTx(accounts[1], accounts[0].Address.String(), defaultSendAmount, 0, 1000), + s.createMsgSendTx(accounts[2], accounts[1].Address.String(), defaultSendAmount, 0, 1000), + } + + // Create a bid transaction that includes the bundle and is valid + bid := reserveFee + height := s.queryCurrentHeight() + bidTxHash := s.execAuctionBidTx(0, bid, height+1, bundle) + s.displayExpectedBundle("bad bid", bidTxHash, bundle) + + // Wait for a block to be created + s.waitForABlock() + + bundleHashes := s.bundleToTxHashes(bundle) + expectedExecution := map[string]bool{ + bidTxHash: false, + bundleHashes[0]: false, + bundleHashes[1]: false, + bundleHashes[2]: false, + } + s.verifyBlock(height+1, bidTxHash, bundleHashes, expectedExecution) + + // Ensure that the escrow account has the correct balance + s.Require().Equal(escrowBalance, s.queryBalanceOf(escrowAddress, app.BondDenom)) + }, + }, + { + name: "Invalid bid that is attempting to bid more than their balance", + test: func() { + // Get escrow account balance to ensure that it is not changed + escrowBalance := s.queryBalanceOf(escrowAddress, app.BondDenom) + + // Create a bundle with a single transaction that is valid + bundle := []string{ + s.createMsgSendTx(accounts[0], accounts[1].Address.String(), defaultSendAmount, 0, 1000), + } + + // Create a bid transaction that includes the bundle and is valid + bid := sdk.NewCoin(app.BondDenom, sdk.NewInt(999999999999999999)) + height := s.queryCurrentHeight() + bidTxHash := s.execAuctionBidTx(0, bid, height+1, bundle) + s.displayExpectedBundle("bad bid", bidTxHash, bundle) + + // Wait for a block to be created + s.waitForABlock() + + bundleHashes := s.bundleToTxHashes(bundle) + expectedExecution := map[string]bool{ + bidTxHash: false, + bundleHashes[0]: false, + } + s.verifyBlock(height+1, bidTxHash, bundleHashes, expectedExecution) + + // Ensure that the escrow account has the correct balance + s.Require().Equal(escrowBalance, s.queryBalanceOf(escrowAddress, app.BondDenom)) + }, + }, + { + name: "Valid bid with multiple other transactions", + test: func() { + // Get escrow account balance to ensure that it is updated correctly + escrowBalance := s.queryBalanceOf(escrowAddress, app.BondDenom) + + // Create a bundle with a multiple transaction that is valid + bundle := []string{ + s.createMsgSendTx(accounts[0], accounts[1].Address.String(), defaultSendAmount, 0, 1000), + s.createMsgSendTx(accounts[0], accounts[1].Address.String(), defaultSendAmount, 1, 1000), + s.createMsgSendTx(accounts[0], accounts[1].Address.String(), defaultSendAmount, 2, 1000), + s.createMsgSendTx(accounts[0], accounts[1].Address.String(), defaultSendAmount, 3, 1000), + } + + // Wait for a block to ensure all transactions are included in the same block + s.waitForABlock() + + // Create a bid transaction that includes the bundle and is valid + bid := reserveFee + height := s.queryCurrentHeight() + bidTxHash := s.execAuctionBidTx(0, bid, height+1, bundle) + s.displayExpectedBundle("good bid", bidTxHash, bundle) + + // Execute a few other messages to be included in the block after the bid and bundle + txHash1 := s.execMsgSendTx(1, accounts[0].Address, sdk.NewCoin(app.BondDenom, sdk.NewInt(100))) + txHash2 := s.execMsgSendTx(2, accounts[0].Address, sdk.NewCoin(app.BondDenom, sdk.NewInt(100))) + txHash3 := s.execMsgSendTx(3, accounts[0].Address, sdk.NewCoin(app.BondDenom, sdk.NewInt(100))) + + // Wait for a block to be created + s.waitForABlock() + + bundleHashes := s.bundleToTxHashes(bundle) + expectedExecution := map[string]bool{ + bidTxHash: true, + bundleHashes[0]: true, + bundleHashes[1]: true, + bundleHashes[2]: true, + bundleHashes[3]: true, + txHash1: true, + txHash2: true, + txHash3: true, + } + s.verifyBlock(height+1, bidTxHash, bundleHashes, expectedExecution) + + // Ensure that the escrow account has the correct balance + expectedEscrowFee := s.calculateProposerEscrowSplit(bid) + s.Require().Equal(escrowBalance.Add(expectedEscrowFee), s.queryBalanceOf(escrowAddress, app.BondDenom)) + }, + }, + } + + for _, tc := range testCases { + s.waitForABlock() + s.Run(tc.name, tc.test) + } } diff --git a/tests/e2e/e2e_tx_test.go b/tests/e2e/e2e_tx_test.go new file mode 100644 index 0000000..6f0a30c --- /dev/null +++ b/tests/e2e/e2e_tx_test.go @@ -0,0 +1,198 @@ +package e2e + +import ( + "bytes" + "context" + "encoding/hex" + "fmt" + "strings" + "time" + + "github.com/cosmos/cosmos-sdk/client/flags" + clienttx "github.com/cosmos/cosmos-sdk/client/tx" + 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/ory/dockertest/v3/docker" + "github.com/skip-mev/pob/tests/app" +) + +// execAuctionBidTx executes an auction bid transaction on the given validator given the provided +// bid, timeout, and bundle. This function returns the transaction hash. It does not wait for the +// transaction to be committed. +func (s *IntegrationTestSuite) execAuctionBidTx(valIdx int, bid sdk.Coin, timeout int64, bundle []string) string { + address, err := s.chain.validators[valIdx].keyInfo.GetAddress() + s.Require().NoError(err) + + ctx, cancel := context.WithTimeout(context.Background(), time.Minute) + defer cancel() + + exec, err := s.dkrPool.Client.CreateExec(docker.CreateExecOptions{ + Context: ctx, + AttachStdout: true, + AttachStderr: true, + Container: s.valResources[valIdx].Container.ID, + User: "root", + Cmd: []string{ + "testappd", + "tx", + "builder", + "auction-bid", + address.String(), // bidder + bid.String(), // bid + strings.Join(bundle, ","), // bundle + fmt.Sprintf("--%s=%d", flags.FlagTimeoutHeight, timeout), // timeout + fmt.Sprintf("--%s=%s", flags.FlagFrom, s.chain.validators[valIdx].keyInfo.Name), + fmt.Sprintf("--%s=%s", flags.FlagChainID, s.chain.id), + fmt.Sprintf("--%s=%s", flags.FlagFees, sdk.NewCoin(app.BondDenom, sdk.NewInt(1000000000)).String()), + "--keyring-backend=test", + "--broadcast-mode=sync", + "-y", + }, + }) + s.Require().NoError(err) + + var ( + outBuf bytes.Buffer + errBuf bytes.Buffer + ) + + err = s.dkrPool.Client.StartExec(exec.ID, docker.StartExecOptions{ + Context: ctx, + Detach: false, + OutputStream: &outBuf, + ErrorStream: &errBuf, + }) + s.Require().NoErrorf(err, "stdout: %s, stderr: %s", outBuf.String(), errBuf.String()) + + output := outBuf.String() + resp := strings.Split(output, ":") + txHash := strings.TrimSpace(resp[len(resp)-1]) + + s.T().Logf( + "broadcasted bid tx %s with bid %s timeout %d and %d bundled txs", + txHash, bid, timeout, len(bundle), + ) + + return txHash +} + +// execMsgSendTx executes a send transaction on the given validator given the provided +// recipient and amount. This function returns the transaction hash. It does not wait for the +// transaction to be committed. +func (s *IntegrationTestSuite) execMsgSendTx(valIdx int, to sdk.AccAddress, amount sdk.Coin) string { + address, err := s.chain.validators[valIdx].keyInfo.GetAddress() + s.Require().NoError(err) + + s.T().Logf( + "sending %s from %s to %s", + amount, address, to, + ) + ctx, cancel := context.WithTimeout(context.Background(), time.Minute) + defer cancel() + + exec, err := s.dkrPool.Client.CreateExec(docker.CreateExecOptions{ + Context: ctx, + AttachStdout: true, + AttachStderr: true, + Container: s.valResources[valIdx].Container.ID, + User: "root", + Cmd: []string{ + "testappd", + "tx", + "bank", + "send", + address.String(), // sender + to.String(), // receiver + amount.String(), // amount + fmt.Sprintf("--%s=%s", flags.FlagFrom, s.chain.validators[valIdx].keyInfo.Name), + fmt.Sprintf("--%s=%s", flags.FlagChainID, s.chain.id), + fmt.Sprintf("--%s=%s", flags.FlagFees, sdk.NewCoin(app.BondDenom, sdk.NewInt(1000000000)).String()), + "--keyring-backend=test", + "--broadcast-mode=sync", + "-y", + }, + }) + s.Require().NoError(err) + + var ( + outBuf bytes.Buffer + errBuf bytes.Buffer + ) + + err = s.dkrPool.Client.StartExec(exec.ID, docker.StartExecOptions{ + Context: ctx, + Detach: false, + OutputStream: &outBuf, + ErrorStream: &errBuf, + }) + s.Require().NoErrorf(err, "stdout: %s, stderr: %s", outBuf.String(), errBuf.String()) + + output := outBuf.String() + resp := strings.Split(output, ":") + txHash := strings.TrimSpace(resp[len(resp)-1]) + + return txHash +} + +// createMsgSendTx creates a send transaction given the provided signer, recipient, amount, sequence number offset, and block height timeout. +// This function is primarily used to create bundles of transactions. +func (s *IntegrationTestSuite) createMsgSendTx(account TestAccount, toAddress string, amount sdk.Coins, sequenceOffset, height int) string { + txConfig := encodingConfig.TxConfig + txBuilder := txConfig.NewTxBuilder() + + msgs := []sdk.Msg{ + &banktypes.MsgSend{ + FromAddress: account.Address.String(), + ToAddress: toAddress, + Amount: amount, + }, + } + + // Get account info of the sender to set the account number and sequence number + baseAccount := s.queryAccount(account.Address) + sequenceNumber := baseAccount.Sequence + uint64(sequenceOffset) + + // Set the messages, fees, and timeout. + txBuilder.SetMsgs(msgs...) + txBuilder.SetGasLimit(5000000) + txBuilder.SetFeeAmount(sdk.NewCoins(sdk.NewCoin("stake", sdk.NewInt(75000)))) + txBuilder.SetTimeoutHeight(uint64(height)) + + sigV2 := signing.SignatureV2{ + PubKey: account.PrivateKey.PubKey(), + Data: &signing.SingleSignatureData{ + SignMode: txConfig.SignModeHandler().DefaultMode(), + Signature: nil, + }, + Sequence: sequenceNumber, + } + + s.Require().NoError(txBuilder.SetSignatures(sigV2)) + + signerData := authsigning.SignerData{ + ChainID: s.chain.id, + AccountNumber: baseAccount.AccountNumber, + Sequence: sequenceNumber, + } + + sigV2, err := clienttx.SignWithPrivKey( + txConfig.SignModeHandler().DefaultMode(), + signerData, + txBuilder, + account.PrivateKey, + txConfig, + sequenceNumber, + ) + s.Require().NoError(err) + s.Require().NoError(txBuilder.SetSignatures(sigV2)) + + bz, err := txConfig.TxEncoder()(txBuilder.GetTx()) + s.Require().NoError(err) + + // Hex encode the transaction + hash := hex.EncodeToString(bz) + + return hash +} diff --git a/tests/e2e/e2e_utils_test.go b/tests/e2e/e2e_utils_test.go new file mode 100644 index 0000000..b9c12bd --- /dev/null +++ b/tests/e2e/e2e_utils_test.go @@ -0,0 +1,290 @@ +package e2e + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "fmt" + "strings" + "time" + + "github.com/cosmos/cosmos-sdk/client" + "github.com/cosmos/cosmos-sdk/client/flags" + tmclient "github.com/cosmos/cosmos-sdk/client/grpc/tmservice" + "github.com/cosmos/cosmos-sdk/crypto/keys/secp256k1" + sdk "github.com/cosmos/cosmos-sdk/types" + txtypes "github.com/cosmos/cosmos-sdk/types/tx" + authtypes "github.com/cosmos/cosmos-sdk/x/auth/types" + banktypes "github.com/cosmos/cosmos-sdk/x/bank/types" + buildertypes "github.com/skip-mev/pob/x/builder/types" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" +) + +// createClientContext creates a client.Context for use in integration tests. +// Note, it assumes all queries and broadcasts go to the first node. +func (s *IntegrationTestSuite) createClientContext() client.Context { + node := s.valResources[0] + + rpcURI := node.GetHostPort("26657/tcp") + gRPCURI := node.GetHostPort("9090/tcp") + + rpcClient, err := client.NewClientFromNode(rpcURI) + s.Require().NoError(err) + + grpcClient, err := grpc.Dial(gRPCURI, []grpc.DialOption{grpc.WithTransportCredentials(insecure.NewCredentials())}...) + s.Require().NoError(err) + + return client.Context{}. + WithNodeURI(rpcURI). + WithClient(rpcClient). + WithGRPCClient(grpcClient). + WithInterfaceRegistry(encodingConfig.InterfaceRegistry). + WithCodec(encodingConfig.Codec). + WithChainID(s.chain.id). + WithBroadcastMode(flags.BroadcastSync) +} + +// createTestAccounts creates and funds test accounts with a balance. +func (s *IntegrationTestSuite) createTestAccounts(numAccounts int, balance sdk.Coin) []TestAccount { + accounts := make([]TestAccount, numAccounts) + + for i := 0; i < numAccounts; i++ { + // Generate a new account with private key that will be used to sign transactions. + privKey := secp256k1.GenPrivKey() + pubKey := privKey.PubKey() + addr := sdk.AccAddress(pubKey.Address()) + + account := TestAccount{ + PrivateKey: privKey, + Address: addr, + } + + // Fund the account. + s.execMsgSendTx(0, account.Address, balance) + + // Wait for the balance to be updated. + s.Require().Eventually(func() bool { + return !s.queryBalancesOf(addr.String()).IsZero() + }, + 10*time.Second, + 1*time.Second, + ) + + accounts[i] = account + } + + return accounts +} + +// calculateProposerEscrowSplit calculates the amount of a bid that should go to the escrow account +// and the amount that should go to the proposer. The simulation e2e environment does not support +// checking the proposer's balance, it only validates that the escrow address has the correct balance. +func (s *IntegrationTestSuite) calculateProposerEscrowSplit(bid sdk.Coin) sdk.Coin { + // Get the params to determine the proposer fee. + params := s.queryBuilderParams() + proposerFee := params.ProposerFee + + var proposerReward sdk.Coins + if proposerFee.IsZero() { + // send the entire bid to the escrow account when no proposer fee is set + return bid + } + + // determine the amount of the bid that goes to the (previous) proposer + bidDec := sdk.NewDecCoinsFromCoins(bid) + proposerReward, _ = bidDec.MulDecTruncate(proposerFee).TruncateDecimal() + + // Determine the amount of the remaining bid that goes to the escrow account. + // If a decimal remainder exists, it'll stay with the bidding account. + escrowTotal := bidDec.Sub(sdk.NewDecCoinsFromCoins(proposerReward...)) + escrowReward, _ := escrowTotal.TruncateDecimal() + + return sdk.NewCoin(bid.Denom, escrowReward.AmountOf(bid.Denom)) +} + +// waitForABlock will wait until the current block height has increased by a single block. +func (s *IntegrationTestSuite) waitForABlock() { + height := s.queryCurrentHeight() + s.Require().Eventually( + func() bool { + return s.queryCurrentHeight() >= height+1 + }, + 10*time.Second, + 50*time.Millisecond, + ) +} + +// bundleToTxHashes converts a bundle to a slice of transaction hashes. +func (s *IntegrationTestSuite) bundleToTxHashes(bundle []string) []string { + hashes := make([]string, len(bundle)) + + for i, tx := range bundle { + hashBz, err := hex.DecodeString(tx) + s.Require().NoError(err) + + shaBz := sha256.Sum256(hashBz) + hashes[i] = hex.EncodeToString(shaBz[:]) + } + + return hashes +} + +// verifyBlock verifies that the transactions in the block at the given height were seen +// and executed in the order they were submitted i.e. how they are broadcasted in the bundle. +func (s *IntegrationTestSuite) verifyBlock(height int64, bidTx string, bundle []string, expectedExecution map[string]bool) { + s.waitForABlock() + s.T().Logf("Verifying block %d", height) + + // Get the block's transactions and display the expected and actual block for debugging. + txs := s.queryBlockTxs(height) + s.displayBlock(txs, bidTx, bundle) + + // Ensure that all transactions executed as expected (i.e. landed or failed to land). + for tx, landed := range expectedExecution { + s.T().Logf("Verifying tx %s executed as %t", tx, landed) + s.Require().Equal(landed, s.queryTxPassed(tx) == nil) + } + s.T().Logf("All txs executed as expected") + + // Check that the block contains the expected transactions in the expected order + // iff the bid transaction was expected to execute. + if expectedExecution[bidTx] { + hashBz := sha256.Sum256(txs[0]) + hash := hex.EncodeToString(hashBz[:]) + s.Require().Equal(strings.ToUpper(bidTx), strings.ToUpper(hash)) + + for index, bundleTx := range bundle { + hashBz := sha256.Sum256(txs[index+1]) + txHash := hex.EncodeToString(hashBz[:]) + + s.Require().Equal(strings.ToUpper(bundleTx), strings.ToUpper(txHash)) + } + } +} + +// displayExpectedBlock displays the expected and actual blocks. +func (s *IntegrationTestSuite) displayBlock(txs [][]byte, bidTx string, bundle []string) { + expectedBlock := fmt.Sprintf("Expected block:\n\t(%d, %s)\n", 0, bidTx) + for index, bundleTx := range bundle { + expectedBlock += fmt.Sprintf("\t(%d, %s)\n", index+1, bundleTx) + } + + s.T().Logf(expectedBlock) + + // Display the actual block. + if len(txs) == 0 { + s.T().Logf("Actual block is empty") + return + } + + hashBz := sha256.Sum256(txs[0]) + hash := hex.EncodeToString(hashBz[:]) + actualBlock := fmt.Sprintf("Actual block:\n\t(%d, %s)\n", 0, hash) + for index, tx := range txs[1:] { + hashBz := sha256.Sum256(tx) + txHash := hex.EncodeToString(hashBz[:]) + + actualBlock += fmt.Sprintf("\t(%d, %s)\n", index+1, txHash) + } + + s.T().Logf(actualBlock) +} + +// displayExpectedBundle displays the expected order of the bid and bundled transactions. +func (s *IntegrationTestSuite) displayExpectedBundle(prefix, bidTx string, bundle []string) { + expectedBundle := fmt.Sprintf("%s expected bundle:\n\t(%d, %s)\n", prefix, 0, bidTx) + for index, bundleTx := range s.bundleToTxHashes(bundle) { + expectedBundle += fmt.Sprintf("\t(%d, %s)\n", index+1, bundleTx) + } + + s.T().Logf(expectedBundle) +} + +// queryTx queries a transaction by its hash and returns whether there was an +// error in including the transaction in a block. +func (s *IntegrationTestSuite) queryTxPassed(txHash string) error { + queryClient := txtypes.NewServiceClient(s.createClientContext()) + + req := &txtypes.GetTxRequest{Hash: txHash} + resp, err := queryClient.GetTx(context.Background(), req) + if err != nil { + return err + } + + if resp.TxResponse.Code != 0 { + return fmt.Errorf("tx failed: %s", resp.TxResponse.RawLog) + } + + return nil +} + +// queryBuilderParams returns the params of the builder module. +func (s *IntegrationTestSuite) queryBuilderParams() buildertypes.Params { + queryClient := buildertypes.NewQueryClient(s.createClientContext()) + + req := &buildertypes.QueryParamsRequest{} + resp, err := queryClient.Params(context.Background(), req) + s.Require().NoError(err) + + return resp.Params +} + +// queryBalancesOf returns the balances of an account. +func (s *IntegrationTestSuite) queryBalancesOf(address string) sdk.Coins { + queryClient := banktypes.NewQueryClient(s.createClientContext()) + + req := &banktypes.QueryAllBalancesRequest{Address: address} + resp, err := queryClient.AllBalances(context.Background(), req) + s.Require().NoError(err) + + return resp.Balances +} + +// queryBalanceOf returns the balance of an account for a specific denom. +func (s *IntegrationTestSuite) queryBalanceOf(address string, denom string) sdk.Coin { + queryClient := banktypes.NewQueryClient(s.createClientContext()) + + req := &banktypes.QueryBalanceRequest{Address: address, Denom: denom} + resp, err := queryClient.Balance(context.Background(), req) + s.Require().NoError(err) + + return *resp.Balance +} + +// queryAccount returns the account of an address. +func (s *IntegrationTestSuite) queryAccount(address sdk.AccAddress) *authtypes.BaseAccount { + queryClient := authtypes.NewQueryClient(s.createClientContext()) + + req := &authtypes.QueryAccountRequest{Address: address.String()} + resp, err := queryClient.Account(context.Background(), req) + s.Require().NoError(err) + + account := &authtypes.BaseAccount{} + err = account.Unmarshal(resp.Account.Value) + s.Require().NoError(err) + + return account +} + +// queryCurrentHeight returns the current block height. +func (s *IntegrationTestSuite) queryCurrentHeight() int64 { + queryClient := tmclient.NewServiceClient(s.createClientContext()) + + req := &tmclient.GetLatestBlockRequest{} + resp, err := queryClient.GetLatestBlock(context.Background(), req) + s.Require().NoError(err) + + return resp.SdkBlock.Header.Height +} + +// queryBlockTxs returns the txs of the block at the given height. +func (s *IntegrationTestSuite) queryBlockTxs(height int64) [][]byte { + queryClient := tmclient.NewServiceClient(s.createClientContext()) + + req := &tmclient.GetBlockByHeightRequest{Height: height} + resp, err := queryClient.GetBlockByHeight(context.Background(), req) + s.Require().NoError(err) + + return resp.GetSdkBlock().Data.Txs +}