diff --git a/changelog.md b/changelog.md index 0c3541c10e..cf5521e9d9 100644 --- a/changelog.md +++ b/changelog.md @@ -53,6 +53,8 @@ * [3831](https://github.com/zeta-chain/node/pull/3831) - e2e tests for sui fungible token withdraw and call * [3582](https://github.com/zeta-chain/node/pull/3852) - add solana to tss migration e2e tests * [3866](https://github.com/zeta-chain/node/pull/3866) - add e2e test for upgrading sui gateway package +* [3417](https://github.com/zeta-chain/node/pull/3417) - add e2e test for the Bitcoin RBF (Replace-By-Fee) feature + ### Refactor diff --git a/cmd/zetae2e/local/bitcoin.go b/cmd/zetae2e/local/bitcoin.go index aaea7b9c13..729d5ad999 100644 --- a/cmd/zetae2e/local/bitcoin.go +++ b/cmd/zetae2e/local/bitcoin.go @@ -14,6 +14,11 @@ import ( "github.com/zeta-chain/node/testutil" ) +const ( + testGroupDepositName = "btc_deposit" + testGroupWithdrawName = "btc_withdraw" +) + // startBitcoinTests starts Bitcoin related tests func startBitcoinTests( eg *errgroup.Group, @@ -58,6 +63,9 @@ func startBitcoinTests( e2etests.TestBitcoinWithdrawP2WSHName, e2etests.TestBitcoinWithdrawMultipleName, e2etests.TestBitcoinWithdrawRestrictedName, + // to run RBF test, change the constant 'minTxConfirmations' to 1 in the Bitcoin observer + // https://github.com/zeta-chain/node/blob/5c2a8ffbc702130fd9538b1cd7640d0e04d3e4f6/zetaclient/chains/bitcoin/observer/outbound.go#L27 + //e2etests.TestBitcoinWithdrawRBFName, } if !light { @@ -89,7 +97,7 @@ func bitcoinTestRoutines( // initialize runner for deposit tests account := conf.AdditionalAccounts.UserBitcoinDeposit runnerDeposit := initBitcoinRunner( - "btc_deposit", + testGroupDepositName, account, conf, deployerRunner, @@ -101,7 +109,7 @@ func bitcoinTestRoutines( // initialize runner for withdraw tests account = conf.AdditionalAccounts.UserBitcoinWithdraw runnerWithdraw := initBitcoinRunner( - "btc_withdraw", + testGroupWithdrawName, account, conf, deployerRunner, @@ -123,8 +131,8 @@ func bitcoinTestRoutines( } // create test routines - routineDeposit := createBitcoinTestRoutine(runnerDeposit, depositTests) - routineWithdraw := createBitcoinTestRoutine(runnerWithdraw, withdrawTests) + routineDeposit := createBitcoinTestRoutine(runnerDeposit, depositTests, testGroupDepositName) + routineWithdraw := createBitcoinTestRoutine(runnerWithdraw, withdrawTests, testGroupWithdrawName) return routineDeposit, routineWithdraw } @@ -177,7 +185,8 @@ func initBitcoinRunner( } // createBitcoinTestRoutine creates a test routine for given test names -func createBitcoinTestRoutine(r *runner.E2ERunner, testNames []string) func() error { +// The 'wgDependency' argument is used to wait for dependent routine to complete +func createBitcoinTestRoutine(r *runner.E2ERunner, testNames []string, name string) func() error { return func() (err error) { r.Logger.Print("🏃 starting bitcoin tests") startTime := time.Now() @@ -197,6 +206,11 @@ func createBitcoinTestRoutine(r *runner.E2ERunner, testNames []string) func() er r.Logger.Print("🍾 bitcoin tests completed in %s", time.Since(startTime).String()) + // mark deposit test group as done + if name == testGroupDepositName { + e2etests.DepdencyAllBitcoinDeposits.Done() + } + return err } } diff --git a/e2e/e2etests/e2etests.go b/e2e/e2etests/e2etests.go index c62e3fd60c..5a872c3a48 100644 --- a/e2e/e2etests/e2etests.go +++ b/e2e/e2etests/e2etests.go @@ -142,6 +142,7 @@ const ( TestBitcoinWithdrawInvalidAddressName = "bitcoin_withdraw_invalid" TestBitcoinWithdrawRestrictedName = "bitcoin_withdraw_restricted" TestBitcoinDepositInvalidMemoRevertName = "bitcoin_deposit_invalid_memo_revert" + TestBitcoinWithdrawRBFName = "bitcoin_withdraw_rbf" /* Application tests @@ -264,6 +265,12 @@ const ( CountArgDescription = "count" ) +// Here are all the dependencies for the e2e tests, add more dependencies here if needed +var ( + // DepdencyAllBitcoinDeposits is a dependency to wait for all bitcoin deposit tests to complete + DepdencyAllBitcoinDeposits = runner.NewE2EDependency("all_bitcoin_deposits") +) + // AllE2ETests is an ordered list of all e2e tests var AllE2ETests = []runner.E2ETest{ /* @@ -1165,6 +1172,16 @@ var AllE2ETests = []runner.E2ETest{ TestBitcoinDepositInvalidMemoRevert, runner.WithMinimumVersion("v29.0.0"), ), + runner.NewE2ETest( + TestBitcoinWithdrawRBFName, + "withdraw Bitcoin from ZEVM and replace the outbound using RBF", + []runner.ArgDefinition{ + {Description: "receiver address", DefaultValue: ""}, + {Description: "amount in btc", DefaultValue: "0.001"}, + }, + TestBitcoinWithdrawRBF, + runner.WithDependencies(DepdencyAllBitcoinDeposits), + ), /* Application tests */ diff --git a/e2e/e2etests/helpers.go b/e2e/e2etests/helpers.go index 5eb7ba359e..ed4de62bc4 100644 --- a/e2e/e2etests/helpers.go +++ b/e2e/e2etests/helpers.go @@ -10,7 +10,6 @@ import ( "github.com/btcsuite/btcd/btcjson" "github.com/btcsuite/btcd/btcutil" "github.com/btcsuite/btcd/chaincfg/chainhash" - "github.com/ethereum/go-ethereum/accounts/abi/bind" "github.com/stretchr/testify/require" "github.com/zeta-chain/node/e2e/runner" @@ -28,39 +27,13 @@ func randomPayload(r *runner.E2ERunner) string { } func withdrawBTCZRC20(r *runner.E2ERunner, to btcutil.Address, amount *big.Int) *btcjson.TxRawResult { - _, gasFee, err := r.BTCZRC20.WithdrawGasFee(&bind.CallOpts{}) - require.NoError(r, err) - minimumAmount := new(big.Int).Add(amount, gasFee) - currentBalance, err := r.BTCZRC20.BalanceOf(&bind.CallOpts{}, r.ZEVMAuth.From) - require.NoError(r, err) - require.Greater( - r, - currentBalance.Int64(), - minimumAmount.Int64(), - "current balance must be greater than amount + gasFee", - ) - - tx, err := r.BTCZRC20.Approve( - r.ZEVMAuth, - r.BTCZRC20Addr, - big.NewInt(amount.Int64()*2), - ) // approve more to cover withdraw fee - require.NoError(r, err) - - receipt := utils.MustWaitForTxReceipt(r.Ctx, r.ZEVMClient, tx, r.Logger, r.ReceiptTimeout) - utils.RequireTxSuccessful(r, receipt) + // approve and withdraw on ZRC20 contract + receipt := r.WithdrawBTC(to, amount, true) // mine blocks if testing on regnet stop := r.MineBlocksIfLocalBitcoin() defer stop() - // withdraw 'amount' of BTC from ZRC20 to BTC address - tx, err = r.BTCZRC20.Withdraw(r.ZEVMAuth, []byte(to.EncodeAddress()), amount) - require.NoError(r, err) - - receipt = utils.MustWaitForTxReceipt(r.Ctx, r.ZEVMClient, tx, r.Logger, r.ReceiptTimeout) - utils.RequireTxSuccessful(r, receipt) - // get cctx and check status cctx := utils.WaitCctxMinedByInboundHash(r.Ctx, receipt.TxHash.Hex(), r.CctxClient, r.Logger, r.CctxTimeout) utils.RequireCCTXStatus(r, cctx, crosschaintypes.CctxStatus_OutboundMined) diff --git a/e2e/e2etests/test_bitcoin_deposit_and_withdraw_with_dust.go b/e2e/e2etests/test_bitcoin_deposit_and_withdraw_with_dust.go index 8abdc09400..c88c029fda 100644 --- a/e2e/e2etests/test_bitcoin_deposit_and_withdraw_with_dust.go +++ b/e2e/e2etests/test_bitcoin_deposit_and_withdraw_with_dust.go @@ -22,7 +22,6 @@ func TestBitcoinDepositAndWithdrawWithDust(r *runner.E2ERunner, args []string) { require.Len(r, args, 0) // ARRANGE - // Deploy the withdrawer contract on ZetaChain with a withdraw amount of 100 satoshis (dust amount is 1000 satoshis) withdrawerAddr, tx, _, err := withdrawer.DeployWithdrawer(r.ZEVMAuth, r.ZEVMClient, big.NewInt(100)) require.NoError(r, err) diff --git a/e2e/e2etests/test_bitcoin_withdraw_rbf.go b/e2e/e2etests/test_bitcoin_withdraw_rbf.go new file mode 100644 index 0000000000..906cf87b79 --- /dev/null +++ b/e2e/e2etests/test_bitcoin_withdraw_rbf.go @@ -0,0 +1,76 @@ +package e2etests + +import ( + "strconv" + "time" + + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/stretchr/testify/require" + + "github.com/zeta-chain/node/e2e/runner" + "github.com/zeta-chain/node/e2e/utils" + crosschaintypes "github.com/zeta-chain/node/x/crosschain/types" +) + +// TestBitcoinWithdrawRBF tests the RBF (Replace-By-Fee) feature in Zetaclient. +// It needs block mining to be stopped and runs as the last test in the suite. +// +// IMPORTANT: the test requires to simulate a stuck tx in the Bitcoin regnet. +// The challenge to simulate a stuck tx is to create overwhelming traffic in the local mempool. +// +// To work around this: +// 1. change the 'minTxConfirmations' to 1 to not include outbound tx right away (production should use 0) +// here: https://github.com/zeta-chain/node/blob/5c2a8ffbc702130fd9538b1cd7640d0e04d3e4f6/zetaclient/chains/bitcoin/observer/outbound.go#L27 +// 2. stop block mining to let the pending tx sit in the mempool for longer time +func TestBitcoinWithdrawRBF(r *runner.E2ERunner, args []string) { + require.Len(r, args, 2) + + // parse arguments + defaultReceiver := r.GetBtcAddress().EncodeAddress() + to, amount := utils.ParseBitcoinWithdrawArgs(r, args, defaultReceiver, r.GetBitcoinChainID()) + + // initiate a withdraw CCTX + receipt := r.WithdrawBTC(to, amount, true) + cctx := utils.GetCCTXByInboundHash(r.Ctx, r.CctxClient, receipt.TxHash.Hex()) + + // wait for the 1st outbound tracker hash to come in + nonce := cctx.GetCurrentOutboundParam().TssNonce + hashes := utils.WaitOutboundTracker(r.Ctx, r.CctxClient, r.GetBitcoinChainID(), nonce, 1, r.Logger, 3*time.Minute) + txHash, err := chainhash.NewHashFromStr(hashes[0]) + r.Logger.Info("got 1st tracker hash: %s", txHash) + + // get original tx + require.NoError(r, err) + txResult, err := r.BtcRPCClient.GetTransaction(r.Ctx, txHash) + require.NoError(r, err) + require.Zero(r, txResult.Confirmations) + + // wait for RBF tx to kick in + hashes = utils.WaitOutboundTracker(r.Ctx, r.CctxClient, r.GetBitcoinChainID(), nonce, 2, r.Logger, 3*time.Minute) + txHashRBF, err := chainhash.NewHashFromStr(hashes[1]) + require.NoError(r, err) + r.Logger.Info("got 2nd tracker hash: %s", txHashRBF) + + // resume block mining + stop := r.MineBlocksIfLocalBitcoin() + defer stop() + + // waiting for CCTX to be mined + cctx = utils.WaitCctxMinedByInboundHash(r.Ctx, receipt.TxHash.Hex(), r.CctxClient, r.Logger, r.CctxTimeout) + utils.RequireCCTXStatus(r, cctx, crosschaintypes.CctxStatus_OutboundMined) + + // ensure the original tx is dropped + utils.MustHaveDroppedBitcoinTx(r.Ctx, r.BtcRPCClient, txHash) + + // ensure the RBF tx is mined + rawResult := utils.MustHaveMinedBitcoinTx(r.Ctx, r.BtcRPCClient, txHashRBF) + + // ensure RBF fee rate > old rate + params := cctx.GetCurrentOutboundParam() + oldRate, err := strconv.ParseInt(params.GasPrice, 10, 64) + require.NoError(r, err) + + _, newRate, err := r.BtcRPCClient.GetTransactionFeeAndRate(r.Ctx, rawResult) + require.NoError(r, err) + require.Greater(r, newRate, oldRate, "RBF fee rate should be higher than the original tx") +} diff --git a/e2e/runner/bitcoin.go b/e2e/runner/bitcoin.go index 6b70e1b093..6eb70739bd 100644 --- a/e2e/runner/bitcoin.go +++ b/e2e/runner/bitcoin.go @@ -4,6 +4,7 @@ import ( "bytes" "encoding/hex" "fmt" + "math/big" "sort" "time" @@ -15,6 +16,7 @@ import ( "github.com/btcsuite/btcd/wire" "github.com/ethereum/go-ethereum/accounts/abi/bind" "github.com/ethereum/go-ethereum/common" + ethtypes "github.com/ethereum/go-ethereum/core/types" "github.com/rs/zerolog/log" "github.com/stretchr/testify/require" @@ -182,6 +184,44 @@ func (r *E2ERunner) DepositBTC(receiver common.Address) { require.Equal(r, 1, balance.Sign(), "balance should be positive") } +// WithdrawBTC is a helper function to call 'withdraw' on BTCZRC20 contract with optional 'approve' +func (r *E2ERunner) WithdrawBTC(to btcutil.Address, amount *big.Int, approve bool) *ethtypes.Receipt { + // ensure enough balance to cover the withdrawal + _, gasFee, err := r.BTCZRC20.WithdrawGasFee(&bind.CallOpts{}) + require.NoError(r, err) + minimumAmount := new(big.Int).Add(amount, gasFee) + currentBalance, err := r.BTCZRC20.BalanceOf(&bind.CallOpts{}, r.ZEVMAuth.From) + require.NoError(r, err) + require.Greater( + r, + currentBalance.Int64(), + minimumAmount.Int64(), + "current balance must be greater than amount + gasFee", + ) + + // approve more to cover withdraw fee + if approve { + tx, err := r.BTCZRC20.Approve( + r.ZEVMAuth, + r.BTCZRC20Addr, + big.NewInt(amount.Int64()*2), + ) + require.NoError(r, err) + + receipt := utils.MustWaitForTxReceipt(r.Ctx, r.ZEVMClient, tx, r.Logger, r.ReceiptTimeout) + utils.RequireTxSuccessful(r, receipt) + } + + // withdraw 'amount' of BTC from ZRC20 to BTC address + tx, err := r.BTCZRC20.Withdraw(r.ZEVMAuth, []byte(to.EncodeAddress()), amount) + require.NoError(r, err) + + receipt := utils.MustWaitForTxReceipt(r.Ctx, r.ZEVMClient, tx, r.Logger, r.ReceiptTimeout) + utils.RequireTxSuccessful(r, receipt) + + return receipt +} + func (r *E2ERunner) SendToTSSWithMemo( amount float64, memo []byte, diff --git a/e2e/runner/e2etest.go b/e2e/runner/e2etest.go index 8de3217800..5e02f1d62f 100644 --- a/e2e/runner/e2etest.go +++ b/e2e/runner/e2etest.go @@ -1,6 +1,9 @@ package runner -import "fmt" +import ( + "fmt" + "sync" +) // E2ETestFunc is a function representing a E2E test // It takes a E2ERunner as an argument @@ -16,12 +19,20 @@ func WithMinimumVersion(version string) E2ETestOpt { } } +// WithDependencies sets dependencies to the E2ETest to wait for completion +func WithDependencies(dependencies ...E2EDependency) E2ETestOpt { + return func(t *E2ETest) { + t.Dependencies = dependencies + } +} + // E2ETest represents a E2E test with a name, args, description and test func type E2ETest struct { Name string Description string Args []string ArgsDefinition []ArgDefinition + Dependencies []E2EDependency E2ETest E2ETestFunc MinimumVersion string } @@ -46,6 +57,33 @@ func NewE2ETest( return test } +// E2EDependency defines a structure that holds a E2E test dependency +type E2EDependency struct { + name string + waitGroup *sync.WaitGroup +} + +// NewE2EDependency creates a new instance of E2Edependency with specified parameters. +func NewE2EDependency(name string) E2EDependency { + var wg sync.WaitGroup + wg.Add(1) + + return E2EDependency{ + name: name, + waitGroup: &wg, + } +} + +// Wait waits for the E2EDependency to complete +func (d *E2EDependency) Wait() { + d.waitGroup.Wait() +} + +// Done marks the E2EDependency as done +func (d *E2EDependency) Done() { + d.waitGroup.Done() +} + // ArgDefinition defines a structure for holding an argument's description along with it's default value. type ArgDefinition struct { Description string @@ -110,6 +148,7 @@ func (r *E2ERunner) GetE2ETestsToRunByConfig( Name: e2eTest.Name, Description: e2eTest.Description, ArgsDefinition: e2eTest.ArgsDefinition, + Dependencies: e2eTest.Dependencies, E2ETest: e2eTest.E2ETest, MinimumVersion: e2eTest.MinimumVersion, } diff --git a/e2e/runner/run.go b/e2e/runner/run.go index 66f6cc6474..2d4481904f 100644 --- a/e2e/runner/run.go +++ b/e2e/runner/run.go @@ -28,6 +28,15 @@ func (r *E2ERunner) RunE2ETests(e2eTests []E2ETest) (err error) { // RunE2ETest runs a e2e test func (r *E2ERunner) RunE2ETest(e2eTest E2ETest, checkAccounting bool) error { + // wait for all dependencies to complete + // this is only used by Bitcoin RBF test at the moment + if len(e2eTest.Dependencies) > 0 { + r.Logger.Print("⏳ waiting - %s", e2eTest.Name) + for _, dependency := range e2eTest.Dependencies { + dependency.Wait() + } + } + startTime := time.Now() // note: spacing is padded to width of completed message r.Logger.Print("⏳ running - %s", e2eTest.Name) diff --git a/e2e/utils/bitcoin.go b/e2e/utils/bitcoin.go new file mode 100644 index 0000000000..ea63676299 --- /dev/null +++ b/e2e/utils/bitcoin.go @@ -0,0 +1,49 @@ +package utils + +import ( + "context" + + "github.com/btcsuite/btcd/btcjson" + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/stretchr/testify/require" + + "github.com/zeta-chain/node/zetaclient/chains/bitcoin/client" +) + +// MustHaveDroppedBitcoinTx ensures the given Bitcoin tx has been dropped +func MustHaveDroppedBitcoinTx(ctx context.Context, client *client.Client, txHash *chainhash.Hash) { + t := TestingFromContext(ctx) + + // dropped tx has negative confirmations + txResult, err := client.GetTransaction(ctx, txHash) + if err == nil { + require.Negative(t, txResult.Confirmations) + } + + // dropped tx should be removed from mempool + entry, err := client.GetMempoolEntry(ctx, txHash.String()) + require.Error(t, err) + require.Nil(t, entry) + + // dropped tx won't exist in blockchain + // -5: No such mempool or blockchain transaction + rawTx, err := client.GetRawTransaction(ctx, txHash) + require.Error(t, err) + require.Nil(t, rawTx) +} + +// MustHaveMinedBitcoinTx ensures the given Bitcoin tx has been mined +func MustHaveMinedBitcoinTx(ctx context.Context, client *client.Client, txHash *chainhash.Hash) *btcjson.TxRawResult { + t := TestingFromContext(ctx) + + // positive confirmations + txResult, err := client.GetTransaction(ctx, txHash) + require.NoError(t, err) + require.Positive(t, txResult.Confirmations) + + // tx exists in blockchain + rawResult, err := client.GetRawTransactionVerbose(ctx, txHash) + require.NoError(t, err) + + return rawResult +} diff --git a/e2e/utils/zetacore.go b/e2e/utils/zetacore.go index a626f3e009..7a6a15ab88 100644 --- a/e2e/utils/zetacore.go +++ b/e2e/utils/zetacore.go @@ -2,6 +2,7 @@ package utils import ( "context" + "fmt" "time" rpchttp "github.com/cometbft/cometbft/rpc/client/http" @@ -30,6 +31,24 @@ const ( DefaultCctxTimeout = 8 * time.Minute ) +// GetCCTXByInboundHash gets cctx by inbound hash +func GetCCTXByInboundHash( + ctx context.Context, + client crosschaintypes.QueryClient, + inboundHash string, +) *crosschaintypes.CrossChainTx { + t := TestingFromContext(ctx) + + // query cctx by inbound hash + in := &crosschaintypes.QueryInboundHashToCctxDataRequest{InboundHash: inboundHash} + res, err := client.InTxHashToCctxData(ctx, in) + + require.NoError(t, err) + require.Len(t, res.CrossChainTxs, 1) + + return &res.CrossChainTxs[0] +} + // WaitCctxMinedByInboundHash waits until cctx is mined; returns the cctxIndex (the last one) func WaitCctxMinedByInboundHash( ctx context.Context, @@ -192,6 +211,58 @@ func WaitCCTXMinedByIndex( } } +// WaitOutboundTracker wait for outbound tracker to be filled with 'hashCount' hashes +func WaitOutboundTracker( + ctx context.Context, + client crosschaintypes.QueryClient, + chainID int64, + nonce uint64, + hashCount int, + logger infoLogger, + timeout time.Duration, +) []string { + if timeout == 0 { + timeout = DefaultCctxTimeout + } + + t := TestingFromContext(ctx) + startTime := time.Now() + in := &crosschaintypes.QueryAllOutboundTrackerByChainRequest{Chain: chainID} + + for { + require.False( + t, + time.Since(startTime) > timeout, + fmt.Sprintf("waiting outbound tracker timeout, chainID: %d, nonce: %d", chainID, nonce), + ) + + // wait for a Zeta block before querying outbound tracker + time.Sleep(constant.ZetaBlockTime) + + outboundTracker, err := client.OutboundTrackerAllByChain(ctx, in) + require.NoError(t, err) + + // loop through all outbound trackers + for i, tracker := range outboundTracker.OutboundTracker { + if tracker.Nonce == nonce { + logger.Info("Tracker[%d]:\n", i) + logger.Info(" ChainId: %d\n", tracker.ChainId) + logger.Info(" Nonce: %d\n", tracker.Nonce) + logger.Info(" HashList:\n") + + hashes := []string{} + for j, hash := range tracker.HashList { + hashes = append(hashes, hash.TxHash) + logger.Info(" hash[%d]: %s\n", j, hash.TxHash) + } + if len(hashes) >= hashCount { + return hashes + } + } + } + } +} + type WaitOpts func(c *waitConfig) // MatchStatus is the WaitOpts that matches CCTX with the given status.