From 799a22b31c0b77d917108cd95ea1162348a8e051 Mon Sep 17 00:00:00 2001 From: Charlie Chen Date: Sat, 7 Dec 2024 00:02:00 -0600 Subject: [PATCH 01/20] initiate Bitcoin RBF live tests --- go.mod | 2 +- x/crosschain/keeper/abci.go | 16 +- x/crosschain/keeper/abci_test.go | 46 ++ x/observer/types/crosschain_flags.go | 2 +- zetaclient/chains/bitcoin/fee.go | 2 +- zetaclient/chains/bitcoin/fee_test.go | 3 +- .../chains/bitcoin/observer/observer.go | 2 +- .../chains/bitcoin/rpc/rpc_live_test.go | 105 ++-- .../chains/bitcoin/rpc/rpc_rbf_live_test.go | 548 ++++++++++++++++++ zetaclient/chains/bitcoin/signer/signer.go | 9 + zetaclient/common/env.go | 3 + 11 files changed, 668 insertions(+), 70 deletions(-) create mode 100644 zetaclient/chains/bitcoin/rpc/rpc_rbf_live_test.go diff --git a/go.mod b/go.mod index 05a7166c93..eff31d76be 100644 --- a/go.mod +++ b/go.mod @@ -126,7 +126,7 @@ require ( github.com/davidlazar/go-crypto v0.0.0-20200604182044-b73af7476f6c // indirect github.com/deckarep/golang-set v1.8.0 // indirect github.com/decred/dcrd/dcrec/edwards/v2 v2.0.0 // indirect - github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0 // indirect + github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0 github.com/desertbit/timer v0.0.0-20180107155436-c41aec40b27f // indirect github.com/dgraph-io/badger/v4 v4.2.0 // indirect github.com/dgraph-io/ristretto v0.1.1 // indirect diff --git a/x/crosschain/keeper/abci.go b/x/crosschain/keeper/abci.go index 03d13fb6d5..d18648e6e4 100644 --- a/x/crosschain/keeper/abci.go +++ b/x/crosschain/keeper/abci.go @@ -52,8 +52,8 @@ func (k Keeper) IterateAndUpdateCctxGasPrice( IterateChains: for _, chain := range chains { - // support only external evm chains - if zetachains.IsEVMChain(chain.ChainId, additionalChains) && !zetachains.IsZetaChain(chain.ChainId, additionalChains) { + // support only external evm chains and bitcoin chain + if IsGasStabilityPoolEnabledChain(chain.ChainId, additionalChains) { res, err := k.ListPendingCctx(sdk.UnwrapSDKContext(ctx), &types.QueryListPendingCctxRequest{ ChainId: chain.ChainId, Limit: gasPriceIncreaseFlags.MaxPendingCctxs, @@ -175,3 +175,15 @@ func CheckAndUpdateCctxGasPrice( return gasPriceIncrease, additionalFees, nil } + +// IsGasStabilityPoolEnabledChain returns true if given chainID is enabled for gas stability pool +func IsGasStabilityPoolEnabledChain(chainID int64, additionalChains []zetachains.Chain) bool { + switch { + case zetachains.IsEVMChain(chainID, additionalChains): + return !zetachains.IsZetaChain(chainID, additionalChains) + case zetachains.IsBitcoinChain(chainID, additionalChains): + return true + default: + return false + } +} diff --git a/x/crosschain/keeper/abci_test.go b/x/crosschain/keeper/abci_test.go index 32499e1827..2e1cbd246b 100644 --- a/x/crosschain/keeper/abci_test.go +++ b/x/crosschain/keeper/abci_test.go @@ -10,6 +10,7 @@ import ( "github.com/stretchr/testify/require" "github.com/zeta-chain/node/pkg/chains" + zetachains "github.com/zeta-chain/node/pkg/chains" testkeeper "github.com/zeta-chain/node/testutil/keeper" "github.com/zeta-chain/node/testutil/sample" "github.com/zeta-chain/node/x/crosschain/keeper" @@ -449,3 +450,48 @@ func TestCheckAndUpdateCctxGasPrice(t *testing.T) { }) } } + +func TestIsGasStabilityPoolEnabledChain(t *testing.T) { + tests := []struct { + name string + chainID int64 + expected bool + }{ + { + name: "Ethereum is enabled", + chainID: chains.Ethereum.ChainId, + expected: true, + }, + { + name: "Binance Smart Chain is enabled", + chainID: chains.BscMainnet.ChainId, + expected: true, + }, + { + name: "Bitcoin is enabled", + chainID: chains.BitcoinMainnet.ChainId, + expected: true, + }, + { + name: "ZetaChain is not enabled", + chainID: chains.ZetaChainMainnet.ChainId, + expected: false, + }, + { + name: "Solana is not enabled", + chainID: chains.SolanaMainnet.ChainId, + expected: false, + }, + { + name: "TON is not enabled", + chainID: chains.TONMainnet.ChainId, + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + require.Equal(t, tt.expected, keeper.IsGasStabilityPoolEnabledChain(tt.chainID, []zetachains.Chain{})) + }) + } +} diff --git a/x/observer/types/crosschain_flags.go b/x/observer/types/crosschain_flags.go index 5637debe0e..0f2c3e4cb0 100644 --- a/x/observer/types/crosschain_flags.go +++ b/x/observer/types/crosschain_flags.go @@ -4,8 +4,8 @@ import "time" var DefaultGasPriceIncreaseFlags = GasPriceIncreaseFlags{ // EpochLength is the number of blocks in an epoch before triggering a gas price increase - EpochLength: 100, + // RetryInterval is the number of blocks to wait before incrementing the gas price again RetryInterval: time.Minute * 10, diff --git a/zetaclient/chains/bitcoin/fee.go b/zetaclient/chains/bitcoin/fee.go index 7ce483a5bf..1c0803e552 100644 --- a/zetaclient/chains/bitcoin/fee.go +++ b/zetaclient/chains/bitcoin/fee.go @@ -32,7 +32,6 @@ const ( bytesPerWitness = 108 // each additional witness incurs about 108 bytes and it may vary OutboundBytesMin = uint64(239) // 239vB == EstimateSegWitTxSize(2, 2, toP2WPKH) OutboundBytesMax = uint64(1543) // 1543v == EstimateSegWitTxSize(21, 2, toP2TR) - OutboundBytesAvg = uint64(245) // 245vB is a suggested gas limit for zetacore // defaultDepositorFeeRate is the default fee rate for depositor fee, 20 sat/vB defaultDepositorFeeRate = 20 @@ -49,6 +48,7 @@ var ( BtcOutboundBytesDepositor = OutboundSizeDepositor() // BtcOutboundBytesWithdrawer is the outbound size incurred by the withdrawer: 177vB + // This will be the suggested gas limit used for zetacore BtcOutboundBytesWithdrawer = OutboundSizeWithdrawer() // DefaultDepositorFee is the default depositor fee is 0.00001360 BTC (20 * 68vB / 100000000) diff --git a/zetaclient/chains/bitcoin/fee_test.go b/zetaclient/chains/bitcoin/fee_test.go index 82f60ff0ef..209e35e358 100644 --- a/zetaclient/chains/bitcoin/fee_test.go +++ b/zetaclient/chains/bitcoin/fee_test.go @@ -420,7 +420,7 @@ func TestOutboundSizeBreakdown(t *testing.T) { txSizeTotal += sizeOutput } - // calculate the average outbound size + // calculate the average outbound size (245 vByte) // #nosec G115 always in range txSizeAverage := uint64((float64(txSizeTotal))/float64(len(payees)) + 0.5) @@ -433,7 +433,6 @@ func TestOutboundSizeBreakdown(t *testing.T) { require.Equal(t, uint64(177), txSizeWithdrawer) // total outbound size == (deposit fee + withdrawer fee), 245 = 68 + 177 - require.Equal(t, OutboundBytesAvg, txSizeAverage) require.Equal(t, txSizeAverage, txSizeDepositor+txSizeWithdrawer) // check default depositor fee diff --git a/zetaclient/chains/bitcoin/observer/observer.go b/zetaclient/chains/bitcoin/observer/observer.go index 8a3516f3d0..1815313794 100644 --- a/zetaclient/chains/bitcoin/observer/observer.go +++ b/zetaclient/chains/bitcoin/observer/observer.go @@ -285,7 +285,7 @@ func (ob *Observer) PostGasPrice(ctx context.Context) error { return errors.Wrap(err, "GetBlockCount error") } - // UTXO has no concept of priority fee (like eth) + // Bitcoin has no concept of priority fee (like eth) const priorityFee = 0 // #nosec G115 always positive diff --git a/zetaclient/chains/bitcoin/rpc/rpc_live_test.go b/zetaclient/chains/bitcoin/rpc/rpc_live_test.go index f3fdf5f12d..cafed92343 100644 --- a/zetaclient/chains/bitcoin/rpc/rpc_live_test.go +++ b/zetaclient/chains/bitcoin/rpc/rpc_live_test.go @@ -33,9 +33,10 @@ func createRPCClient(chainID int64) (*rpcclient.Client, error) { var connCfg *rpcclient.ConnConfig rpcMainnet := os.Getenv(common.EnvBtcRPCMainnet) rpcTestnet := os.Getenv(common.EnvBtcRPCTestnet) + rpcTestnet4 := "localhost:48332" // os.Getenv(common.EnvBtcRPCTestnet4) - // mainnet - if chainID == chains.BitcoinMainnet.ChainId { + switch chainID { + case chains.BitcoinMainnet.ChainId: connCfg = &rpcclient.ConnConfig{ Host: rpcMainnet, // mainnet endpoint goes here User: "user", @@ -44,9 +45,7 @@ func createRPCClient(chainID int64) (*rpcclient.Client, error) { HTTPPostMode: true, DisableTLS: true, } - } - // testnet3 - if chainID == chains.BitcoinTestnet.ChainId { + case chains.BitcoinTestnet.ChainId: connCfg = &rpcclient.ConnConfig{ Host: rpcTestnet, // testnet endpoint goes here User: "user", @@ -55,7 +54,19 @@ func createRPCClient(chainID int64) (*rpcclient.Client, error) { HTTPPostMode: true, DisableTLS: true, } + case chains.BitcoinTestnet4.ChainId: + connCfg = &rpcclient.ConnConfig{ + Host: rpcTestnet4, // testnet endpoint goes here + User: "admin", + Pass: "admin", + Params: "testnet3", // testnet4 uses testnet3 network name + HTTPPostMode: true, + DisableTLS: true, + } + default: + return nil, errors.New("unsupported chain") } + return rpcclient.New(connCfg, nil) } @@ -101,19 +112,32 @@ func getMempoolSpaceTxsByBlock( return blkHash, mempoolTxs, nil } -// Test_BitcoinLive is a phony test to run each live test individually +// Test_BitcoinLive is a test to run all Bitcoin live tests func Test_BitcoinLive(t *testing.T) { - // LiveTest_FilterAndParseIncomingTx(t) - // LiveTest_FilterAndParseIncomingTx_Nop(t) - // LiveTest_NewRPCClient(t) - // LiveTest_GetBlockHeightByHash(t) - // LiveTest_BitcoinFeeRate(t) - // LiveTest_AvgFeeRateMainnetMempoolSpace(t) - // LiveTest_AvgFeeRateTestnetMempoolSpace(t) - // LiveTest_GetRecentFeeRate(t) - // LiveTest_GetSenderByVin(t) - // LiveTest_GetTransactionFeeAndRate(t) - // LiveTest_CalcDepositorFeeV2(t) + if !common.LiveTestEnabled() { + return + } + + LiveTest_PendingMempoolTx(t) + LiveTest_NewRPCClient(t) + LiveTest_CheckRPCStatus(t) + LiveTest_FilterAndParseIncomingTx(t) + LiveTest_GetBlockHeightByHash(t) + LiveTest_GetSenderByVin(t) +} + +// Test_BitcoinFeeLive is a test to run all Bitcoin fee related live tests +func Test_BitcoinFeeLive(t *testing.T) { + if !common.LiveTestEnabled() { + return + } + + LiveTest_BitcoinFeeRate(t) + LiveTest_AvgFeeRateMainnetMempoolSpace(t) + LiveTest_AvgFeeRateTestnetMempoolSpace(t) + LiveTest_GetRecentFeeRate(t) + LiveTest_GetTransactionFeeAndRate(t) + LiveTest_CalcDepositorFee(t) } func LiveTest_FilterAndParseIncomingTx(t *testing.T) { @@ -140,7 +164,7 @@ func LiveTest_FilterAndParseIncomingTx(t *testing.T) { ) require.NoError(t, err) require.Len(t, inbounds, 1) - require.Equal(t, inbounds[0].Value, 0.0001) + require.Equal(t, inbounds[0].Value+inbounds[0].DepositorFee, 0.0001) require.Equal(t, inbounds[0].ToAddress, "tb1qsa222mn2rhdq9cruxkz8p2teutvxuextx3ees2") // the text memo is base64 std encoded string:DSRR1RmDCwWmxqY201/TMtsJdmA= @@ -153,49 +177,6 @@ func LiveTest_FilterAndParseIncomingTx(t *testing.T) { require.Equal(t, inbounds[0].TxHash, "889bfa69eaff80a826286d42ec3f725fd97c3338357ddc3a1f543c2d6266f797") } -func LiveTest_FilterAndParseIncomingTx_Nop(t *testing.T) { - // setup Bitcoin client - client, err := createRPCClient(chains.BitcoinTestnet.ChainId) - require.NoError(t, err) - - // get a block that contains no incoming tx - hashStr := "000000000000002fd8136dbf91708898da9d6ae61d7c354065a052568e2f2888" - hash, err := chainhash.NewHashFromStr(hashStr) - require.NoError(t, err) - - block, err := client.GetBlockVerboseTx(hash) - require.NoError(t, err) - - // filter incoming tx - inbounds, err := observer.FilterAndParseIncomingTx( - client, - block.Tx, - uint64(block.Height), - "tb1qsa222mn2rhdq9cruxkz8p2teutvxuextx3ees2", - log.Logger, - &chaincfg.TestNet3Params, - ) - - require.NoError(t, err) - require.Empty(t, inbounds) -} - -// TestBitcoinObserverLive is a phony test to run each live test individually -func TestBitcoinObserverLive(t *testing.T) { - if !common.LiveTestEnabled() { - return - } - - LiveTest_NewRPCClient(t) - LiveTest_CheckRPCStatus(t) - LiveTest_GetBlockHeightByHash(t) - LiveTest_BitcoinFeeRate(t) - LiveTest_AvgFeeRateMainnetMempoolSpace(t) - LiveTest_AvgFeeRateTestnetMempoolSpace(t) - LiveTest_GetRecentFeeRate(t) - LiveTest_GetSenderByVin(t) -} - // LiveTestNewRPCClient creates a new Bitcoin RPC client func LiveTest_NewRPCClient(t *testing.T) { btcConfig := config.BTCConfig{ @@ -500,7 +481,7 @@ func LiveTest_GetTransactionFeeAndRate(t *testing.T) { // calculates block range to test startBlock, err := client.GetBlockCount() require.NoError(t, err) - endBlock := startBlock - 100 // go back whatever blocks as needed + endBlock := startBlock - 1 // go back whatever blocks as needed // loop through mempool.space blocks backwards for bn := startBlock; bn >= endBlock; { diff --git a/zetaclient/chains/bitcoin/rpc/rpc_rbf_live_test.go b/zetaclient/chains/bitcoin/rpc/rpc_rbf_live_test.go new file mode 100644 index 0000000000..e8e719408a --- /dev/null +++ b/zetaclient/chains/bitcoin/rpc/rpc_rbf_live_test.go @@ -0,0 +1,548 @@ +package rpc_test + +import ( + "encoding/hex" + "fmt" + "os" + "sort" + "testing" + "time" + + "github.com/btcsuite/btcd/btcec/v2/ecdsa" + "github.com/btcsuite/btcd/btcjson" + "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/mempool" + "github.com/btcsuite/btcd/rpcclient" + "github.com/btcsuite/btcd/txscript" + "github.com/btcsuite/btcd/wire" + "github.com/decred/dcrd/dcrec/secp256k1/v4" + "github.com/pkg/errors" + "github.com/stretchr/testify/require" + "github.com/zeta-chain/node/pkg/chains" + "github.com/zeta-chain/node/zetaclient/chains/bitcoin" + "github.com/zeta-chain/node/zetaclient/chains/bitcoin/rpc" + "github.com/zeta-chain/node/zetaclient/common" +) + +// setupTest initializes the privateKey, sender, receiver and RPC client +func setupTest(t *testing.T) (*rpcclient.Client, *secp256k1.PrivateKey, btcutil.Address, btcutil.Address) { + // network to use + chain := chains.BitcoinTestnet4 + net, err := chains.GetBTCChainParams(chain.ChainId) + require.NoError(t, err) + + // load test private key + privKeyHex := os.Getenv("TEST_PK_BTC") + privKeyBytes, err := hex.DecodeString(privKeyHex) + require.NoError(t, err) + + // construct a secp256k1 private key object + privKey := secp256k1.PrivKeyFromBytes(privKeyBytes) + pubKeyHash := btcutil.Hash160(privKey.PubKey().SerializeCompressed()) + sender, err := btcutil.NewAddressWitnessPubKeyHash(pubKeyHash, net) + require.NoError(t, err) + fmt.Printf("sender : %s\n", sender.EncodeAddress()) + + // receiver address + to, err := btcutil.DecodeAddress("tb1qxr8zcffrkmqwvtkzjz8nxs05p2vs6pt9rzq27a", net) + require.NoError(t, err) + fmt.Printf("receiver: %s\n", to.EncodeAddress()) + + // setup Bitcoin client + client, err := createRPCClient(chain.ChainId) + require.NoError(t, err) + + return client, privKey, sender, to +} + +// Test_BitcoinLive is a test to run all Bitcoin RBF related tests +func Test_BitcoinRBFLive(t *testing.T) { + if !common.LiveTestEnabled() { + return + } + + LiveTest_PendingMempoolTx(t) +} + +func LiveTest_RBFTransaction(t *testing.T) { + // setup test + client, privKey, sender, to := setupTest(t) + + // define amount, fee rate and bump fee reserved + amount := 0.00001 + nonceMark := chains.NonceMarkAmount(1) + feeRate := int64(2) + bumpFeeReserved := int64(10000) + + // STEP 1 + // build and send tx1 + nonceMark += 1 + txHash1 := buildAndSendRBFTx(t, client, privKey, nil, sender, to, amount, nonceMark, feeRate, bumpFeeReserved) + fmt.Printf("sent tx1: %s\n", txHash1) + + // STEP 2 + // build and send tx2 (child of tx1) + nonceMark += 1 + txHash2 := buildAndSendRBFTx(t, client, privKey, txHash1, sender, to, amount, nonceMark, feeRate, bumpFeeReserved) + fmt.Printf("sent tx2: %s\n", txHash2) + + // STEP 3 + // wait for a short time before bumping fee + rawTx1, confirmed := waitForTxConfirmation(client, sender, txHash1, 10*time.Second) + if confirmed { + fmt.Println("Opps: tx1 confirmed, no chance to bump fee; please start over") + return + } + + // STEP 4 + // bump gas fee for tx1 (the parent of tx2) + // we assume that tx1, tx2 and tx3 have same vBytes for simplicity + // two rules to satisfy: + // - feeTx3 >= feeTx1 + feeTx2 + // - additionalFees >= vSizeTx3 * minRelayFeeRate + // see: https://github.com/bitcoin/bitcoin/blob/master/src/policy/rbf.cpp#L166-L183 + minRelayFeeRate := int64(1) + feeRateIncrease := minRelayFeeRate + sizeTx3 := mempool.GetTxVirtualSize(rawTx1) + additionalFees := (sizeTx3 + 1) * (feeRate + feeRateIncrease) // +1 in case Bitcoin Core rounds up the vSize + fmt.Printf("additional fee: %d sats\n", additionalFees) + tx3, err := bumpRBFTxFee(rawTx1.MsgTx(), additionalFees) + require.NoError(t, err) + + // STEP 5 + // sign and send tx3, which replaces tx1 + signTx(t, client, privKey, tx3) + txHash3, err := client.SendRawTransaction(tx3, true) + require.NoError(t, err) + fmt.Printf("sent tx3: %s\n", txHash3) + + // STEP 6 + // wait for tx3 confirmation + rawTx3, confirmed := waitForTxConfirmation(client, sender, txHash3, 30*time.Minute) + require.True(t, confirmed) + printTx(rawTx3.MsgTx()) + fmt.Println("tx3 confirmed") + + // STEP 7 + // tx1 and tx2 must be dropped + ensureTxDropped(t, client, txHash1) + fmt.Println("tx1 dropped") + ensureTxDropped(t, client, txHash2) + fmt.Println("tx2 dropped") +} + +// Test_RBFTransactionChained_CPFP tests Child-Pays-For-Parent (CPFP) fee bumping strategy for chained RBF transactions +func LiveTest_RBFTransaction_Chained_CPFP(t *testing.T) { + // setup test + client, privKey, sender, to := setupTest(t) + + // define amount, fee rate and bump fee reserved + amount := 0.00001 + nonceMark := chains.NonceMarkAmount(0) + feeRate := int64(2) + bumpFeeReserved := int64(10000) + + // STEP 1 + // build and send tx1 + nonceMark += 1 + txHash1 := buildAndSendRBFTx(t, client, privKey, nil, sender, to, amount, nonceMark, feeRate, bumpFeeReserved) + fmt.Printf("sent tx1: %s\n", txHash1) + + // STEP 2 + // build and send tx2 (child of tx1) + nonceMark += 1 + txHash2 := buildAndSendRBFTx(t, client, privKey, txHash1, sender, to, amount, nonceMark, feeRate, bumpFeeReserved) + fmt.Printf("sent tx2: %s\n", txHash2) + + // STEP 3 + // build and send tx3 (child of tx2) + nonceMark += 1 + txHash3 := buildAndSendRBFTx(t, client, privKey, txHash2, sender, to, amount, nonceMark, feeRate, bumpFeeReserved) + fmt.Printf("sent tx3: %s\n", txHash3) + + // STEP 4 + // wait for a short time before bumping fee + rawTx3, confirmed := waitForTxConfirmation(client, sender, txHash3, 10*time.Second) + if confirmed { + fmt.Println("Opps: tx3 confirmed, no chance to bump fee; please start over") + return + } + + // STEP 5 + // bump gas fee for tx3 (the child/grandchild of tx1/tx2) + // we assume that tx3 has same vBytes as the fee-bump tx (tx4) for simplicity + // two rules to satisfy: + // - feeTx4 >= feeTx3 + // - additionalFees >= vSizeTx4 * minRelayFeeRate + // see: https://github.com/bitcoin/bitcoin/blob/master/src/policy/rbf.cpp#L166-L183 + minRelayFeeRate := int64(1) + feeRateIncrease := minRelayFeeRate + additionalFees := (mempool.GetTxVirtualSize(rawTx3) + 1) * feeRateIncrease + fmt.Printf("additional fee: %d sats\n", additionalFees) + tx4, err := bumpRBFTxFee(rawTx3.MsgTx(), additionalFees) + require.NoError(t, err) + + // STEP 6 + // sign and send tx4, which replaces tx3 + signTx(t, client, privKey, tx4) + txHash4, err := client.SendRawTransaction(tx4, true) + require.NoError(t, err) + fmt.Printf("sent tx4: %s\n", txHash4) + + // STEP 7 + // wait for tx4 confirmation + rawTx4, confirmed := waitForTxConfirmation(client, sender, txHash4, 30*time.Minute) + require.True(t, confirmed) + printTx(rawTx4.MsgTx()) + fmt.Println("tx4 confirmed") + + // STEP 8 + // tx3 must be dropped + ensureTxDropped(t, client, txHash3) + fmt.Println("tx1 dropped") +} + +func LiveTest_PendingMempoolTx(t *testing.T) { + // setup Bitcoin client + client, err := createRPCClient(chains.BitcoinMainnet.ChainId) + require.NoError(t, err) + + // get mempool transactions + mempoolTxs, err := client.GetRawMempool() + require.NoError(t, err) + fmt.Printf("mempool txs: %d\n", len(mempoolTxs)) + + // get last block height + lastHeight, err := client.GetBlockCount() + require.NoError(t, err) + fmt.Printf("block height: %d\n", lastHeight) + + const ( + // average minutes per block is about 10 minutes + minutesPerBlockAverage = 10.0 + + // maxBlockTimeDiffPercentage is the maximum error percentage between the estimated and actual block time + // note: 25% is a percentage to make sure the test is not too strict + maxBlockTimeDiffPercentage = 0.25 + ) + + // the goal of the test is to ensure the 'Time' and 'Height' provided by the mempool are correct. + // otherwise, zetaclient should not rely on these information to schedule RBF/CPFP transactions. + // loop through the mempool to sample N pending txs that are pending for more than 2 hours + N := 10 + for i := len(mempoolTxs) - 1; i >= 0; i-- { + txHash := mempoolTxs[i] + entry, err := client.GetMempoolEntry(txHash.String()) + if err == nil { + txTime := time.Unix(entry.Time, 0) + txTimeStr := txTime.Format(time.DateTime) + elapsed := time.Since(txTime) + if elapsed > 2*time.Hour { + // calculate average block time + elapsedBlocks := lastHeight - entry.Height + minutesPerBlockCalculated := elapsed.Minutes() / float64(elapsedBlocks) + blockTimeDiff := minutesPerBlockAverage - minutesPerBlockCalculated + if blockTimeDiff < 0 { + blockTimeDiff = -blockTimeDiff + } + + // the block time difference should fall within 25% of the average block time + require.Less(t, blockTimeDiff, minutesPerBlockAverage*maxBlockTimeDiffPercentage) + fmt.Printf( + "txid: %s, height: %d, time: %s, pending: %f minutes, block time: %f minutes, diff: %f%%\n", + txHash, + entry.Height, + txTimeStr, + elapsed.Minutes(), + minutesPerBlockCalculated, + blockTimeDiff/minutesPerBlockAverage*100, + ) + + // break if we have enough samples + if N -= 1; N == 0 { + break + } + } + } + } +} + +// buildAndSendRBFTx builds, signs and sends an RBF transaction +func buildAndSendRBFTx( + t *testing.T, + client *rpcclient.Client, + privKey *secp256k1.PrivateKey, + parent *chainhash.Hash, + sender, to btcutil.Address, + amount float64, + nonceMark int64, + feeRate int64, + bumpFeeReserved int64, +) *chainhash.Hash { + // list outputs + utxos := listUTXOs(client, sender) + require.NotEmpty(t, utxos) + + // ensure all inputs are from the parent tx + if parent != nil { + for _, out := range utxos { + require.Equal(t, parent.String(), out.TxID) + } + } + + // build tx opt-in RBF + tx := buildRBFTx(t, utxos, sender, to, amount, nonceMark, feeRate, bumpFeeReserved) + + // sign tx + signTx(t, client, privKey, tx) + + // broadcast tx + txHash, err := client.SendRawTransaction(tx, true) + require.NoError(t, err) + + return txHash +} + +func listUTXOs(client *rpcclient.Client, address btcutil.Address) []btcjson.ListUnspentResult { + utxos, err := client.ListUnspentMinMaxAddresses(0, 9999999, []btcutil.Address{address}) + if err != nil { + fmt.Printf("ListUnspent failed: %s\n", err) + return nil + } + + // sort utxos by amount, txid, vout + sort.SliceStable(utxos, func(i, j int) bool { + if utxos[i].Amount == utxos[j].Amount { + if utxos[i].TxID == utxos[j].TxID { + return utxos[i].Vout < utxos[j].Vout + } + return utxos[i].TxID < utxos[j].TxID + } + return utxos[i].Amount < utxos[j].Amount + }) + + // print utxos + fmt.Println("utxos:") + for _, out := range utxos { + fmt.Printf( + " txid: %s, vout: %d, amount: %f, confirmation: %d\n", + out.TxID, + out.Vout, + out.Amount, + out.Confirmations, + ) + } + + return utxos +} + +func buildRBFTx( + t *testing.T, + utxos []btcjson.ListUnspentResult, + sender, to btcutil.Address, + amount float64, + nonceMark int64, + feeRate int64, + bumpFeeReserved int64, +) *wire.MsgTx { + // build tx with all unspents + total := 0.0 + tx := wire.NewMsgTx(wire.TxVersion) + for _, output := range utxos { + hash, err := chainhash.NewHashFromStr(output.TxID) + require.NoError(t, err) + + // add input + outpoint := wire.NewOutPoint(hash, output.Vout) + txIn := wire.NewTxIn(outpoint, nil, nil) + txIn.Sequence = 1 // opt-in for RBF + tx.AddTxIn(txIn) + total += output.Amount + } + totalSats, err := bitcoin.GetSatoshis(total) + require.NoError(t, err) + + // amount to send in satoshis + amountSats, err := bitcoin.GetSatoshis(amount) + require.NoError(t, err) + + // calculate tx fee + txSize, err := bitcoin.EstimateOutboundSize(uint64(len(utxos)), []btcutil.Address{to}) + require.NoError(t, err) + fees := int64(txSize) * feeRate + + // make sure total is greater than amount + fees + require.GreaterOrEqual(t, totalSats, nonceMark+amountSats+fees+bumpFeeReserved) + + // 1st output: simulated nonce-mark amount to self + pkScriptSender, err := txscript.PayToAddrScript(sender) + require.NoError(t, err) + txOut0 := wire.NewTxOut(nonceMark, pkScriptSender) + tx.AddTxOut(txOut0) + + // 2nd output: payment to receiver + pkScriptReceiver, err := txscript.PayToAddrScript(to) + require.NoError(t, err) + txOut1 := wire.NewTxOut(amountSats, pkScriptReceiver) + tx.AddTxOut(txOut1) + + // 3rd output: change to self + changeSats := totalSats - nonceMark - amountSats - fees + require.GreaterOrEqual(t, changeSats, bumpFeeReserved) + txOut2 := wire.NewTxOut(changeSats, pkScriptSender) + tx.AddTxOut(txOut2) + + return tx +} + +func signTx(t *testing.T, client *rpcclient.Client, privKey *secp256k1.PrivateKey, tx *wire.MsgTx) { + // we know that the first output is the nonce-mark amount, so it contains the sender pkScript + pkScriptSender := tx.TxOut[0].PkScript + + sigHashes := txscript.NewTxSigHashes(tx, txscript.NewCannedPrevOutputFetcher([]byte{}, 0)) + for idx, input := range tx.TxIn { + // get input amount from previous tx outpoint via RPC + rawTx, err := client.GetRawTransaction(&input.PreviousOutPoint.Hash) + require.NoError(t, err) + amount := rawTx.MsgTx().TxOut[input.PreviousOutPoint.Index].Value + + // calculate witness signature hash for signing + witnessHash, err := txscript.CalcWitnessSigHash(pkScriptSender, sigHashes, txscript.SigHashAll, tx, idx, amount) + require.NoError(t, err) + + // sign the witness hash + sig := ecdsa.Sign(privKey, witnessHash) + tx.TxIn[idx].Witness = wire.TxWitness{ + append(sig.Serialize(), byte(txscript.SigHashAll)), + privKey.PubKey().SerializeCompressed(), + } + } + + printTx(tx) +} + +func printTx(tx *wire.MsgTx) { + fmt.Printf("\n==============================================================\n") + fmt.Printf("tx version: %d\n", tx.Version) + fmt.Printf("tx locktime: %d\n", tx.LockTime) + fmt.Println("tx inputs:") + for i, vin := range tx.TxIn { + fmt.Printf(" input[%d]:\n", i) + fmt.Printf(" prevout hash: %s\n", vin.PreviousOutPoint.Hash) + fmt.Printf(" prevout index: %d\n", vin.PreviousOutPoint.Index) + fmt.Printf(" sig script: %s\n", hex.EncodeToString(vin.SignatureScript)) + fmt.Printf(" sequence: %d\n", vin.Sequence) + fmt.Printf(" witness: \n") + for j, w := range vin.Witness { + fmt.Printf(" witness[%d]: %s\n", j, hex.EncodeToString(w)) + } + } + fmt.Println("tx outputs:") + for i, vout := range tx.TxOut { + fmt.Printf(" output[%d]:\n", i) + fmt.Printf(" value: %d\n", vout.Value) + fmt.Printf(" script: %s\n", hex.EncodeToString(vout.PkScript)) + } + fmt.Printf("==============================================================\n\n") +} + +func peekUnconfirmedTx(client *rpcclient.Client, txHash *chainhash.Hash) (*btcutil.Tx, bool) { + confirmed := false + + // try querying tx result + _, getTxResult, err := rpc.GetTxResultByHash(client, txHash.String()) + if err == nil { + confirmed = getTxResult.Confirmations > 0 + fmt.Printf("tx confirmations: %d\n", getTxResult.Confirmations) + } else { + fmt.Printf("GetTxResultByHash failed: %s\n", err) + } + + // query tx from mempool + entry, err := client.GetMempoolEntry(txHash.String()) + switch { + case err != nil: + fmt.Println("tx in mempool: NO") + default: + txTime := time.Unix(entry.Time, 0) + txTimeStr := txTime.Format(time.DateTime) + elapsed := int64(time.Since(txTime).Seconds()) + fmt.Printf( + "tx in mempool: YES, VSize: %d, height: %d, time: %s, elapsed: %d\n", + entry.VSize, + entry.Height, + txTimeStr, + elapsed, + ) + } + + // query the raw tx + rawTx, err := client.GetRawTransaction(txHash) + if err != nil { + fmt.Printf("GetRawTransaction failed: %s\n", err) + } + + return rawTx, confirmed +} + +func waitForTxConfirmation( + client *rpcclient.Client, + sender btcutil.Address, + txHash *chainhash.Hash, + timeOut time.Duration, +) (*btcutil.Tx, bool) { + start := time.Now() + for { + rawTx, confirmed := peekUnconfirmedTx(client, txHash) + listUTXOs(client, sender) + fmt.Println() + + if confirmed { + return rawTx, true + } + if time.Since(start) > timeOut { + return rawTx, false + } + + time.Sleep(5 * time.Second) + } +} + +func bumpRBFTxFee(oldTx *wire.MsgTx, additionalFee int64) (*wire.MsgTx, error) { + // copy the old tx and reset + newTx := oldTx.Copy() + for idx := range newTx.TxIn { + newTx.TxIn[idx].Witness = wire.TxWitness{} + newTx.TxIn[idx].Sequence = 1 + } + + // original change needs to be enough to cover the additional fee + if newTx.TxOut[2].Value <= additionalFee { + return nil, errors.New("change amount is not enough to cover the additional fee") + } + + // bump fee by reducing the change amount + newTx.TxOut[2].Value = newTx.TxOut[2].Value - additionalFee + + return newTx, nil +} + +func ensureTxDropped(t *testing.T, client *rpcclient.Client, txHash *chainhash.Hash) { + // dropped tx must has negative confirmations (if returned) + _, getTxResult, err := rpc.GetTxResultByHash(client, txHash.String()) + if err == nil { + require.Negative(t, getTxResult.Confirmations) + } + + // dropped tx should be removed from mempool + entry, err := client.GetMempoolEntry(txHash.String()) + require.Error(t, err) + require.Nil(t, entry) + + // dropped tx should not be found + // -5: No such mempool or blockchain transaction + rawTx, err := client.GetRawTransaction(txHash) + require.Error(t, err) + require.Nil(t, rawTx) +} diff --git a/zetaclient/chains/bitcoin/signer/signer.go b/zetaclient/chains/bitcoin/signer/signer.go index 1321b0c14f..2769a46845 100644 --- a/zetaclient/chains/bitcoin/signer/signer.go +++ b/zetaclient/chains/bitcoin/signer/signer.go @@ -42,6 +42,12 @@ const ( // the rank below (or equal to) which we consolidate UTXOs consolidationRank = 10 + // rbfTxInSequenceNum is the sequence number used to signal an opt-in full-RBF (Replace-By-Fee) transaction + // Setting sequenceNum to "1" effectively makes the transaction timelocks irrelevant. + // See bip125: https://github.com/bitcoin/bips/blob/master/bip-0125.mediawiki + // Also see: https://github.com/BlockchainCommons/Learning-Bitcoin-from-the-Command-Line/blob/master/05_2_Resending_a_Transaction_with_RBF.md + rbfTxInSequenceNum uint32 = 1 + // broadcastBackoff is the initial backoff duration for retrying broadcast broadcastBackoff = 1000 * time.Millisecond @@ -234,8 +240,11 @@ func (signer *Signer) SignWithdrawTx( if err != nil { return nil, err } + + // add input and set 'nSequence' to opt-in for RBF outpoint := wire.NewOutPoint(hash, prevOut.Vout) txIn := wire.NewTxIn(outpoint, nil, nil) + txIn.Sequence = rbfTxInSequenceNum tx.AddTxIn(txIn) } diff --git a/zetaclient/common/env.go b/zetaclient/common/env.go index f3e97110c6..b689ba1050 100644 --- a/zetaclient/common/env.go +++ b/zetaclient/common/env.go @@ -14,6 +14,9 @@ const ( // EnvBtcRPCTestnet is the environment variable to enable testnet for bitcoin rpc EnvBtcRPCTestnet = "BTC_RPC_TESTNET" + + // EnvBtcRPCTestnet4 is the environment variable to enable testnet4 for bitcoin rpc + EnvBtcRPCTestnet4 = "BTC_RPC_TESTNET4" ) // LiveTestEnabled returns true if live tests are enabled From 3af0e096d0639cfe3fd2f4fc4ba2a86f26951a0d Mon Sep 17 00:00:00 2001 From: Charlie Chen Date: Mon, 9 Dec 2024 13:50:17 -0600 Subject: [PATCH 02/20] remove duplicate LiveTest_PendingMempoolTx test --- zetaclient/chains/bitcoin/rpc/rpc_live_test.go | 1 - 1 file changed, 1 deletion(-) diff --git a/zetaclient/chains/bitcoin/rpc/rpc_live_test.go b/zetaclient/chains/bitcoin/rpc/rpc_live_test.go index cafed92343..452b2953df 100644 --- a/zetaclient/chains/bitcoin/rpc/rpc_live_test.go +++ b/zetaclient/chains/bitcoin/rpc/rpc_live_test.go @@ -118,7 +118,6 @@ func Test_BitcoinLive(t *testing.T) { return } - LiveTest_PendingMempoolTx(t) LiveTest_NewRPCClient(t) LiveTest_CheckRPCStatus(t) LiveTest_FilterAndParseIncomingTx(t) From 1ad6628bda582268e682d77955562b9b590ee1c1 Mon Sep 17 00:00:00 2001 From: Charlie Chen Date: Sat, 21 Dec 2024 23:53:22 -0600 Subject: [PATCH 03/20] initiate Bitcoin mempool watcher and RBF keysign logic --- zetaclient/chains/bitcoin/fee.go | 60 ++- zetaclient/chains/bitcoin/fee_test.go | 20 +- zetaclient/chains/bitcoin/observer/db.go | 65 +++ .../chains/bitcoin/observer/gas_price.go | 106 +++++ zetaclient/chains/bitcoin/observer/mempool.go | 149 +++++++ .../chains/bitcoin/observer/observer.go | 301 ++----------- .../chains/bitcoin/observer/outbound.go | 414 +++++++----------- zetaclient/chains/bitcoin/observer/utxos.go | 230 ++++++++++ zetaclient/chains/bitcoin/rpc/rpc.go | 132 ++++++ .../chains/bitcoin/rpc/rpc_rbf_live_test.go | 134 ++++-- .../chains/bitcoin/signer/fee_bumper.go | 166 +++++++ .../chains/bitcoin/signer/outbound_data.go | 116 +++++ .../chains/bitcoin/signer/sign_withdraw.go | 253 +++++++++++ .../bitcoin/signer/sign_withdraw_rbf.go | 79 ++++ zetaclient/chains/bitcoin/signer/signer.go | 402 ++++------------- .../chains/bitcoin/signer/signer_test.go | 15 +- zetaclient/chains/interfaces/interfaces.go | 1 + zetaclient/common/constant.go | 3 + zetaclient/logs/fields.go | 1 + zetaclient/orchestrator/orchestrator.go | 18 +- zetaclient/testutils/mocks/btc_rpc.go | 30 ++ 21 files changed, 1758 insertions(+), 937 deletions(-) create mode 100644 zetaclient/chains/bitcoin/observer/db.go create mode 100644 zetaclient/chains/bitcoin/observer/gas_price.go create mode 100644 zetaclient/chains/bitcoin/observer/mempool.go create mode 100644 zetaclient/chains/bitcoin/observer/utxos.go create mode 100644 zetaclient/chains/bitcoin/signer/fee_bumper.go create mode 100644 zetaclient/chains/bitcoin/signer/outbound_data.go create mode 100644 zetaclient/chains/bitcoin/signer/sign_withdraw.go create mode 100644 zetaclient/chains/bitcoin/signer/sign_withdraw_rbf.go diff --git a/zetaclient/chains/bitcoin/fee.go b/zetaclient/chains/bitcoin/fee.go index 1c0803e552..47f93280cf 100644 --- a/zetaclient/chains/bitcoin/fee.go +++ b/zetaclient/chains/bitcoin/fee.go @@ -4,7 +4,6 @@ import ( "encoding/hex" "fmt" "math" - "math/big" "github.com/btcsuite/btcd/blockchain" "github.com/btcsuite/btcd/btcjson" @@ -20,18 +19,17 @@ import ( const ( // constants related to transaction size calculations - bytesPerKB = 1000 - bytesPerInput = 41 // each input is 41 bytes - bytesPerOutputP2TR = 43 // each P2TR output is 43 bytes - bytesPerOutputP2WSH = 43 // each P2WSH output is 43 bytes - bytesPerOutputP2WPKH = 31 // each P2WPKH output is 31 bytes - bytesPerOutputP2SH = 32 // each P2SH output is 32 bytes - bytesPerOutputP2PKH = 34 // each P2PKH output is 34 bytes - bytesPerOutputAvg = 37 // average size of all above types of outputs (36.6 bytes) - bytes1stWitness = 110 // the 1st witness incurs about 110 bytes and it may vary - bytesPerWitness = 108 // each additional witness incurs about 108 bytes and it may vary - OutboundBytesMin = uint64(239) // 239vB == EstimateSegWitTxSize(2, 2, toP2WPKH) - OutboundBytesMax = uint64(1543) // 1543v == EstimateSegWitTxSize(21, 2, toP2TR) + bytesPerInput = 41 // each input is 41 bytes + bytesPerOutputP2TR = 43 // each P2TR output is 43 bytes + bytesPerOutputP2WSH = 43 // each P2WSH output is 43 bytes + bytesPerOutputP2WPKH = 31 // each P2WPKH output is 31 bytes + bytesPerOutputP2SH = 32 // each P2SH output is 32 bytes + bytesPerOutputP2PKH = 34 // each P2PKH output is 34 bytes + bytesPerOutputAvg = 37 // average size of all above types of outputs (36.6 bytes) + bytes1stWitness = 110 // the 1st witness incurs about 110 bytes and it may vary + bytesPerWitness = 108 // each additional witness incurs about 108 bytes and it may vary + OutboundBytesMin = int64(239) // 239vB == EstimateSegWitTxSize(2, 2, toP2WPKH) + OutboundBytesMax = int64(1543) // 1543v == EstimateSegWitTxSize(21, 2, toP2TR) // defaultDepositorFeeRate is the default fee rate for depositor fee, 20 sat/vB defaultDepositorFeeRate = 20 @@ -59,34 +57,27 @@ var ( // DepositorFeeCalculator is a function type to calculate the Bitcoin depositor fee type DepositorFeeCalculator func(interfaces.BTCRPCClient, *btcjson.TxRawResult, *chaincfg.Params) (float64, error) -// FeeRateToSatPerByte converts a fee rate in BTC/KB to sat/byte. -func FeeRateToSatPerByte(rate float64) *big.Int { - // #nosec G115 always in range - satPerKB := new(big.Int).SetInt64(int64(rate * btcutil.SatoshiPerBitcoin)) - return new(big.Int).Div(satPerKB, big.NewInt(bytesPerKB)) -} - // WiredTxSize calculates the wired tx size in bytes -func WiredTxSize(numInputs uint64, numOutputs uint64) uint64 { +func WiredTxSize(numInputs uint64, numOutputs uint64) int64 { // Version 4 bytes + LockTime 4 bytes + Serialized varint size for the // number of transaction inputs and outputs. // #nosec G115 always positive - return uint64(8 + wire.VarIntSerializeSize(numInputs) + wire.VarIntSerializeSize(numOutputs)) + return int64(8 + wire.VarIntSerializeSize(numInputs) + wire.VarIntSerializeSize(numOutputs)) } // EstimateOutboundSize estimates the size of an outbound in vBytes -func EstimateOutboundSize(numInputs uint64, payees []btcutil.Address) (uint64, error) { +func EstimateOutboundSize(numInputs int64, payees []btcutil.Address) (int64, error) { if numInputs == 0 { return 0, nil } // #nosec G115 always positive numOutputs := 2 + uint64(len(payees)) - bytesWiredTx := WiredTxSize(numInputs, numOutputs) + bytesWiredTx := WiredTxSize(uint64(numInputs), numOutputs) bytesInput := numInputs * bytesPerInput - bytesOutput := uint64(2) * bytesPerOutputP2WPKH // new nonce mark, change + bytesOutput := int64(2) * bytesPerOutputP2WPKH // new nonce mark, change // calculate the size of the outputs to payees - bytesToPayees := uint64(0) + bytesToPayees := int64(0) for _, to := range payees { sizeOutput, err := GetOutputSizeByAddress(to) if err != nil { @@ -104,7 +95,7 @@ func EstimateOutboundSize(numInputs uint64, payees []btcutil.Address) (uint64, e } // GetOutputSizeByAddress returns the size of a tx output in bytes by the given address -func GetOutputSizeByAddress(to btcutil.Address) (uint64, error) { +func GetOutputSizeByAddress(to btcutil.Address) (int64, error) { switch addr := to.(type) { case *btcutil.AddressTaproot: if addr == nil { @@ -137,16 +128,16 @@ func GetOutputSizeByAddress(to btcutil.Address) (uint64, error) { } // OutboundSizeDepositor returns outbound size (68vB) incurred by the depositor -func OutboundSizeDepositor() uint64 { +func OutboundSizeDepositor() int64 { return bytesPerInput + bytesPerWitness/blockchain.WitnessScaleFactor } // OutboundSizeWithdrawer returns outbound size (177vB) incurred by the withdrawer (1 input, 3 outputs) -func OutboundSizeWithdrawer() uint64 { +func OutboundSizeWithdrawer() int64 { bytesWiredTx := WiredTxSize(1, 3) - bytesInput := uint64(1) * bytesPerInput // nonce mark - bytesOutput := uint64(2) * bytesPerOutputP2WPKH // 2 P2WPKH outputs: new nonce mark, change - bytesOutput += bytesPerOutputAvg // 1 output to withdrawer's address + bytesInput := int64(1) * bytesPerInput // nonce mark + bytesOutput := int64(2) * bytesPerOutputP2WPKH // 2 P2WPKH outputs: new nonce mark, change + bytesOutput += bytesPerOutputAvg // 1 output to withdrawer's address return bytesWiredTx + bytesInput + bytesOutput + bytes1stWitness/blockchain.WitnessScaleFactor } @@ -246,7 +237,7 @@ func CalcDepositorFee( // GetRecentFeeRate gets the highest fee rate from recent blocks // Note: this method should be used for testnet ONLY -func GetRecentFeeRate(rpcClient interfaces.BTCRPCClient, netParams *chaincfg.Params) (uint64, error) { +func GetRecentFeeRate(rpcClient interfaces.BTCRPCClient, netParams *chaincfg.Params) (int64, error) { // should avoid using this method for mainnet if netParams.Name == chaincfg.MainNetParams.Name { return 0, errors.New("GetRecentFeeRate should not be used for mainnet") @@ -286,6 +277,5 @@ func GetRecentFeeRate(rpcClient interfaces.BTCRPCClient, netParams *chaincfg.Par highestRate = defaultTestnetFeeRate } - // #nosec G115 always in range - return uint64(highestRate), nil + return highestRate, nil } diff --git a/zetaclient/chains/bitcoin/fee_test.go b/zetaclient/chains/bitcoin/fee_test.go index 209e35e358..e12c427733 100644 --- a/zetaclient/chains/bitcoin/fee_test.go +++ b/zetaclient/chains/bitcoin/fee_test.go @@ -195,9 +195,9 @@ func TestOutboundSize2In3Out(t *testing.T) { // Estimate the tx size in vByte // #nosec G115 always positive - vError := uint64(1) // 1 vByte error tolerance - vBytes := uint64(blockchain.GetTransactionWeight(btcutil.NewTx(tx)) / blockchain.WitnessScaleFactor) - vBytesEstimated, err := EstimateOutboundSize(uint64(len(utxosTxids)), []btcutil.Address{payee}) + vError := int64(1) // 1 vByte error tolerance + vBytes := blockchain.GetTransactionWeight(btcutil.NewTx(tx)) / blockchain.WitnessScaleFactor + vBytesEstimated, err := EstimateOutboundSize(int64(len(utxosTxids)), []btcutil.Address{payee}) require.NoError(t, err) if vBytes > vBytesEstimated { require.True(t, vBytes-vBytesEstimated <= vError) @@ -219,9 +219,9 @@ func TestOutboundSize21In3Out(t *testing.T) { // Estimate the tx size in vByte // #nosec G115 always positive - vError := uint64(21 / 4) // 5 vBytes error tolerance - vBytes := uint64(blockchain.GetTransactionWeight(btcutil.NewTx(tx)) / blockchain.WitnessScaleFactor) - vBytesEstimated, err := EstimateOutboundSize(uint64(len(exampleTxids)), []btcutil.Address{payee}) + vError := int64(21 / 4) // 5 vBytes error tolerance + vBytes := blockchain.GetTransactionWeight(btcutil.NewTx(tx)) / blockchain.WitnessScaleFactor + vBytesEstimated, err := EstimateOutboundSize(int64(len(exampleTxids)), []btcutil.Address{payee}) require.NoError(t, err) if vBytes > vBytesEstimated { require.True(t, vBytes-vBytesEstimated <= vError) @@ -243,11 +243,11 @@ func TestOutboundSizeXIn3Out(t *testing.T) { // Estimate the tx size // #nosec G115 always positive - vError := uint64( + vError := int64( 0.25 + float64(x)/4, ) // 1st witness incurs 0.25 more vByte error than others (which incurs 1/4 vByte per witness) - vBytes := uint64(blockchain.GetTransactionWeight(btcutil.NewTx(tx)) / blockchain.WitnessScaleFactor) - vBytesEstimated, err := EstimateOutboundSize(uint64(len(exampleTxids[:x])), []btcutil.Address{payee}) + vBytes := blockchain.GetTransactionWeight(btcutil.NewTx(tx)) / blockchain.WitnessScaleFactor + vBytesEstimated, err := EstimateOutboundSize(int64(len(exampleTxids[:x])), []btcutil.Address{payee}) require.NoError(t, err) if vBytes > vBytesEstimated { require.True(t, vBytes-vBytesEstimated <= vError) @@ -413,7 +413,7 @@ func TestOutboundSizeBreakdown(t *testing.T) { } // add all outbound sizes paying to each address - txSizeTotal := uint64(0) + txSizeTotal := int64(0) for _, payee := range payees { sizeOutput, err := EstimateOutboundSize(2, []btcutil.Address{payee}) require.NoError(t, err) diff --git a/zetaclient/chains/bitcoin/observer/db.go b/zetaclient/chains/bitcoin/observer/db.go new file mode 100644 index 0000000000..a8c76d88c2 --- /dev/null +++ b/zetaclient/chains/bitcoin/observer/db.go @@ -0,0 +1,65 @@ +package observer + +import ( + "github.com/pkg/errors" + + "github.com/zeta-chain/node/pkg/chains" + clienttypes "github.com/zeta-chain/node/zetaclient/types" +) + +// SaveBroadcastedTx saves successfully broadcasted transaction +func (ob *Observer) SaveBroadcastedTx(txHash string, nonce uint64) { + outboundID := ob.OutboundID(nonce) + ob.Mu().Lock() + ob.broadcastedTx[outboundID] = txHash + ob.Mu().Unlock() + + broadcastEntry := clienttypes.ToOutboundHashSQLType(txHash, outboundID) + if err := ob.DB().Client().Save(&broadcastEntry).Error; err != nil { + ob.logger.Outbound.Error(). + Err(err). + Msgf("SaveBroadcastedTx: error saving broadcasted txHash %s for outbound %s", txHash, outboundID) + } + ob.logger.Outbound.Info().Msgf("SaveBroadcastedTx: saved broadcasted txHash %s for outbound %s", txHash, outboundID) +} + +// LoadLastBlockScanned loads the last scanned block from the database +func (ob *Observer) LoadLastBlockScanned() error { + err := ob.Observer.LoadLastBlockScanned(ob.Logger().Chain) + if err != nil { + return errors.Wrapf(err, "error LoadLastBlockScanned for chain %d", ob.Chain().ChainId) + } + + // observer will scan from the last block when 'lastBlockScanned == 0', this happens when: + // 1. environment variable is set explicitly to "latest" + // 2. environment variable is empty and last scanned block is not found in DB + if ob.LastBlockScanned() == 0 { + blockNumber, err := ob.btcClient.GetBlockCount() + if err != nil { + return errors.Wrapf(err, "error GetBlockCount for chain %d", ob.Chain().ChainId) + } + // #nosec G115 always positive + ob.WithLastBlockScanned(uint64(blockNumber)) + } + + // bitcoin regtest starts from hardcoded block 100 + if chains.IsBitcoinRegnet(ob.Chain().ChainId) { + ob.WithLastBlockScanned(RegnetStartBlock) + } + ob.Logger().Chain.Info().Msgf("chain %d starts scanning from block %d", ob.Chain().ChainId, ob.LastBlockScanned()) + + return nil +} + +// LoadBroadcastedTxMap loads broadcasted transactions from the database +func (ob *Observer) LoadBroadcastedTxMap() error { + var broadcastedTransactions []clienttypes.OutboundHashSQLType + if err := ob.DB().Client().Find(&broadcastedTransactions).Error; err != nil { + ob.logger.Chain.Error().Err(err).Msgf("error iterating over db for chain %d", ob.Chain().ChainId) + return err + } + for _, entry := range broadcastedTransactions { + ob.broadcastedTx[entry.Key] = entry.Hash + } + return nil +} diff --git a/zetaclient/chains/bitcoin/observer/gas_price.go b/zetaclient/chains/bitcoin/observer/gas_price.go new file mode 100644 index 0000000000..aff3b1b5f5 --- /dev/null +++ b/zetaclient/chains/bitcoin/observer/gas_price.go @@ -0,0 +1,106 @@ +package observer + +import ( + "context" + "fmt" + + "github.com/pkg/errors" + + "github.com/zeta-chain/node/pkg/chains" + "github.com/zeta-chain/node/zetaclient/chains/bitcoin" + "github.com/zeta-chain/node/zetaclient/chains/bitcoin/rpc" + clienttypes "github.com/zeta-chain/node/zetaclient/types" +) + +// WatchGasPrice watches Bitcoin chain for gas rate and post to zetacore +func (ob *Observer) WatchGasPrice(ctx context.Context) error { + // report gas price right away as the ticker takes time to kick in + err := ob.PostGasPrice(ctx) + if err != nil { + ob.logger.GasPrice.Error().Err(err).Msgf("PostGasPrice error for chain %d", ob.Chain().ChainId) + } + + // start gas price ticker + ticker, err := clienttypes.NewDynamicTicker("Bitcoin_WatchGasPrice", ob.ChainParams().GasPriceTicker) + if err != nil { + return errors.Wrapf(err, "NewDynamicTicker error") + } + ob.logger.GasPrice.Info().Msgf("WatchGasPrice started for chain %d with interval %d", + ob.Chain().ChainId, ob.ChainParams().GasPriceTicker) + + defer ticker.Stop() + for { + select { + case <-ticker.C(): + if !ob.ChainParams().IsSupported { + continue + } + err := ob.PostGasPrice(ctx) + if err != nil { + ob.logger.GasPrice.Error().Err(err).Msgf("PostGasPrice error for chain %d", ob.Chain().ChainId) + } + ticker.UpdateInterval(ob.ChainParams().GasPriceTicker, ob.logger.GasPrice) + case <-ob.StopChannel(): + ob.logger.GasPrice.Info().Msgf("WatchGasPrice stopped for chain %d", ob.Chain().ChainId) + return nil + } + } +} + +// PostGasPrice posts gas price to zetacore +func (ob *Observer) PostGasPrice(ctx context.Context) error { + var ( + err error + feeRateEstimated int64 + ) + + // special handle regnet and testnet gas rate + // regnet: RPC 'EstimateSmartFee' is not available + // testnet: RPC 'EstimateSmartFee' returns unreasonable high gas rate + if ob.Chain().NetworkType != chains.NetworkType_mainnet { + feeRateEstimated, err = ob.specialHandleFeeRate() + if err != nil { + return errors.Wrap(err, "unable to execute specialHandleFeeRate") + } + } else { + feeRateEstimated, err = rpc.GetEstimatedFeeRate(ob.btcClient, 1) + if err != nil { + return errors.Wrap(err, "unable to get estimated fee rate") + } + } + + // query the current block number + blockNumber, err := ob.btcClient.GetBlockCount() + if err != nil { + return errors.Wrap(err, "GetBlockCount error") + } + + // Bitcoin has no concept of priority fee (like eth) + const priorityFee = 0 + + // #nosec G115 always positive + _, err = ob.ZetacoreClient(). + PostVoteGasPrice(ctx, ob.Chain(), uint64(feeRateEstimated), priorityFee, uint64(blockNumber)) + if err != nil { + return errors.Wrap(err, "PostVoteGasPrice error") + } + + return nil +} + +// specialHandleFeeRate handles the fee rate for regnet and testnet +func (ob *Observer) specialHandleFeeRate() (int64, error) { + switch ob.Chain().NetworkType { + case chains.NetworkType_privnet: + // hardcode gas price for regnet + return 1, nil + case chains.NetworkType_testnet: + feeRateEstimated, err := bitcoin.GetRecentFeeRate(ob.btcClient, ob.netParams) + if err != nil { + return 0, errors.Wrapf(err, "error GetRecentFeeRate") + } + return feeRateEstimated, nil + default: + return 0, fmt.Errorf(" unsupported bitcoin network type %d", ob.Chain().NetworkType) + } +} diff --git a/zetaclient/chains/bitcoin/observer/mempool.go b/zetaclient/chains/bitcoin/observer/mempool.go new file mode 100644 index 0000000000..4777dca7ef --- /dev/null +++ b/zetaclient/chains/bitcoin/observer/mempool.go @@ -0,0 +1,149 @@ +package observer + +import ( + "context" + + "github.com/btcsuite/btcd/btcutil" + "github.com/pkg/errors" + + "github.com/zeta-chain/node/pkg/ticker" + "github.com/zeta-chain/node/zetaclient/chains/bitcoin/rpc" + "github.com/zeta-chain/node/zetaclient/common" + "github.com/zeta-chain/node/zetaclient/logs" +) + +// WatchMempoolTxs monitors pending outbound txs in the Bitcoin mempool. +func (ob *Observer) WatchMempoolTxs(ctx context.Context) error { + task := func(ctx context.Context, _ *ticker.Ticker) error { + if err := ob.checkLastStuckTx(ctx); err != nil { + ob.Logger().Chain.Err(err).Msg("checkLastStuckTx error") + } + return nil + } + + return ticker.Run( + ctx, + common.MempoolStuckTxCheckInterval, + task, + ticker.WithStopChan(ob.StopChannel()), + ticker.WithLogger(ob.Logger().Chain, "WatchMempoolTxs"), + ) +} + +// checkLastStuckTx checks the last stuck tx in the Bitcoin mempool. +func (ob *Observer) checkLastStuckTx(ctx context.Context) error { + // log fields + lf := map[string]any{ + logs.FieldMethod: "checkLastStuckTx", + } + + // step 1: get last TSS transaction + lastTx, lastNonce, err := ob.GetLastOutbound(ctx) + if err != nil { + return errors.Wrap(err, "GetLastOutbound failed") + } + txHash := lastTx.MsgTx().TxID() + lf[logs.FieldNonce] = lastNonce + lf[logs.FieldTx] = txHash + ob.logger.Outbound.Info().Fields(lf).Msg("checking last TSS outbound") + + // step 2: is last tx stuck in mempool? + stuck, stuckFor, err := rpc.IsTxStuckInMempool(ob.btcClient, txHash, rpc.PendingTxFeeBumpWaitBlocks) + if err != nil { + return errors.Wrapf(err, "cannot determine if tx %s nonce %d is stuck", txHash, lastNonce) + } + + // step 3: update outbound stuck flag + // + // the key ideas to determine if Bitcoin outbound is stuck/unstuck: + // 1. outbound txs are a sequence of txs chained by nonce-mark UTXOs. + // 2. outbound tx with nonce N+1 MUST spend the nonce-mark UTXO produced by parent tx with nonce N. + // 3. when the last descendant tx is stuck, none of its ancestor txs can go through, so the stuck flag is set. + // 4. then RBF kicks in, it bumps the fee of the last descendant tx and aims to increase the average fee + // rate of the whole tx chain (as a package) to make it attractive to miners. + // 5. after RBF replacement, zetaclient clears the stuck flag immediately, hoping the new tx will be included + // within next 'PendingTxFeeBumpWaitBlocks' blocks. + // 6. the new tx may get stuck again (e.g. surging traffic) after 'PendingTxFeeBumpWaitBlocks' blocks, and + // the stuck flag will be set again to trigger another RBF, and so on. + // 7. all pending txs will be eventually cleared by fee bumping, and the stuck flag will be cleared. + // + // Note: reserved RBF bumping fee might be not enough to clear the stuck txs during extreme traffic surges, two options: + // 1. wait for the gas rate to drop. + // 2. manually clear the stuck txs by using offline accelerator services. + stuckAlready := ob.IsOutboundStuck() + if stuck { + ob.logger.Outbound.Warn().Fields(lf).Msgf("Bitcoin outbound is stuck for %f minutes", stuckFor.Minutes()) + } + if !stuck && stuckAlready { + ob.logger.Outbound.Info().Fields(lf).Msgf("Bitcoin outbound is no longer stuck") + } + ob.setOutboundStuck(stuck) + + return nil +} + +// GetLastOutbound gets the last outbound (with highest nonce) that had been sent to Bitcoin network. +// Bitcoin outbound txs can be found from two sources: +// 1. txs that had been reported to tracker and then checked and included by this observer self. +// 2. txs that had been broadcasted by this observer self. +// +// Once 2/3+ of the observers reach consensus on last outbound, RBF will start. +func (ob *Observer) GetLastOutbound(ctx context.Context) (*btcutil.Tx, uint64, error) { + var ( + lastNonce uint64 + lastHash string + ) + + // wait for pending nonce to refresh + pendingNonce := ob.GetPendingNonce() + if ob.GetPendingNonce() == 0 { + return nil, 0, errors.New("pending nonce is zero") + } + + // source 1: + // pick highest nonce tx from included txs + lastNonce = pendingNonce - 1 + txResult := ob.getIncludedTx(lastNonce) + if txResult == nil { + // should NEVER happen by design + return nil, 0, errors.New("last included tx not found") + } + lastHash = txResult.TxID + + // source 2: + // pick highest nonce tx from broadcasted txs + p, err := ob.ZetacoreClient().GetPendingNoncesByChain(ctx, ob.Chain().ChainId) + if err != nil { + return nil, 0, errors.Wrap(err, "GetPendingNoncesByChain failed") + } + for nonce := uint64(p.NonceLow); nonce < uint64(p.NonceHigh); nonce++ { + if nonce > lastNonce { + txID, found := ob.getBroadcastedTx(nonce) + if found { + lastNonce = nonce + lastHash = txID + } + } + } + + // ensure this nonce is the REAL last transaction + // cross-check the latest UTXO list, the nonce-mark utxo exists ONLY for last nonce + if ob.FetchUTXOs(ctx) != nil { + return nil, 0, errors.New("FetchUTXOs failed") + } + if _, err = ob.findNonceMarkUTXO(lastNonce, lastHash); err != nil { + return nil, 0, errors.Wrapf(err, "findNonceMarkUTXO failed for last tx %s nonce %d", lastHash, lastNonce) + } + + // query last transaction + // 'GetRawTransaction' is preferred over 'GetTransaction' here for three reasons: + // 1. it can fetch both stuck tx and non-stuck tx as far as they are valid txs. + // 2. it never fetch invalid tx (e.g., old tx replaced by RBF), so we can exclude invalid ones. + // 3. zetaclient needs the original tx body of a stuck tx to bump its fee and sign again. + lastTx, err := rpc.GetRawTxByHash(ob.btcClient, lastHash) + if err != nil { + return nil, 0, errors.Wrapf(err, "GetRawTxByHash failed for last tx %s nonce %d", lastHash, lastNonce) + } + + return lastTx, lastNonce, nil +} diff --git a/zetaclient/chains/bitcoin/observer/observer.go b/zetaclient/chains/bitcoin/observer/observer.go index 1815313794..2a1522242d 100644 --- a/zetaclient/chains/bitcoin/observer/observer.go +++ b/zetaclient/chains/bitcoin/observer/observer.go @@ -3,14 +3,9 @@ package observer import ( "context" - "fmt" - "math" "math/big" - "sort" - "strings" "github.com/btcsuite/btcd/btcjson" - "github.com/btcsuite/btcd/btcutil" "github.com/btcsuite/btcd/chaincfg" "github.com/btcsuite/btcd/wire" "github.com/pkg/errors" @@ -20,11 +15,9 @@ import ( "github.com/zeta-chain/node/pkg/chains" observertypes "github.com/zeta-chain/node/x/observer/types" "github.com/zeta-chain/node/zetaclient/chains/base" - "github.com/zeta-chain/node/zetaclient/chains/bitcoin" "github.com/zeta-chain/node/zetaclient/chains/interfaces" "github.com/zeta-chain/node/zetaclient/db" "github.com/zeta-chain/node/zetaclient/metrics" - clienttypes "github.com/zeta-chain/node/zetaclient/types" ) const ( @@ -72,6 +65,9 @@ type Observer struct { // pendingNonce is the outbound artificial pending nonce pendingNonce uint64 + // outboundStuck is the flag to indicate if the outbound is stuck in the mempool + outboundStuck bool + // utxos contains the UTXOs owned by the TSS address utxos []btcjson.ListUnspentResult @@ -128,7 +124,6 @@ func NewObserver( Observer: *baseObserver, netParams: netParams, btcClient: btcClient, - pendingNonce: 0, utxos: []btcjson.ListUnspentResult{}, includedTxHashes: make(map[string]bool), includedTxResults: make(map[string]*btcjson.GetTransactionResult), @@ -180,6 +175,9 @@ func (ob *Observer) Start(ctx context.Context) { // watch bitcoin chain for UTXOs owned by the TSS address bg.Work(ctx, ob.WatchUTXOs, bg.WithName("WatchUTXOs"), bg.WithLogger(ob.Logger().Outbound)) + // watch bitcoin chain for pending mempool txs + bg.Work(ctx, ob.WatchMempoolTxs, bg.WithName("WatchMempoolTxs"), bg.WithLogger(ob.Logger().Outbound)) + // watch bitcoin chain for gas rate and post to zetacore bg.Work(ctx, ob.WatchGasPrice, bg.WithName("WatchGasPrice"), bg.WithLogger(ob.Logger().GasPrice)) @@ -211,208 +209,6 @@ func (ob *Observer) ConfirmationsThreshold(amount *big.Int) int64 { return int64(ob.ChainParams().ConfirmationCount) } -// WatchGasPrice watches Bitcoin chain for gas rate and post to zetacore -// TODO(revamp): move ticker related functions to a specific file -// TODO(revamp): move inner logic in a separate function -func (ob *Observer) WatchGasPrice(ctx context.Context) error { - // report gas price right away as the ticker takes time to kick in - err := ob.PostGasPrice(ctx) - if err != nil { - ob.logger.GasPrice.Error().Err(err).Msgf("PostGasPrice error for chain %d", ob.Chain().ChainId) - } - - // start gas price ticker - ticker, err := clienttypes.NewDynamicTicker("Bitcoin_WatchGasPrice", ob.ChainParams().GasPriceTicker) - if err != nil { - return errors.Wrapf(err, "NewDynamicTicker error") - } - ob.logger.GasPrice.Info().Msgf("WatchGasPrice started for chain %d with interval %d", - ob.Chain().ChainId, ob.ChainParams().GasPriceTicker) - - defer ticker.Stop() - for { - select { - case <-ticker.C(): - if !ob.ChainParams().IsSupported { - continue - } - err := ob.PostGasPrice(ctx) - if err != nil { - ob.logger.GasPrice.Error().Err(err).Msgf("PostGasPrice error for chain %d", ob.Chain().ChainId) - } - ticker.UpdateInterval(ob.ChainParams().GasPriceTicker, ob.logger.GasPrice) - case <-ob.StopChannel(): - ob.logger.GasPrice.Info().Msgf("WatchGasPrice stopped for chain %d", ob.Chain().ChainId) - return nil - } - } -} - -// PostGasPrice posts gas price to zetacore -// TODO(revamp): move to gas price file -func (ob *Observer) PostGasPrice(ctx context.Context) error { - var ( - err error - feeRateEstimated uint64 - ) - - // special handle regnet and testnet gas rate - // regnet: RPC 'EstimateSmartFee' is not available - // testnet: RPC 'EstimateSmartFee' returns unreasonable high gas rate - if ob.Chain().NetworkType != chains.NetworkType_mainnet { - feeRateEstimated, err = ob.specialHandleFeeRate() - if err != nil { - return errors.Wrap(err, "unable to execute specialHandleFeeRate") - } - } else { - // EstimateSmartFee returns the fees per kilobyte (BTC/kb) targeting given block confirmation - feeResult, err := ob.btcClient.EstimateSmartFee(1, &btcjson.EstimateModeEconomical) - if err != nil { - return errors.Wrap(err, "unable to estimate smart fee") - } - if feeResult.Errors != nil || feeResult.FeeRate == nil { - return fmt.Errorf("error getting gas price: %s", feeResult.Errors) - } - if *feeResult.FeeRate > math.MaxInt64 { - return fmt.Errorf("gas price is too large: %f", *feeResult.FeeRate) - } - feeRateEstimated = bitcoin.FeeRateToSatPerByte(*feeResult.FeeRate).Uint64() - } - - // query the current block number - blockNumber, err := ob.btcClient.GetBlockCount() - if err != nil { - return errors.Wrap(err, "GetBlockCount error") - } - - // Bitcoin has no concept of priority fee (like eth) - const priorityFee = 0 - - // #nosec G115 always positive - _, err = ob.ZetacoreClient().PostVoteGasPrice(ctx, ob.Chain(), feeRateEstimated, priorityFee, uint64(blockNumber)) - if err != nil { - return errors.Wrap(err, "PostVoteGasPrice error") - } - - return nil -} - -// WatchUTXOs watches bitcoin chain for UTXOs owned by the TSS address -// TODO(revamp): move ticker related functions to a specific file -func (ob *Observer) WatchUTXOs(ctx context.Context) error { - ticker, err := clienttypes.NewDynamicTicker("Bitcoin_WatchUTXOs", ob.ChainParams().WatchUtxoTicker) - if err != nil { - ob.logger.UTXOs.Error().Err(err).Msg("error creating ticker") - return err - } - - defer ticker.Stop() - for { - select { - case <-ticker.C(): - if !ob.ChainParams().IsSupported { - continue - } - err := ob.FetchUTXOs(ctx) - if err != nil { - // log debug log if the error if no wallet is loaded - // this is to prevent extensive logging in localnet when the wallet is not loaded for non-Bitcoin test - // TODO: prevent this routine from running if Bitcoin node is not enabled - // https://github.com/zeta-chain/node/issues/2790 - if !strings.Contains(err.Error(), "No wallet is loaded") { - ob.logger.UTXOs.Error().Err(err).Msg("error fetching btc utxos") - } else { - ob.logger.UTXOs.Debug().Err(err).Msg("No wallet is loaded") - } - } - ticker.UpdateInterval(ob.ChainParams().WatchUtxoTicker, ob.logger.UTXOs) - case <-ob.StopChannel(): - ob.logger.UTXOs.Info().Msgf("WatchUTXOs stopped for chain %d", ob.Chain().ChainId) - return nil - } - } -} - -// FetchUTXOs fetches TSS-owned UTXOs from the Bitcoin node -// TODO(revamp): move to UTXO file -func (ob *Observer) FetchUTXOs(ctx context.Context) error { - defer func() { - if err := recover(); err != nil { - ob.logger.UTXOs.Error().Msgf("BTC FetchUTXOs: caught panic error: %v", err) - } - }() - - // This is useful when a zetaclient's pending nonce lagged behind for whatever reason. - ob.refreshPendingNonce(ctx) - - // get the current block height. - bh, err := ob.btcClient.GetBlockCount() - if err != nil { - return fmt.Errorf("btc: error getting block height : %v", err) - } - maxConfirmations := int(bh) - - // List all unspent UTXOs (160ms) - tssAddr, err := ob.TSS().PubKey().AddressBTC(ob.Chain().ChainId) - if err != nil { - return fmt.Errorf("error getting bitcoin tss address") - } - utxos, err := ob.btcClient.ListUnspentMinMaxAddresses(0, maxConfirmations, []btcutil.Address{tssAddr}) - if err != nil { - return err - } - - // rigid sort to make utxo list deterministic - sort.SliceStable(utxos, func(i, j int) bool { - if utxos[i].Amount == utxos[j].Amount { - if utxos[i].TxID == utxos[j].TxID { - return utxos[i].Vout < utxos[j].Vout - } - return utxos[i].TxID < utxos[j].TxID - } - return utxos[i].Amount < utxos[j].Amount - }) - - // filter UTXOs good to spend for next TSS transaction - utxosFiltered := make([]btcjson.ListUnspentResult, 0) - for _, utxo := range utxos { - // UTXOs big enough to cover the cost of spending themselves - if utxo.Amount < bitcoin.DefaultDepositorFee { - continue - } - // we don't want to spend other people's unconfirmed UTXOs as they may not be safe to spend - if utxo.Confirmations == 0 { - if !ob.isTssTransaction(utxo.TxID) { - continue - } - } - utxosFiltered = append(utxosFiltered, utxo) - } - - ob.Mu().Lock() - ob.TelemetryServer().SetNumberOfUTXOs(len(utxosFiltered)) - ob.utxos = utxosFiltered - ob.Mu().Unlock() - return nil -} - -// SaveBroadcastedTx saves successfully broadcasted transaction -// TODO(revamp): move to db file -func (ob *Observer) SaveBroadcastedTx(txHash string, nonce uint64) { - outboundID := ob.OutboundID(nonce) - ob.Mu().Lock() - ob.broadcastedTx[outboundID] = txHash - ob.Mu().Unlock() - - broadcastEntry := clienttypes.ToOutboundHashSQLType(txHash, outboundID) - if err := ob.DB().Client().Save(&broadcastEntry).Error; err != nil { - ob.logger.Outbound.Error(). - Err(err). - Msgf("SaveBroadcastedTx: error saving broadcasted txHash %s for outbound %s", txHash, outboundID) - } - ob.logger.Outbound.Info().Msgf("SaveBroadcastedTx: saved broadcasted txHash %s for outbound %s", txHash, outboundID) -} - // GetBlockByNumberCached gets cached block (and header) by block number func (ob *Observer) GetBlockByNumberCached(blockNumber int64) (*BTCBlockNHeader, error) { if result, ok := ob.BlockCache().Get(blockNumber); ok { @@ -446,67 +242,40 @@ func (ob *Observer) GetBlockByNumberCached(blockNumber int64) (*BTCBlockNHeader, return blockNheader, nil } -// LoadLastBlockScanned loads the last scanned block from the database -func (ob *Observer) LoadLastBlockScanned() error { - err := ob.Observer.LoadLastBlockScanned(ob.Logger().Chain) - if err != nil { - return errors.Wrapf(err, "error LoadLastBlockScanned for chain %d", ob.Chain().ChainId) - } - - // observer will scan from the last block when 'lastBlockScanned == 0', this happens when: - // 1. environment variable is set explicitly to "latest" - // 2. environment variable is empty and last scanned block is not found in DB - if ob.LastBlockScanned() == 0 { - blockNumber, err := ob.btcClient.GetBlockCount() - if err != nil { - return errors.Wrapf(err, "error GetBlockCount for chain %d", ob.Chain().ChainId) - } - // #nosec G115 always positive - ob.WithLastBlockScanned(uint64(blockNumber)) - } - - // bitcoin regtest starts from hardcoded block 100 - if chains.IsBitcoinRegnet(ob.Chain().ChainId) { - ob.WithLastBlockScanned(RegnetStartBlock) - } - ob.Logger().Chain.Info().Msgf("chain %d starts scanning from block %d", ob.Chain().ChainId, ob.LastBlockScanned()) +// IsOutboundStuck returns true if the outbound is stuck in the mempool +func (ob *Observer) IsOutboundStuck() bool { + ob.Mu().Lock() + defer ob.Mu().Unlock() + return ob.outboundStuck +} - return nil +// isTSSTransaction checks if a given transaction was sent by TSS itself. +// An unconfirmed transaction is safe to spend only if it was sent by TSS and verified by ourselves. +func (ob *Observer) isTSSTransaction(txid string) bool { + _, found := ob.includedTxHashes[txid] + return found } -// LoadBroadcastedTxMap loads broadcasted transactions from the database -func (ob *Observer) LoadBroadcastedTxMap() error { - var broadcastedTransactions []clienttypes.OutboundHashSQLType - if err := ob.DB().Client().Find(&broadcastedTransactions).Error; err != nil { - ob.logger.Chain.Error().Err(err).Msgf("error iterating over db for chain %d", ob.Chain().ChainId) - return err - } - for _, entry := range broadcastedTransactions { - ob.broadcastedTx[entry.Key] = entry.Hash - } - return nil +// setPendingNonce sets the artificial pending nonce +func (ob *Observer) setPendingNonce(nonce uint64) { + ob.Mu().Lock() + defer ob.Mu().Unlock() + ob.pendingNonce = nonce } -// specialHandleFeeRate handles the fee rate for regnet and testnet -func (ob *Observer) specialHandleFeeRate() (uint64, error) { - switch ob.Chain().NetworkType { - case chains.NetworkType_privnet: - // hardcode gas price for regnet - return 1, nil - case chains.NetworkType_testnet: - feeRateEstimated, err := bitcoin.GetRecentFeeRate(ob.btcClient, ob.netParams) - if err != nil { - return 0, errors.Wrapf(err, "error GetRecentFeeRate") - } - return feeRateEstimated, nil - default: - return 0, fmt.Errorf(" unsupported bitcoin network type %d", ob.Chain().NetworkType) - } +// setOutboundStuck sets the outbound stuck flag +func (ob *Observer) setOutboundStuck(stuck bool) { + ob.Mu().Lock() + defer ob.Mu().Unlock() + ob.outboundStuck = stuck } -// isTssTransaction checks if a given transaction was sent by TSS itself. -// An unconfirmed transaction is safe to spend only if it was sent by TSS and verified by ourselves. -func (ob *Observer) isTssTransaction(txid string) bool { - _, found := ob.includedTxHashes[txid] - return found +// getBroadcastedTx gets successfully broadcasted transaction by nonce +func (ob *Observer) getBroadcastedTx(nonce uint64) (string, bool) { + ob.Mu().Lock() + defer ob.Mu().Unlock() + + outboundID := ob.OutboundID(nonce) + txHash, found := ob.broadcastedTx[outboundID] + return txHash, found } diff --git a/zetaclient/chains/bitcoin/observer/outbound.go b/zetaclient/chains/bitcoin/observer/outbound.go index 2e0f3dd9b1..ab56c4acd0 100644 --- a/zetaclient/chains/bitcoin/observer/outbound.go +++ b/zetaclient/chains/bitcoin/observer/outbound.go @@ -20,6 +20,7 @@ import ( "github.com/zeta-chain/node/zetaclient/chains/interfaces" "github.com/zeta-chain/node/zetaclient/compliance" zctx "github.com/zeta-chain/node/zetaclient/context" + "github.com/zeta-chain/node/zetaclient/logs" "github.com/zeta-chain/node/zetaclient/types" "github.com/zeta-chain/node/zetaclient/zetacore" ) @@ -28,98 +29,116 @@ import ( // TODO(revamp): move ticker functions to a specific file // TODO(revamp): move into a separate package func (ob *Observer) WatchOutbound(ctx context.Context) error { + // get app context app, err := zctx.FromContext(ctx) if err != nil { return errors.Wrap(err, "unable to get app from context") } + // create outbound ticker ticker, err := types.NewDynamicTicker("Bitcoin_WatchOutbound", ob.ChainParams().OutboundTicker) if err != nil { return errors.Wrap(err, "unable to create dynamic ticker") } - defer ticker.Stop() - chainID := ob.Chain().ChainId - ob.logger.Outbound.Info().Msgf("WatchOutbound started for chain %d", chainID) + ob.logger.Outbound.Info().Msg("WatchOutbound: started") sampledLogger := ob.logger.Outbound.Sample(&zerolog.BasicSampler{N: 10}) for { select { case <-ticker.C(): if !app.IsOutboundObservationEnabled() { - sampledLogger.Info(). - Msgf("WatchOutbound: outbound observation is disabled for chain %d", chainID) + sampledLogger.Info().Msg("WatchOutbound: outbound observation is disabled") continue } - trackers, err := ob.ZetacoreClient().GetAllOutboundTrackerByChain(ctx, chainID, interfaces.Ascending) + + // process outbound trackers + err := ob.ProcessOutboundTrackers(ctx) if err != nil { - ob.logger.Outbound.Error(). - Err(err). - Msgf("WatchOutbound: error GetAllOutboundTrackerByChain for chain %d", chainID) - continue - } - for _, tracker := range trackers { - // get original cctx parameters - outboundID := ob.OutboundID(tracker.Nonce) - cctx, err := ob.ZetacoreClient().GetCctxByNonce(ctx, chainID, tracker.Nonce) - if err != nil { - ob.logger.Outbound.Info(). - Err(err). - Msgf("WatchOutbound: can't find cctx for chain %d nonce %d", chainID, tracker.Nonce) - break - } - - nonce := cctx.GetCurrentOutboundParam().TssNonce - if tracker.Nonce != nonce { // Tanmay: it doesn't hurt to check - ob.logger.Outbound.Error(). - Msgf("WatchOutbound: tracker nonce %d not match cctx nonce %d", tracker.Nonce, nonce) - break - } - - if len(tracker.HashList) > 1 { - ob.logger.Outbound.Warn(). - Msgf("WatchOutbound: oops, outboundID %s got multiple (%d) outbound hashes", outboundID, len(tracker.HashList)) - } - - // iterate over all txHashes to find the truly included one. - // we do it this (inefficient) way because we don't rely on the first one as it may be a false positive (for unknown reason). - txCount := 0 - var txResult *btcjson.GetTransactionResult - for _, txHash := range tracker.HashList { - result, inMempool := ob.checkIncludedTx(ctx, cctx, txHash.TxHash) - if result != nil && !inMempool { // included - txCount++ - txResult = result - ob.logger.Outbound.Info(). - Msgf("WatchOutbound: included outbound %s for chain %d nonce %d", txHash.TxHash, chainID, tracker.Nonce) - if txCount > 1 { - ob.logger.Outbound.Error().Msgf( - "WatchOutbound: checkIncludedTx passed, txCount %d chain %d nonce %d result %v", txCount, chainID, tracker.Nonce, result) - } - } - } - - if txCount == 1 { // should be only one txHash included for each nonce - ob.setIncludedTx(tracker.Nonce, txResult) - } else if txCount > 1 { - ob.removeIncludedTx(tracker.Nonce) // we can't tell which txHash is true, so we remove all (if any) to be safe - ob.logger.Outbound.Error().Msgf("WatchOutbound: included multiple (%d) outbound for chain %d nonce %d", txCount, chainID, tracker.Nonce) - } + ob.Logger().Outbound.Error().Err(err).Msg("WatchOutbound: ProcessOutboundTrackers failed") } + ticker.UpdateInterval(ob.ChainParams().OutboundTicker, ob.logger.Outbound) case <-ob.StopChannel(): - ob.logger.Outbound.Info().Msgf("WatchOutbound stopped for chain %d", chainID) + ob.logger.Outbound.Info().Msg("WatchOutbound: stopped") return nil } } } -// VoteOutboundIfConfirmed checks outbound status and returns (continueKeysign, error) -func (ob *Observer) VoteOutboundIfConfirmed( +// ProcessOutboundTrackers processes outbound trackers +func (ob *Observer) ProcessOutboundTrackers(ctx context.Context) error { + chainID := ob.Chain().ChainId + trackers, err := ob.ZetacoreClient().GetAllOutboundTrackerByChain(ctx, chainID, interfaces.Ascending) + if err != nil { + return errors.Wrap(err, "GetAllOutboundTrackerByChain failed") + } + + // logger fields + lf := map[string]any{ + logs.FieldMethod: "ProcessOutboundTrackers", + } + + // process outbound trackers + for _, tracker := range trackers { + // set logger fields + lf[logs.FieldNonce] = tracker.Nonce + + // get the CCTX + cctx, err := ob.ZetacoreClient().GetCctxByNonce(ctx, chainID, tracker.Nonce) + if err != nil { + ob.logger.Outbound.Err(err).Fields(lf).Msg("cannot find cctx") + break + } + if len(tracker.HashList) > 1 { + ob.logger.Outbound.Warn().Msgf("oops, got multiple (%d) outbound hashes", len(tracker.HashList)) + } + + // Iterate over all txHashes to find the truly included outbound. + // At any time, there is guarantee that only one single txHash will be considered valid and included for each nonce. + // The reasons are: + // 1. CCTX with nonce 'N = 0' is the past and well-controlled. + // 2. Given any CCTX with nonce 'N > 0', its outbound MUST spend the previous nonce-mark UTXO (nonce N-1) to be considered valid. + // 3. Bitcoin prevents double spending of the same UTXO except for RBF. + // 4. When RBF happens, the original tx will be removed from Bitcoin core, and only the new tx will be valid. + for _, txHash := range tracker.HashList { + _, included := ob.TryIncludeOutbound(ctx, cctx, txHash.TxHash) + if included { + break + } + } + } + + return nil +} + +// TryIncludeOutbound tries to include an outbound for the given cctx and txHash. +// +// Due to 10-min block time, zetaclient observes outbounds both in mempool and in blocks. +// An outbound is considered included if it satisfies one of the following two cases: +// 1. a valid tx pending in mempool with confirmation == 0 +// 2. a valid tx included in a block with confirmation > 0 +// +// Returns: (txResult, included) +func (ob *Observer) TryIncludeOutbound( ctx context.Context, cctx *crosschaintypes.CrossChainTx, -) (bool, error) { + txHash string, +) (*btcjson.GetTransactionResult, bool) { + nonce := cctx.GetCurrentOutboundParam().TssNonce + + // check tx inclusion and save tx result + txResult, included := ob.checkTxInclusion(ctx, cctx, txHash) + if included { + ob.setIncludedTx(nonce, txResult) + } + + return txResult, included +} + +// VoteOutboundIfConfirmed checks outbound status and returns (continueKeysign, error) +func (ob *Observer) VoteOutboundIfConfirmed(ctx context.Context, cctx *crosschaintypes.CrossChainTx) (bool, error) { const ( // not used with Bitcoin outboundGasUsed = 0 @@ -142,6 +161,9 @@ func (ob *Observer) VoteOutboundIfConfirmed( res, included := ob.includedTxResults[outboundID] ob.Mu().Unlock() + // Short-circuit in following two cases: + // 1. Outbound neither broadcasted nor included. It requires a keysign. + // 2. Outbound was broadcasted for nonce 0. It's an edge case (happened before) to avoid duplicate payments. if !included { if !broadcasted { return true, nil @@ -156,26 +178,15 @@ func (ob *Observer) VoteOutboundIfConfirmed( return false, nil } - // Try including this outbound broadcasted by myself - txResult, inMempool := ob.checkIncludedTx(ctx, cctx, txnHash) - if txResult == nil { // check failed, try again next time - return true, nil - } else if inMempool { // still in mempool (should avoid unnecessary Tss keysign) - ob.logger.Outbound.Info().Msgf("VoteOutboundIfConfirmed: outbound %s is still in mempool", outboundID) - return false, nil - } - // included - ob.setIncludedTx(nonce, txResult) - - // Get tx result again in case it is just included - res = ob.getIncludedTx(nonce) - if res == nil { + // Try including this outbound broadcasted by myself to supplement outbound trackers. + // Note: each Bitcoin outbound usually gets included right after broadcasting. + res, included = ob.TryIncludeOutbound(ctx, cctx, txnHash) + if !included { return true, nil } - ob.logger.Outbound.Info().Msgf("VoteOutboundIfConfirmed: setIncludedTx succeeded for outbound %s", outboundID) } - // It's safe to use cctx's amount to post confirmation because it has already been verified in observeOutbound() + // It's safe to use cctx's amount to post confirmation because it has already been verified in checkTxInclusion(). amountInSat := params.Amount.BigInt() if res.Confirmations < ob.ConfirmationsThreshold(amountInSat) { ob.logger.Outbound.Debug(). @@ -244,105 +255,6 @@ func (ob *Observer) VoteOutboundIfConfirmed( return false, nil } -// SelectUTXOs selects a sublist of utxos to be used as inputs. -// -// Parameters: -// - amount: The desired minimum total value of the selected UTXOs. -// - utxos2Spend: The maximum number of UTXOs to spend. -// - nonce: The nonce of the outbound transaction. -// - consolidateRank: The rank below which UTXOs will be consolidated. -// - test: true for unit test only. -// -// Returns: -// - a sublist (includes previous nonce-mark) of UTXOs or an error if the qualifying sublist cannot be found. -// - the total value of the selected UTXOs. -// - the number of consolidated UTXOs. -// - the total value of the consolidated UTXOs. -// -// TODO(revamp): move to utxo file -func (ob *Observer) SelectUTXOs( - ctx context.Context, - amount float64, - utxosToSpend uint16, - nonce uint64, - consolidateRank uint16, - test bool, -) ([]btcjson.ListUnspentResult, float64, uint16, float64, error) { - idx := -1 - if nonce == 0 { - // for nonce = 0; make exception; no need to include nonce-mark utxo - ob.Mu().Lock() - defer ob.Mu().Unlock() - } else { - // for nonce > 0; we proceed only when we see the nonce-mark utxo - preTxid, err := ob.getOutboundIDByNonce(ctx, nonce-1, test) - if err != nil { - return nil, 0, 0, 0, err - } - ob.Mu().Lock() - defer ob.Mu().Unlock() - idx, err = ob.findNonceMarkUTXO(nonce-1, preTxid) - if err != nil { - return nil, 0, 0, 0, err - } - } - - // select smallest possible UTXOs to make payment - total := 0.0 - left, right := 0, 0 - for total < amount && right < len(ob.utxos) { - if utxosToSpend > 0 { // expand sublist - total += ob.utxos[right].Amount - right++ - utxosToSpend-- - } else { // pop the smallest utxo and append the current one - total -= ob.utxos[left].Amount - total += ob.utxos[right].Amount - left++ - right++ - } - } - results := make([]btcjson.ListUnspentResult, right-left) - copy(results, ob.utxos[left:right]) - - // include nonce-mark as the 1st input - if idx >= 0 { // for nonce > 0 - if idx < left || idx >= right { - total += ob.utxos[idx].Amount - results = append([]btcjson.ListUnspentResult{ob.utxos[idx]}, results...) - } else { // move nonce-mark to left - for i := idx - left; i > 0; i-- { - results[i], results[i-1] = results[i-1], results[i] - } - } - } - if total < amount { - return nil, 0, 0, 0, fmt.Errorf( - "SelectUTXOs: not enough btc in reserve - available : %v , tx amount : %v", - total, - amount, - ) - } - - // consolidate biggest possible UTXOs to maximize consolidated value - // consolidation happens only when there are more than (or equal to) consolidateRank (10) UTXOs - utxoRank, consolidatedUtxo, consolidatedValue := uint16(0), uint16(0), 0.0 - for i := len(ob.utxos) - 1; i >= 0 && utxosToSpend > 0; i-- { // iterate over UTXOs big-to-small - if i != idx && (i < left || i >= right) { // exclude nonce-mark and already selected UTXOs - utxoRank++ - if utxoRank >= consolidateRank { // consolication starts from the 10-ranked UTXO based on value - utxosToSpend-- - consolidatedUtxo++ - total += ob.utxos[i].Amount - consolidatedValue += ob.utxos[i].Amount - results = append(results, ob.utxos[i]) - } - } - } - - return results, total, consolidatedUtxo, consolidatedValue, nil -} - // refreshPendingNonce tries increasing the artificial pending nonce of outbound (if lagged behind). // There could be many (unpredictable) reasons for a pending nonce lagging behind, for example: // 1. The zetaclient gets restarted. @@ -355,31 +267,25 @@ func (ob *Observer) refreshPendingNonce(ctx context.Context) { } // increase pending nonce if lagged behind - ob.Mu().Lock() - pendingNonce := ob.pendingNonce - ob.Mu().Unlock() - // #nosec G115 always non-negative nonceLow := uint64(p.NonceLow) - if nonceLow > pendingNonce { + if nonceLow > ob.GetPendingNonce() { // get the last included outbound hash - txid, err := ob.getOutboundIDByNonce(ctx, nonceLow-1, false) + txid, err := ob.getOutboundHashByNonce(ctx, nonceLow-1, false) if err != nil { ob.logger.Chain.Error().Err(err).Msg("refreshPendingNonce: error getting last outbound txid") } // set 'NonceLow' as the new pending nonce - ob.Mu().Lock() - defer ob.Mu().Unlock() - ob.pendingNonce = nonceLow + ob.setPendingNonce(nonceLow) ob.logger.Chain.Info(). - Msgf("refreshPendingNonce: increase pending nonce to %d with txid %s", ob.pendingNonce, txid) + Msgf("refreshPendingNonce: increase pending nonce to %d with txid %s", nonceLow, txid) } } -// getOutboundIDByNonce gets the outbound ID from the nonce of the outbound transaction +// getOutboundHashByNonce gets the outbound hash for given nonce. // test is true for unit test only -func (ob *Observer) getOutboundIDByNonce(ctx context.Context, nonce uint64, test bool) (string, error) { +func (ob *Observer) getOutboundHashByNonce(ctx context.Context, nonce uint64, test bool) (string, error) { // There are 2 types of txids an observer can trust // 1. The ones had been verified and saved by observer self. // 2. The ones had been finalized in zetacore based on majority vote. @@ -413,82 +319,85 @@ func (ob *Observer) getOutboundIDByNonce(ctx context.Context, nonce uint64, test return "", fmt.Errorf("getOutboundIDByNonce: cannot find outbound txid for nonce %d", nonce) } -// findNonceMarkUTXO finds the nonce-mark UTXO in the list of UTXOs. -func (ob *Observer) findNonceMarkUTXO(nonce uint64, txid string) (int, error) { - tssAddress := ob.TSSAddressString() - amount := chains.NonceMarkAmount(nonce) - for i, utxo := range ob.utxos { - sats, err := bitcoin.GetSatoshis(utxo.Amount) - if err != nil { - ob.logger.Outbound.Error().Err(err).Msgf("findNonceMarkUTXO: error getting satoshis for utxo %v", utxo) - } - if utxo.Address == tssAddress && sats == amount && utxo.TxID == txid && utxo.Vout == 0 { - ob.logger.Outbound.Info(). - Msgf("findNonceMarkUTXO: found nonce-mark utxo with txid %s, amount %d satoshi", utxo.TxID, sats) - return i, nil - } - } - return -1, fmt.Errorf("findNonceMarkUTXO: cannot find nonce-mark utxo with nonce %d", nonce) -} - -// checkIncludedTx checks if a txHash is included and returns (txResult, inMempool) -// Note: if txResult is nil, then inMempool flag should be ignored. -func (ob *Observer) checkIncludedTx( +// checkTxInclusion checks if a txHash is included and returns (txResult, included) +// +// Note: a 'included' tx may still be considered stuck if it's in mempool for too long. +func (ob *Observer) checkTxInclusion( ctx context.Context, cctx *crosschaintypes.CrossChainTx, txHash string, ) (*btcjson.GetTransactionResult, bool) { - outboundID := ob.OutboundID(cctx.GetCurrentOutboundParam().TssNonce) - hash, getTxResult, err := rpc.GetTxResultByHash(ob.btcClient, txHash) + // logger fields + lf := map[string]any{ + logs.FieldMethod: "checkTxInclusion", + logs.FieldNonce: cctx.GetCurrentOutboundParam().TssNonce, + logs.FieldTx: txHash, + } + + // fetch tx result + hash, txResult, err := rpc.GetTxResultByHash(ob.btcClient, txHash) if err != nil { - ob.logger.Outbound.Error().Err(err).Msgf("checkIncludedTx: error GetTxResultByHash: %s", txHash) + ob.logger.Outbound.Warn().Err(err).Fields(lf).Msg("GetTxResultByHash failed") return nil, false } - if txHash != getTxResult.TxID { // just in case, we'll use getTxResult.TxID later - ob.logger.Outbound.Error(). - Msgf("checkIncludedTx: inconsistent txHash %s and getTxResult.TxID %s", txHash, getTxResult.TxID) + // validate tx result + err = ob.checkTssOutboundResult(ctx, cctx, hash, txResult) + if err != nil { + ob.logger.Outbound.Error().Err(err).Fields(lf).Msg("checkTssOutboundResult failed") return nil, false } - if getTxResult.Confirmations >= 0 { // check included tx only - err = ob.checkTssOutboundResult(ctx, cctx, hash, getTxResult) - if err != nil { - ob.logger.Outbound.Error(). - Err(err). - Msgf("checkIncludedTx: error verify bitcoin outbound %s outboundID %s", txHash, outboundID) - return nil, false - } - return getTxResult, false // included - } - return getTxResult, true // in mempool + // tx is valid and included + return txResult, true } -// setIncludedTx saves included tx result in memory +// setIncludedTx saves included tx result in memory. +// - the outbounds are chained (by nonce) txs sequentially included. +// - tx results may still be set in arbitrary order as the method is called across goroutines, and it doesn't matter. func (ob *Observer) setIncludedTx(nonce uint64, getTxResult *btcjson.GetTransactionResult) { - txHash := getTxResult.TxID - outboundID := ob.OutboundID(nonce) + var ( + txHash = getTxResult.TxID + outboundID = ob.OutboundID(nonce) + lf = map[string]any{ + logs.FieldMethod: "setIncludedTx", + logs.FieldNonce: nonce, + logs.FieldTx: txHash, + logs.FieldOutboundID: outboundID, + } + ) ob.Mu().Lock() defer ob.Mu().Unlock() res, found := ob.includedTxResults[outboundID] - if !found { // not found. + if !found { + // for new hash: + // - include new outbound and enforce rigid 1-to-1 mapping: nonce <===> txHash + // - try increasing pending nonce on every newly included outbound ob.includedTxHashes[txHash] = true - ob.includedTxResults[outboundID] = getTxResult // include new outbound and enforce rigid 1-to-1 mapping: nonce <===> txHash - if nonce >= ob.pendingNonce { // try increasing pending nonce on every newly included outbound + ob.includedTxResults[outboundID] = getTxResult + if nonce >= ob.pendingNonce { ob.pendingNonce = nonce + 1 } - ob.logger.Outbound.Info(). - Msgf("setIncludedTx: included new bitcoin outbound %s outboundID %s pending nonce %d", txHash, outboundID, ob.pendingNonce) - } else if txHash == res.TxID { // found same hash - ob.includedTxResults[outboundID] = getTxResult // update tx result as confirmations may increase + ob.logger.Outbound.Info().Fields(lf).Msgf("included new bitcoin outbound, pending nonce %d", ob.pendingNonce) + } else if txHash == res.TxID { + // for existing hash: + // - update tx result because confirmations may increase + ob.includedTxResults[outboundID] = getTxResult if getTxResult.Confirmations > res.Confirmations { - ob.logger.Outbound.Info().Msgf("setIncludedTx: bitcoin outbound %s got confirmations %d", txHash, getTxResult.Confirmations) + ob.logger.Outbound.Info().Msgf("bitcoin outbound got %d confirmations", getTxResult.Confirmations) } - } else { // found other hash. - // be alert for duplicate payment!!! As we got a new hash paying same cctx (for whatever reason). - delete(ob.includedTxResults, outboundID) // we can't tell which txHash is true, so we remove all to be safe - ob.logger.Outbound.Error().Msgf("setIncludedTx: duplicate payment by bitcoin outbound %s outboundID %s, prior outbound %s", txHash, outboundID, res.TxID) + } else { + // got multiple hashes for same nonce. RBF happened. + ob.logger.Outbound.Info().Fields(lf).Msgf("replaced bitcoin outbound %s", res.TxID) + + // remove prior txHash and txResult + delete(ob.includedTxHashes, res.TxID) + delete(ob.includedTxResults, outboundID) + + // add new txHash and txResult + ob.includedTxHashes[txHash] = true + ob.includedTxResults[outboundID] = getTxResult } } @@ -499,18 +408,8 @@ func (ob *Observer) getIncludedTx(nonce uint64) *btcjson.GetTransactionResult { return ob.includedTxResults[ob.OutboundID(nonce)] } -// removeIncludedTx removes included tx from memory -func (ob *Observer) removeIncludedTx(nonce uint64) { - ob.Mu().Lock() - defer ob.Mu().Unlock() - txResult, found := ob.includedTxResults[ob.OutboundID(nonce)] - if found { - delete(ob.includedTxHashes, txResult.TxID) - delete(ob.includedTxResults, ob.OutboundID(nonce)) - } -} - // Basic TSS outbound checks: +// - confirmations >= 0 // - should be able to query the raw tx // - check if all inputs are segwit && TSS inputs // @@ -521,6 +420,11 @@ func (ob *Observer) checkTssOutboundResult( hash *chainhash.Hash, res *btcjson.GetTransactionResult, ) error { + // negative confirmation means invalid tx, return error + if res.Confirmations < 0 { + return fmt.Errorf("checkTssOutboundResult: negative confirmations %d", res.Confirmations) + } + params := cctx.GetCurrentOutboundParam() nonce := params.TssNonce rawResult, err := rpc.GetRawTxResult(ob.btcClient, hash, res) @@ -571,9 +475,9 @@ func (ob *Observer) checkTSSVin(ctx context.Context, vins []btcjson.Vin, nonce u } // 1st vin: nonce-mark MUST come from prior TSS outbound if nonce > 0 && i == 0 { - preTxid, err := ob.getOutboundIDByNonce(ctx, nonce-1, false) + preTxid, err := ob.getOutboundHashByNonce(ctx, nonce-1, false) if err != nil { - return fmt.Errorf("checkTSSVin: error findTxIDByNonce %d", nonce-1) + return fmt.Errorf("checkTSSVin: error getOutboundHashByNonce %d", nonce-1) } // nonce-mark MUST the 1st output that comes from prior TSS outbound if vin.Txid != preTxid || vin.Vout != 0 { diff --git a/zetaclient/chains/bitcoin/observer/utxos.go b/zetaclient/chains/bitcoin/observer/utxos.go new file mode 100644 index 0000000000..fa9f65e915 --- /dev/null +++ b/zetaclient/chains/bitcoin/observer/utxos.go @@ -0,0 +1,230 @@ +package observer + +import ( + "context" + "fmt" + "sort" + "strings" + + "github.com/btcsuite/btcd/btcjson" + "github.com/btcsuite/btcd/btcutil" + + "github.com/zeta-chain/node/pkg/chains" + "github.com/zeta-chain/node/zetaclient/chains/bitcoin" + clienttypes "github.com/zeta-chain/node/zetaclient/types" +) + +// WatchUTXOs watches bitcoin chain for UTXOs owned by the TSS address +func (ob *Observer) WatchUTXOs(ctx context.Context) error { + ticker, err := clienttypes.NewDynamicTicker("Bitcoin_WatchUTXOs", ob.ChainParams().WatchUtxoTicker) + if err != nil { + ob.logger.UTXOs.Error().Err(err).Msg("error creating ticker") + return err + } + + defer ticker.Stop() + for { + select { + case <-ticker.C(): + if !ob.ChainParams().IsSupported { + continue + } + err := ob.FetchUTXOs(ctx) + if err != nil { + // log debug log if the error if no wallet is loaded + // this is to prevent extensive logging in localnet when the wallet is not loaded for non-Bitcoin test + // TODO: prevent this routine from running if Bitcoin node is not enabled + // https://github.com/zeta-chain/node/issues/2790 + if !strings.Contains(err.Error(), "No wallet is loaded") { + ob.logger.UTXOs.Error().Err(err).Msg("error fetching btc utxos") + } else { + ob.logger.UTXOs.Debug().Err(err).Msg("No wallet is loaded") + } + } + ticker.UpdateInterval(ob.ChainParams().WatchUtxoTicker, ob.logger.UTXOs) + case <-ob.StopChannel(): + ob.logger.UTXOs.Info().Msgf("WatchUTXOs stopped for chain %d", ob.Chain().ChainId) + return nil + } + } +} + +// FetchUTXOs fetches TSS-owned UTXOs from the Bitcoin node +func (ob *Observer) FetchUTXOs(ctx context.Context) error { + defer func() { + if err := recover(); err != nil { + ob.logger.UTXOs.Error().Msgf("BTC FetchUTXOs: caught panic error: %v", err) + } + }() + + // this is useful when a zetaclient's pending nonce lagged behind for whatever reason. + ob.refreshPendingNonce(ctx) + + // refresh the last block height. + lastBlock, err := ob.btcClient.GetBlockCount() + if err != nil { + return fmt.Errorf("btc: error getting block height : %v", err) + } + if ob.LastBlock() < uint64(lastBlock) { + ob.WithLastBlock(uint64(lastBlock)) + } + + // list all unspent UTXOs (160ms) + maxConfirmations := int(lastBlock) + tssAddr, err := ob.TSS().PubKey().AddressBTC(ob.Chain().ChainId) + if err != nil { + return fmt.Errorf("error getting bitcoin tss address") + } + utxos, err := ob.btcClient.ListUnspentMinMaxAddresses(0, maxConfirmations, []btcutil.Address{tssAddr}) + if err != nil { + return err + } + + // rigid sort to make utxo list deterministic + sort.SliceStable(utxos, func(i, j int) bool { + if utxos[i].Amount == utxos[j].Amount { + if utxos[i].TxID == utxos[j].TxID { + return utxos[i].Vout < utxos[j].Vout + } + return utxos[i].TxID < utxos[j].TxID + } + return utxos[i].Amount < utxos[j].Amount + }) + + // filter UTXOs good to spend for next TSS transaction + utxosFiltered := make([]btcjson.ListUnspentResult, 0) + for _, utxo := range utxos { + // UTXOs big enough to cover the cost of spending themselves + if utxo.Amount < bitcoin.DefaultDepositorFee { + continue + } + // we don't want to spend other people's unconfirmed UTXOs as they may not be safe to spend + if utxo.Confirmations == 0 { + if !ob.isTSSTransaction(utxo.TxID) { + continue + } + } + utxosFiltered = append(utxosFiltered, utxo) + } + + ob.Mu().Lock() + ob.TelemetryServer().SetNumberOfUTXOs(len(utxosFiltered)) + ob.utxos = utxosFiltered + ob.Mu().Unlock() + return nil +} + +// SelectUTXOs selects a sublist of utxos to be used as inputs. +// +// Parameters: +// - amount: The desired minimum total value of the selected UTXOs. +// - utxos2Spend: The maximum number of UTXOs to spend. +// - nonce: The nonce of the outbound transaction. +// - consolidateRank: The rank below which UTXOs will be consolidated. +// - test: true for unit test only. +// +// Returns: +// - a sublist (includes previous nonce-mark) of UTXOs or an error if the qualifying sublist cannot be found. +// - the total value of the selected UTXOs. +// - the number of consolidated UTXOs. +// - the total value of the consolidated UTXOs. +func (ob *Observer) SelectUTXOs( + ctx context.Context, + amount float64, + utxosToSpend uint16, + nonce uint64, + consolidateRank uint16, + test bool, +) ([]btcjson.ListUnspentResult, float64, uint16, float64, error) { + idx := -1 + if nonce == 0 { + // for nonce = 0; make exception; no need to include nonce-mark utxo + ob.Mu().Lock() + defer ob.Mu().Unlock() + } else { + // for nonce > 0; we proceed only when we see the nonce-mark utxo + preTxid, err := ob.getOutboundHashByNonce(ctx, nonce-1, test) + if err != nil { + return nil, 0, 0, 0, err + } + ob.Mu().Lock() + defer ob.Mu().Unlock() + idx, err = ob.findNonceMarkUTXO(nonce-1, preTxid) + if err != nil { + return nil, 0, 0, 0, err + } + } + + // select smallest possible UTXOs to make payment + total := 0.0 + left, right := 0, 0 + for total < amount && right < len(ob.utxos) { + if utxosToSpend > 0 { // expand sublist + total += ob.utxos[right].Amount + right++ + utxosToSpend-- + } else { // pop the smallest utxo and append the current one + total -= ob.utxos[left].Amount + total += ob.utxos[right].Amount + left++ + right++ + } + } + results := make([]btcjson.ListUnspentResult, right-left) + copy(results, ob.utxos[left:right]) + + // include nonce-mark as the 1st input + if idx >= 0 { // for nonce > 0 + if idx < left || idx >= right { + total += ob.utxos[idx].Amount + results = append([]btcjson.ListUnspentResult{ob.utxos[idx]}, results...) + } else { // move nonce-mark to left + for i := idx - left; i > 0; i-- { + results[i], results[i-1] = results[i-1], results[i] + } + } + } + if total < amount { + return nil, 0, 0, 0, fmt.Errorf( + "SelectUTXOs: not enough btc in reserve - available : %v , tx amount : %v", + total, + amount, + ) + } + + // consolidate biggest possible UTXOs to maximize consolidated value + // consolidation happens only when there are more than (or equal to) consolidateRank (10) UTXOs + utxoRank, consolidatedUtxo, consolidatedValue := uint16(0), uint16(0), 0.0 + for i := len(ob.utxos) - 1; i >= 0 && utxosToSpend > 0; i-- { // iterate over UTXOs big-to-small + if i != idx && (i < left || i >= right) { // exclude nonce-mark and already selected UTXOs + utxoRank++ + if utxoRank >= consolidateRank { // consolication starts from the 10-ranked UTXO based on value + utxosToSpend-- + consolidatedUtxo++ + total += ob.utxos[i].Amount + consolidatedValue += ob.utxos[i].Amount + results = append(results, ob.utxos[i]) + } + } + } + + return results, total, consolidatedUtxo, consolidatedValue, nil +} + +// findNonceMarkUTXO finds the nonce-mark UTXO in the list of UTXOs. +func (ob *Observer) findNonceMarkUTXO(nonce uint64, txid string) (int, error) { + tssAddress := ob.TSSAddressString() + amount := chains.NonceMarkAmount(nonce) + for i, utxo := range ob.utxos { + sats, err := bitcoin.GetSatoshis(utxo.Amount) + if err != nil { + ob.logger.Outbound.Error().Err(err).Msgf("FindNonceMarkUTXO: error getting satoshis for utxo %v", utxo) + } + if utxo.Address == tssAddress && sats == amount && utxo.TxID == txid && utxo.Vout == 0 { + ob.logger.Outbound.Info(). + Msgf("FindNonceMarkUTXO: found nonce-mark utxo with txid %s, amount %d satoshi", utxo.TxID, sats) + return i, nil + } + } + return -1, fmt.Errorf("FindNonceMarkUTXO: cannot find nonce-mark utxo with nonce %d", nonce) +} diff --git a/zetaclient/chains/bitcoin/rpc/rpc.go b/zetaclient/chains/bitcoin/rpc/rpc.go index 48182c8726..2e19eedfe7 100644 --- a/zetaclient/chains/bitcoin/rpc/rpc.go +++ b/zetaclient/chains/bitcoin/rpc/rpc.go @@ -2,6 +2,9 @@ package rpc import ( "fmt" + "math" + "math/big" + "strings" "time" "github.com/btcsuite/btcd/btcjson" @@ -18,6 +21,18 @@ const ( // RPCAlertLatency is the default threshold for RPC latency to be considered unhealthy and trigger an alert. // Bitcoin block time is 10 minutes, 1200s (20 minutes) is a reasonable threshold for Bitcoin RPCAlertLatency = time.Duration(1200) * time.Second + + // PendingTxFeeBumpWaitBlocks is the number of blocks to await before considering a tx stuck in mempool + PendingTxFeeBumpWaitBlocks = 3 + + // blockTimeBTC represents the average time to mine a block in Bitcoin + blockTimeBTC = 10 * time.Minute + + // BTCMaxSupply is the maximum supply of Bitcoin + maxBTCSupply = 21000000.0 + + // bytesPerKB is the number of bytes in a KB + bytesPerKB = 1000 ) // NewRPCClient creates a new RPC client by the given config. @@ -130,6 +145,32 @@ func GetRawTxResult( return btcjson.TxRawResult{}, fmt.Errorf("GetRawTxResult: tx %s not included yet", hash) } +// FeeRateToSatPerByte converts a fee rate from BTC/KB to sat/byte. +func FeeRateToSatPerByte(rate float64) *big.Int { + // #nosec G115 always in range + satPerKB := new(big.Int).SetInt64(int64(rate * btcutil.SatoshiPerBitcoin)) + return new(big.Int).Div(satPerKB, big.NewInt(bytesPerKB)) +} + +// GetEstimatedFeeRate gets estimated smart fee rate (BTC/Kb) targeting given block confirmation +func GetEstimatedFeeRate(rpcClient interfaces.BTCRPCClient, confTarget int64) (int64, error) { + feeResult, err := rpcClient.EstimateSmartFee(confTarget, &btcjson.EstimateModeEconomical) + if err != nil { + return 0, errors.Wrap(err, "unable to estimate smart fee") + } + if feeResult.Errors != nil { + return 0, fmt.Errorf("fee result contains errors: %s", feeResult.Errors) + } + if feeResult.FeeRate == nil { + return 0, fmt.Errorf("fee rate is nil") + } + if *feeResult.FeeRate <= 0 || *feeResult.FeeRate >= maxBTCSupply { + return 0, fmt.Errorf("fee rate is invalid: %f", *feeResult.FeeRate) + } + + return FeeRateToSatPerByte(*feeResult.FeeRate).Int64(), nil +} + // GetTransactionFeeAndRate gets the transaction fee and rate for a given tx result func GetTransactionFeeAndRate(rpcClient interfaces.BTCRPCClient, rawResult *btcjson.TxRawResult) (int64, int64, error) { var ( @@ -181,6 +222,97 @@ func GetTransactionFeeAndRate(rpcClient interfaces.BTCRPCClient, rawResult *btcj return fee, feeRate, nil } +// IsTxStuckInMempool checks if the transaction is stuck in the mempool. +// +// A pending tx with 'confirmations == 0' will be considered stuck due to excessive pending time. +func IsTxStuckInMempool( + client interfaces.BTCRPCClient, + txHash string, + maxWaitBlocks int64, +) (bool, time.Duration, error) { + lastBlock, err := client.GetBlockCount() + if err != nil { + return false, 0, errors.Wrap(err, "GetBlockCount failed") + } + + memplEntry, err := client.GetMempoolEntry(txHash) + if err != nil { + if strings.Contains(err.Error(), "Transaction not in mempool") { + return false, 0, nil // not a mempool tx, of course not stuck + } + return false, 0, errors.Wrap(err, "GetMempoolEntry failed") + } + + // is the tx pending for too long? + pendingTime := time.Since(time.Unix(memplEntry.Time, 0)) + pendingTimeAllowed := blockTimeBTC * time.Duration(maxWaitBlocks) + pendingDeadline := memplEntry.Height + maxWaitBlocks + if pendingTime > pendingTimeAllowed && lastBlock > pendingDeadline { + return true, pendingTime, nil + } + + return false, pendingTime, nil +} + +// GetTotalMempoolParentsSizeNFees returns the total fee and vsize of all pending parents of a given pending child tx (inclusive) +// +// A parent is defined as: +// - a tx that is also pending in the mempool +// - a tx that has its first output spent by the child as first input +// +// Returns: (totalTxs, totalFees, totalVSize, error) +func GetTotalMempoolParentsSizeNFees( + client interfaces.BTCRPCClient, + childHash string, +) (int64, float64, int64, int64, error) { + var ( + totalTxs int64 + totalFees float64 + totalVSize int64 + avgFeeRate int64 + ) + + // loop through all parents + parentHash := childHash + for { + memplEntry, err := client.GetMempoolEntry(parentHash) + if err != nil { + if strings.Contains(err.Error(), "Transaction not in mempool") { + // not a mempool tx, stop looking for parents + break + } + return 0, 0, 0, 0, errors.Wrapf(err, "unable to get mempool entry for tx %s", parentHash) + } + + // sum up the total fees and vsize + totalTxs++ + totalFees += memplEntry.Fee + totalVSize += int64(memplEntry.VSize) + + // find the parent tx + tx, err := GetRawTxByHash(client, parentHash) + if err != nil { + return 0, 0, 0, 0, errors.Wrapf(err, "unable to get tx %s", parentHash) + } + parentHash = tx.MsgTx().TxIn[0].PreviousOutPoint.Hash.String() + } + + // sanity check, should never happen + if totalFees <= 0 || totalVSize <= 0 { + return 0, 0, 0, 0, errors.Errorf("invalid result: totalFees %f, totalVSize %d", totalFees, totalVSize) + } + + // no pending tx found + if totalTxs == 0 { + return 0, 0, 0, 0, errors.Errorf("no pending tx found for given child %s", childHash) + } + + // calculate the average fee rate + avgFeeRate = int64(math.Ceil(totalFees / float64(totalVSize))) + + return totalTxs, totalFees, totalVSize, avgFeeRate, nil +} + // CheckRPCStatus checks the RPC status of the bitcoin chain func CheckRPCStatus(client interfaces.BTCRPCClient, tssAddress btcutil.Address) (time.Time, error) { // query latest block number diff --git a/zetaclient/chains/bitcoin/rpc/rpc_rbf_live_test.go b/zetaclient/chains/bitcoin/rpc/rpc_rbf_live_test.go index e8e719408a..e6a765cf98 100644 --- a/zetaclient/chains/bitcoin/rpc/rpc_rbf_live_test.go +++ b/zetaclient/chains/bitcoin/rpc/rpc_rbf_live_test.go @@ -28,7 +28,7 @@ import ( // setupTest initializes the privateKey, sender, receiver and RPC client func setupTest(t *testing.T) (*rpcclient.Client, *secp256k1.PrivateKey, btcutil.Address, btcutil.Address) { // network to use - chain := chains.BitcoinTestnet4 + chain := chains.BitcoinMainnet net, err := chains.GetBTCChainParams(chain.ChainId) require.NoError(t, err) @@ -62,18 +62,29 @@ func Test_BitcoinRBFLive(t *testing.T) { return } - LiveTest_PendingMempoolTx(t) + //LiveTest_PendingMempoolTx(t) } -func LiveTest_RBFTransaction(t *testing.T) { +func Test_RBFTransaction(t *testing.T) { // setup test client, privKey, sender, to := setupTest(t) + // try querying tx result + _, getTxResult, err := rpc.GetTxResultByHash( + client, + "329d9204b906adc5f220954d53d9d990ebe92404c19297233aacb4a2ae799b69", + ) + if err == nil { + fmt.Printf("tx confirmations: %d\n", getTxResult.Confirmations) + } else { + fmt.Printf("GetTxResultByHash failed: %s\n", err) + } + // define amount, fee rate and bump fee reserved amount := 0.00001 nonceMark := chains.NonceMarkAmount(1) - feeRate := int64(2) - bumpFeeReserved := int64(10000) + feeRate := int64(6) + bumpFeeReserved := int64(0) // STEP 1 // build and send tx1 @@ -83,13 +94,13 @@ func LiveTest_RBFTransaction(t *testing.T) { // STEP 2 // build and send tx2 (child of tx1) - nonceMark += 1 - txHash2 := buildAndSendRBFTx(t, client, privKey, txHash1, sender, to, amount, nonceMark, feeRate, bumpFeeReserved) - fmt.Printf("sent tx2: %s\n", txHash2) + // nonceMark += 1 + // txHash2 := buildAndSendRBFTx(t, client, privKey, txHash1, sender, to, amount, nonceMark, feeRate, bumpFeeReserved) + // fmt.Printf("sent tx2: %s\n", txHash2) // STEP 3 // wait for a short time before bumping fee - rawTx1, confirmed := waitForTxConfirmation(client, sender, txHash1, 10*time.Second) + rawTx1, confirmed := waitForTxConfirmation(client, sender, txHash1, 600*time.Second) if confirmed { fmt.Println("Opps: tx1 confirmed, no chance to bump fee; please start over") return @@ -128,42 +139,69 @@ func LiveTest_RBFTransaction(t *testing.T) { // tx1 and tx2 must be dropped ensureTxDropped(t, client, txHash1) fmt.Println("tx1 dropped") - ensureTxDropped(t, client, txHash2) - fmt.Println("tx2 dropped") + //ensureTxDropped(t, client, txHash2) + //fmt.Println("tx2 dropped") } // Test_RBFTransactionChained_CPFP tests Child-Pays-For-Parent (CPFP) fee bumping strategy for chained RBF transactions -func LiveTest_RBFTransaction_Chained_CPFP(t *testing.T) { +func Test_RBFTransaction_Chained_CPFP(t *testing.T) { // setup test client, privKey, sender, to := setupTest(t) // define amount, fee rate and bump fee reserved amount := 0.00001 - nonceMark := chains.NonceMarkAmount(0) - feeRate := int64(2) - bumpFeeReserved := int64(10000) + nonceMark := int64(0) + feeRate := int64(20) + bumpFeeReserved := int64(0) + + //// + txid := "a5028b27a82aaea7f1bc6da41cb42e5f69478ef2b2e2cca7335db62f689f7e18" + oldHash, err := chainhash.NewHashFromStr(txid) + require.NoError(t, err) + rawTx2, err := client.GetRawTransaction(oldHash) + + // STEP 5 + // bump gas fee for tx3 (the child/grandchild of tx1/tx2) + // we assume that tx3 has same vBytes as the fee-bump tx (tx4) for simplicity + // two rules to satisfy: + // - feeTx4 >= feeTx3 + // - additionalFees >= vSizeTx4 * minRelayFeeRate + // see: https://github.com/bitcoin/bitcoin/blob/master/src/policy/rbf.cpp#L166-L183 + minRelayFeeRate := int64(1) + feeRateIncrease := minRelayFeeRate + feeRate - 1 + additionalFees := (110) * feeRateIncrease + fmt.Printf("additional fee: %d sats\n", additionalFees) + tx3, err := bumpRBFTxFee(rawTx2.MsgTx(), additionalFees) + require.NoError(t, err) + + // STEP 6 + // sign and send tx3, which replaces tx2 + signTx(t, client, privKey, tx3) + txHash, err := client.SendRawTransaction(tx3, true) + require.NoError(t, err) + fmt.Printf("sent tx3: %s\n", txHash) // STEP 1 // build and send tx1 - nonceMark += 1 + nonceMark = 0 txHash1 := buildAndSendRBFTx(t, client, privKey, nil, sender, to, amount, nonceMark, feeRate, bumpFeeReserved) fmt.Printf("sent tx1: %s\n", txHash1) // STEP 2 // build and send tx2 (child of tx1) - nonceMark += 1 + //nonceMark += 1 txHash2 := buildAndSendRBFTx(t, client, privKey, txHash1, sender, to, amount, nonceMark, feeRate, bumpFeeReserved) fmt.Printf("sent tx2: %s\n", txHash2) // STEP 3 // build and send tx3 (child of tx2) - nonceMark += 1 + //nonceMark += 1 txHash3 := buildAndSendRBFTx(t, client, privKey, txHash2, sender, to, amount, nonceMark, feeRate, bumpFeeReserved) fmt.Printf("sent tx3: %s\n", txHash3) // STEP 4 // wait for a short time before bumping fee - rawTx3, confirmed := waitForTxConfirmation(client, sender, txHash3, 10*time.Second) + rawTx2, confirmed := waitForTxConfirmation(client, sender, txHash3, 10*time.Second) if confirmed { fmt.Println("Opps: tx3 confirmed, no chance to bump fee; please start over") return @@ -176,11 +214,11 @@ func LiveTest_RBFTransaction_Chained_CPFP(t *testing.T) { // - feeTx4 >= feeTx3 // - additionalFees >= vSizeTx4 * minRelayFeeRate // see: https://github.com/bitcoin/bitcoin/blob/master/src/policy/rbf.cpp#L166-L183 - minRelayFeeRate := int64(1) - feeRateIncrease := minRelayFeeRate - additionalFees := (mempool.GetTxVirtualSize(rawTx3) + 1) * feeRateIncrease + minRelayFeeRate = int64(1) + feeRateIncrease = minRelayFeeRate + additionalFees = (mempool.GetTxVirtualSize(rawTx2) + 1) * feeRateIncrease fmt.Printf("additional fee: %d sats\n", additionalFees) - tx4, err := bumpRBFTxFee(rawTx3.MsgTx(), additionalFees) + tx4, err := bumpRBFTxFee(rawTx2.MsgTx(), additionalFees) require.NoError(t, err) // STEP 6 @@ -203,7 +241,7 @@ func LiveTest_RBFTransaction_Chained_CPFP(t *testing.T) { fmt.Println("tx1 dropped") } -func LiveTest_PendingMempoolTx(t *testing.T) { +func Test_PendingMempoolTx(t *testing.T) { // setup Bitcoin client client, err := createRPCClient(chains.BitcoinMainnet.ChainId) require.NoError(t, err) @@ -235,10 +273,11 @@ func LiveTest_PendingMempoolTx(t *testing.T) { txHash := mempoolTxs[i] entry, err := client.GetMempoolEntry(txHash.String()) if err == nil { + require.Positive(t, entry.Fee) txTime := time.Unix(entry.Time, 0) txTimeStr := txTime.Format(time.DateTime) elapsed := time.Since(txTime) - if elapsed > 2*time.Hour { + if elapsed > 30*time.Minute { // calculate average block time elapsedBlocks := lastHeight - entry.Height minutesPerBlockCalculated := elapsed.Minutes() / float64(elapsedBlocks) @@ -282,7 +321,18 @@ func buildAndSendRBFTx( ) *chainhash.Hash { // list outputs utxos := listUTXOs(client, sender) - require.NotEmpty(t, utxos) + //require.NotEmpty(t, utxos) + + // use hardcoded utxos if none found + if len(utxos) == 0 { + utxos = []btcjson.ListUnspentResult{ + { + TxID: "329d9204b906adc5f220954d53d9d990ebe92404c19297233aacb4a2ae799b69", + Vout: 0, + Amount: 0.00014399, + }, + } + } // ensure all inputs are from the parent tx if parent != nil { @@ -364,31 +414,37 @@ func buildRBFTx( require.NoError(t, err) // amount to send in satoshis - amountSats, err := bitcoin.GetSatoshis(amount) - require.NoError(t, err) + //amountSats, err := bitcoin.GetSatoshis(amount) + //require.NoError(t, err) // calculate tx fee - txSize, err := bitcoin.EstimateOutboundSize(uint64(len(utxos)), []btcutil.Address{to}) + txSize, err := bitcoin.EstimateOutboundSize(int64(len(utxos)), []btcutil.Address{to}) require.NoError(t, err) + require.Greater(t, txSize, uint64(62)) + //txSize = 125 // remove the size of the nonce-mark and payee outputs + txSize -= 62 // remove the size of the nonce-mark and payee outputs fees := int64(txSize) * feeRate + // adjust amount + amountSats := totalSats - fees + // make sure total is greater than amount + fees - require.GreaterOrEqual(t, totalSats, nonceMark+amountSats+fees+bumpFeeReserved) + //require.GreaterOrEqual(t, totalSats, nonceMark+amountSats+fees+bumpFeeReserved) // 1st output: simulated nonce-mark amount to self pkScriptSender, err := txscript.PayToAddrScript(sender) require.NoError(t, err) - txOut0 := wire.NewTxOut(nonceMark, pkScriptSender) - tx.AddTxOut(txOut0) + // txOut0 := wire.NewTxOut(nonceMark, pkScriptSender) + // tx.AddTxOut(txOut0) // 2nd output: payment to receiver - pkScriptReceiver, err := txscript.PayToAddrScript(to) - require.NoError(t, err) - txOut1 := wire.NewTxOut(amountSats, pkScriptReceiver) - tx.AddTxOut(txOut1) + // pkScriptReceiver, err := txscript.PayToAddrScript(to) + // require.NoError(t, err) + // txOut1 := wire.NewTxOut(amountSats, pkScriptReceiver) + // tx.AddTxOut(txOut1) // 3rd output: change to self - changeSats := totalSats - nonceMark - amountSats - fees + changeSats := amountSats //totalSats - nonceMark - amountSats - fees require.GreaterOrEqual(t, changeSats, bumpFeeReserved) txOut2 := wire.NewTxOut(changeSats, pkScriptSender) tx.AddTxOut(txOut2) @@ -518,12 +574,12 @@ func bumpRBFTxFee(oldTx *wire.MsgTx, additionalFee int64) (*wire.MsgTx, error) { } // original change needs to be enough to cover the additional fee - if newTx.TxOut[2].Value <= additionalFee { + if newTx.TxOut[0].Value <= additionalFee { return nil, errors.New("change amount is not enough to cover the additional fee") } // bump fee by reducing the change amount - newTx.TxOut[2].Value = newTx.TxOut[2].Value - additionalFee + newTx.TxOut[0].Value = newTx.TxOut[0].Value - additionalFee return newTx, nil } diff --git a/zetaclient/chains/bitcoin/signer/fee_bumper.go b/zetaclient/chains/bitcoin/signer/fee_bumper.go new file mode 100644 index 0000000000..c4665b0280 --- /dev/null +++ b/zetaclient/chains/bitcoin/signer/fee_bumper.go @@ -0,0 +1,166 @@ +package signer + +import ( + "fmt" + "math" + + "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/mempool" + "github.com/btcsuite/btcd/wire" + "github.com/pkg/errors" + "github.com/rs/zerolog" + + "github.com/zeta-chain/node/pkg/constant" + "github.com/zeta-chain/node/zetaclient/chains/bitcoin" + "github.com/zeta-chain/node/zetaclient/chains/bitcoin/rpc" + "github.com/zeta-chain/node/zetaclient/chains/interfaces" +) + +const ( + // minCPFPFeeBumpFactor is the minimum factor by which the CPFP average fee rate should be bumped. + // This value 20% is a heuristic, not mandated by the Bitcoin protocol, designed to balance effectiveness + // in replacing stuck transactions while avoiding excessive sensitivity to fee market fluctuations. + minCPFPFeeBumpFactor = 1.2 +) + +// MempoolTxsInfoFetcher is a function type to fetch mempool txs information +type MempoolTxsInfoFetcher func(interfaces.BTCRPCClient, string) (int64, float64, int64, int64, error) + +// CPFPFeeBumper is a helper struct to contain CPFP (child-pays-for-parent) fee bumping logic +type CPFPFeeBumper struct { + // client is the RPC client to interact with the Bitcoin chain + client interfaces.BTCRPCClient + + // tx is the stuck transaction to bump + tx *btcutil.Tx + + // minRelayFee is the minimum relay fee in BTC + minRelayFee float64 + + // cctxRate is the most recent fee rate of the CCTX + cctxRate int64 + + // liveRate is the most recent market fee rate + liveRate int64 + + // totalTxs is the total number of stuck TSS txs + totalTxs int64 + + // totalFees is the total fees of all stuck TSS txs + totalFees int64 + + // totalVSize is the total vsize of all stuck TSS txs + totalVSize int64 + + // avgFeeRate is the average fee rate of all stuck TSS txs + avgFeeRate int64 +} + +// NewCPFPFeeBumper creates a new CPFPFeeBumper +func NewCPFPFeeBumper( + client interfaces.BTCRPCClient, + tx *btcutil.Tx, + cctxRate int64, + minRelayFee float64, +) *CPFPFeeBumper { + return &CPFPFeeBumper{ + client: client, + tx: tx, + minRelayFee: minRelayFee, + cctxRate: cctxRate, + } +} + +// BumpTxFee bumps the fee of the stuck transactions +func (b *CPFPFeeBumper) BumpTxFee() (*wire.MsgTx, int64, error) { + // tx replacement is triggered only when market fee rate goes 20% higher than current paid fee rate. + // zetacore updates the cctx fee rate evey 10 minutes, we could hold on and retry later. + minBumpRate := int64(math.Ceil(float64(b.avgFeeRate) * minCPFPFeeBumpFactor)) + if b.cctxRate < minBumpRate { + return nil, 0, fmt.Errorf( + "hold on RBF: cctx rate %d is lower than the min bumped rate %d", + b.cctxRate, + minBumpRate, + ) + } + + // the live rate may continue increasing during network congestion, we should wait until it stabilizes a bit. + // this is to ensure the live rate is not 20%+ higher than the cctx rate, otherwise, the replacement tx may + // also get stuck and need another replacement. + bumpedRate := int64(math.Ceil(float64(b.cctxRate) * minCPFPFeeBumpFactor)) + if b.liveRate > bumpedRate { + return nil, 0, fmt.Errorf( + "hold on RBF: live rate %d is much higher than the cctx rate %d", + b.liveRate, + b.cctxRate, + ) + } + + // calculate minmimum relay fees of the new replacement tx + // the new tx will have almost same size as the old one because the tx body stays the same + txVSize := mempool.GetTxVirtualSize(b.tx) + minRelayFeeRate := rpc.FeeRateToSatPerByte(b.minRelayFee) + minRelayTxFees := txVSize * minRelayFeeRate.Int64() + + // calculate the RBF additional fees required by Bitcoin protocol + // two conditions to satisfy: + // 1. new txFees >= old txFees (already handled above) + // 2. additionalFees >= minRelayTxFees + // + // see: https://github.com/bitcoin/bitcoin/blob/master/src/policy/rbf.cpp#L166-L183 + additionalFees := b.totalVSize*b.cctxRate - b.totalFees + if additionalFees < minRelayTxFees { + additionalFees = minRelayTxFees + } + + // copy the old tx and clear witness data (e.g., signatures) + newTx := b.tx.MsgTx().Copy() + for idx := range newTx.TxIn { + newTx.TxIn[idx].Witness = wire.TxWitness{} + } + + // check reserved bump fees amount in the original tx + if len(newTx.TxOut) < 3 { + return nil, 0, errors.New("original tx has no reserved bump fees") + } + + // bump fees in two ways: + // 1. deduct additional fees from the change amount + // 2. give up the whole change amount if it's not enough + if newTx.TxOut[2].Value >= additionalFees+constant.BTCWithdrawalDustAmount { + newTx.TxOut[2].Value -= additionalFees + } else { + newTx.TxOut = newTx.TxOut[:2] + } + + return newTx, additionalFees, nil +} + +// fetchFeeBumpInfo fetches all necessary information needed to bump the stuck tx +func (b *CPFPFeeBumper) FetchFeeBumpInfo(memplTxsInfoFetcher MempoolTxsInfoFetcher, logger zerolog.Logger) error { + // query live network fee rate + liveRate, err := rpc.GetEstimatedFeeRate(b.client, 1) + if err != nil { + return errors.Wrap(err, "GetEstimatedFeeRate failed") + } + b.liveRate = liveRate + + // query total fees and sizes of all pending parent TSS txs + totalTxs, totalFees, totalVSize, avgFeeRate, err := memplTxsInfoFetcher(b.client, b.tx.MsgTx().TxID()) + if err != nil { + return errors.Wrap(err, "GetTotalMempoolParentsSizeNFees failed") + } + totalFeesSats, err := bitcoin.GetSatoshis(totalFees) + if err != nil { + return errors.Wrapf(err, "cannot convert total fees %f", totalFees) + } + + b.totalTxs = totalTxs + b.totalFees = totalFeesSats + b.totalVSize = totalVSize + b.avgFeeRate = avgFeeRate + logger.Info(). + Msgf("totalTxs %d, totalFees %f, totalVSize %d, avgFeeRate %d", totalTxs, totalFees, totalVSize, avgFeeRate) + + return nil +} diff --git a/zetaclient/chains/bitcoin/signer/outbound_data.go b/zetaclient/chains/bitcoin/signer/outbound_data.go new file mode 100644 index 0000000000..6eea1d866d --- /dev/null +++ b/zetaclient/chains/bitcoin/signer/outbound_data.go @@ -0,0 +1,116 @@ +package signer + +import ( + "fmt" + "strconv" + + "github.com/btcsuite/btcd/btcutil" + "github.com/pkg/errors" + "github.com/rs/zerolog" + + "github.com/zeta-chain/node/pkg/chains" + "github.com/zeta-chain/node/pkg/coin" + "github.com/zeta-chain/node/pkg/constant" + "github.com/zeta-chain/node/x/crosschain/types" + "github.com/zeta-chain/node/zetaclient/chains/bitcoin/rpc" + "github.com/zeta-chain/node/zetaclient/compliance" +) + +// OutboundData is a data structure containing necessary data to construct a BTC outbound transaction +type OutboundData struct { + // chainID is the external chain ID + chainID int64 + + // to is the recipient address + to btcutil.Address + + // amount is the amount in BTC + amount float64 + + // feeRate is the fee rate in satoshis/vByte + feeRate int64 + + // txSize is the average size of a BTC outbound transaction + // user is charged (in ZRC20 contract) at a static txSize on each withdrawal + txSize int64 + + // nonce is the nonce of the outbound + nonce uint64 + + // height is the ZetaChain block height + height uint64 + + // cancelTx is a flag to indicate if this outbound should be cancelled + cancelTx bool +} + +// NewOutboundData creates OutboundData from the given CCTX. +func NewOutboundData( + cctx *types.CrossChainTx, + chainID int64, + height uint64, + minRelayFee float64, + logger, loggerCompliance zerolog.Logger, +) (*OutboundData, error) { + if cctx == nil { + return nil, errors.New("cctx is nil") + } + params := cctx.GetCurrentOutboundParam() + + // support gas token only for Bitcoin outbound + if cctx.InboundParams.CoinType != coin.CoinType_Gas { + logger.Error().Msg("can only send gas token to a Bitcoin network") + return nil, nil + } + + // fee rate + feeRate, err := strconv.ParseInt(params.GasPrice, 10, 64) + if err != nil || feeRate < 0 { + return nil, fmt.Errorf("cannot convert gas price %s", params.GasPrice) + } + + // check receiver address + to, err := chains.DecodeBtcAddress(params.Receiver, params.ReceiverChainId) + if err != nil { + return nil, errors.Wrapf(err, "cannot decode address %s", params.Receiver) + } + if !chains.IsBtcAddressSupported(to) { + return nil, fmt.Errorf("unsupported address %s", params.Receiver) + } + amount := float64(params.Amount.Uint64()) / 1e8 + + // add minimum relay fee (1000 satoshis/KB by default) to gasPrice to avoid minRelayTxFee error + // see: https://github.com/bitcoin/bitcoin/blob/master/src/policy/policy.h#L35 + satPerByte := rpc.FeeRateToSatPerByte(minRelayFee) + feeRate += satPerByte.Int64() + + // compliance check + restrictedCCTX := compliance.IsCctxRestricted(cctx) + if restrictedCCTX { + compliance.PrintComplianceLog(logger, loggerCompliance, + true, chainID, cctx.Index, cctx.InboundParams.Sender, params.Receiver, "BTC") + } + + // check dust amount + dustAmount := params.Amount.Uint64() < constant.BTCWithdrawalDustAmount + if dustAmount { + logger.Warn().Msgf("dust amount %d sats, canceling tx", params.Amount.Uint64()) + } + + // set the amount to 0 when the tx should be cancelled + cancelTx := restrictedCCTX || dustAmount + if cancelTx { + amount = 0.0 + } + + return &OutboundData{ + chainID: chainID, + to: to, + amount: amount, + feeRate: feeRate, + txSize: int64(params.CallOptions.GasLimit), + nonce: params.TssNonce, + height: height, + cancelTx: cancelTx, + }, nil +} diff --git a/zetaclient/chains/bitcoin/signer/sign_withdraw.go b/zetaclient/chains/bitcoin/signer/sign_withdraw.go new file mode 100644 index 0000000000..2fd617a7c8 --- /dev/null +++ b/zetaclient/chains/bitcoin/signer/sign_withdraw.go @@ -0,0 +1,253 @@ +package signer + +import ( + "context" + "fmt" + + "github.com/btcsuite/btcd/btcec/v2" + btcecdsa "github.com/btcsuite/btcd/btcec/v2/ecdsa" + "github.com/btcsuite/btcd/btcjson" + "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/txscript" + "github.com/btcsuite/btcd/wire" + "github.com/pkg/errors" + + "github.com/zeta-chain/node/pkg/chains" + "github.com/zeta-chain/node/zetaclient/chains/bitcoin" + "github.com/zeta-chain/node/zetaclient/chains/bitcoin/observer" +) + +const ( + // the maximum number of inputs per outbound + MaxNoOfInputsPerTx = 20 + + // the rank below (or equal to) which we consolidate UTXOs + consolidationRank = 10 +) + +// SignWithdrawTx signs a BTC withdrawal tx and returns the signed tx +func (signer *Signer) SignWithdrawTx( + ctx context.Context, + txData *OutboundData, + ob *observer.Observer, +) (*wire.MsgTx, error) { + nonceMark := chains.NonceMarkAmount(txData.nonce) + estimateFee := float64(txData.feeRate*bitcoin.OutboundBytesMax) / 1e8 + + // refreshing UTXO list before TSS keysign is important: + // 1. all TSS outbounds have opted-in for RBF to be replaceable + // 2. using old UTXOs may lead to accidental double-spending + // 3. double-spending may trigger unexpected tx replacement (RBF) + // + // Note: unwanted RBF will rarely happen for two reasons: + // 1. it requires 2/3 TSS signers to accidentally sign the same tx using same outdated UTXOs. + // 2. RBF requires a higher fee rate than the original tx. + err := ob.FetchUTXOs(ctx) + if err != nil { + return nil, errors.Wrap(err, "FetchUTXOs failed") + } + + // select N UTXOs to cover the total expense + prevOuts, total, consolidatedUtxo, consolidatedValue, err := ob.SelectUTXOs( + ctx, + txData.amount+estimateFee+float64(nonceMark)*1e-8, + MaxNoOfInputsPerTx, + txData.nonce, + consolidationRank, + false, + ) + if err != nil { + return nil, err + } + + // build tx and add inputs + tx := wire.NewMsgTx(wire.TxVersion) + inAmounts, err := signer.AddTxInputs(tx, prevOuts) + if err != nil { + return nil, err + } + + // size checking + // #nosec G115 always positive + txSize, err := bitcoin.EstimateOutboundSize(int64(len(prevOuts)), []btcutil.Address{txData.to}) + if err != nil { + return nil, err + } + if txData.txSize < bitcoin.BtcOutboundBytesWithdrawer { // ZRC20 'withdraw' charged less fee from end user + signer.Logger().Std.Info(). + Msgf("txSize %d is less than BtcOutboundBytesWithdrawer %d for nonce %d", txData.txSize, txSize, txData.nonce) + } + if txSize < bitcoin.OutboundBytesMin { // outbound shouldn't be blocked a low sizeLimit + signer.Logger().Std.Warn(). + Msgf("txSize %d is less than outboundBytesMin %d; use outboundBytesMin", txSize, bitcoin.OutboundBytesMin) + txSize = bitcoin.OutboundBytesMin + } + if txSize > bitcoin.OutboundBytesMax { // in case of accident + signer.Logger().Std.Warn(). + Msgf("txSize %d is greater than outboundBytesMax %d; use outboundBytesMax", txSize, bitcoin.OutboundBytesMax) + txSize = bitcoin.OutboundBytesMax + } + + // fee calculation + // #nosec G115 always in range (checked above) + fees := txSize * txData.feeRate + signer.Logger(). + Std.Info(). + Msgf("bitcoin outbound nonce %d feeRate %d size %d fees %d consolidated %d utxos of value %v", + txData.nonce, txData.feeRate, txSize, fees, consolidatedUtxo, consolidatedValue) + + // add tx outputs + err = signer.AddWithdrawTxOutputs(tx, txData.to, total, txData.amount, nonceMark, fees, txData.cancelTx) + if err != nil { + return nil, err + } + + // sign the tx + err = signer.SignTx(ctx, tx, inAmounts, txData.height, txData.nonce) + if err != nil { + return nil, errors.Wrap(err, "SignTx failed") + } + + return tx, nil +} + +// AddTxInputs adds the inputs to the tx and returns input amounts +func (signer *Signer) AddTxInputs(tx *wire.MsgTx, utxos []btcjson.ListUnspentResult) ([]int64, error) { + amounts := make([]int64, len(utxos)) + for i, utxo := range utxos { + hash, err := chainhash.NewHashFromStr(utxo.TxID) + if err != nil { + return nil, err + } + + // add input and set 'nSequence' to opt-in for RBF + // it doesn't matter on which input we set the RBF sequence + outpoint := wire.NewOutPoint(hash, utxo.Vout) + txIn := wire.NewTxIn(outpoint, nil, nil) + if i == 0 { + txIn.Sequence = rbfTxInSequenceNum + } + tx.AddTxIn(txIn) + + // store the amount for later signing use + amount, err := bitcoin.GetSatoshis(utxos[i].Amount) + if err != nil { + return nil, err + } + amounts[i] = amount + } + + return amounts, nil +} + +// AddWithdrawTxOutputs adds the 3 outputs to the withdraw tx +// 1st output: the nonce-mark btc to TSS itself +// 2nd output: the payment to the recipient +// 3rd output: the remaining btc to TSS itself +func (signer *Signer) AddWithdrawTxOutputs( + tx *wire.MsgTx, + to btcutil.Address, + total float64, + amount float64, + nonceMark int64, + fees int64, + cancelTx bool, +) error { + // convert withdraw amount to satoshis + amountSatoshis, err := bitcoin.GetSatoshis(amount) + if err != nil { + return err + } + + // calculate remaining btc (the change) to TSS self + remaining := total - amount + remainingSats, err := bitcoin.GetSatoshis(remaining) + if err != nil { + return err + } + remainingSats -= fees + remainingSats -= nonceMark + if remainingSats < 0 { + return fmt.Errorf("remainder value is negative: %d", remainingSats) + } else if remainingSats == nonceMark { + signer.Logger().Std.Info().Msgf("adjust remainder value to avoid duplicate nonce-mark: %d", remainingSats) + remainingSats-- + } + + // 1st output: the nonce-mark btc to TSS self + payToSelfScript, err := signer.PkScriptTSS() + if err != nil { + return err + } + txOut1 := wire.NewTxOut(nonceMark, payToSelfScript) + tx.AddTxOut(txOut1) + + // 2nd output: the payment to the recipient + if !cancelTx { + pkScript, err := txscript.PayToAddrScript(to) + if err != nil { + return err + } + txOut2 := wire.NewTxOut(amountSatoshis, pkScript) + tx.AddTxOut(txOut2) + } else { + // send the amount to TSS self if tx is cancelled + remainingSats += amountSatoshis + } + + // 3rd output: the remaining btc to TSS self + if remainingSats > 0 { + txOut3 := wire.NewTxOut(remainingSats, payToSelfScript) + tx.AddTxOut(txOut3) + } + return nil +} + +// SignTx signs the tx with the given witnessHashes +func (signer *Signer) SignTx( + ctx context.Context, + tx *wire.MsgTx, + inputAmounts []int64, + height uint64, + nonce uint64, +) error { + // get the TSS pkScript + pkScript, err := signer.PkScriptTSS() + if err != nil { + return err + } + + // calculate sighashes to sign + sigHashes := txscript.NewTxSigHashes(tx, txscript.NewCannedPrevOutputFetcher([]byte{}, 0)) + witnessHashes := make([][]byte, len(tx.TxIn)) + for ix := range tx.TxIn { + amount := inputAmounts[ix] + witnessHashes[ix], err = txscript.CalcWitnessSigHash(pkScript, sigHashes, txscript.SigHashAll, tx, ix, amount) + if err != nil { + return err + } + } + + // sign the tx with TSS + sig65Bs, err := signer.TSS().SignBatch(ctx, witnessHashes, height, nonce, signer.Chain().ChainId) + if err != nil { + return fmt.Errorf("SignBatch failed: %v", err) + } + + for ix := range tx.TxIn { + sig65B := sig65Bs[ix] + R := &btcec.ModNScalar{} + R.SetBytes((*[32]byte)(sig65B[:32])) + S := &btcec.ModNScalar{} + S.SetBytes((*[32]byte)(sig65B[32:64])) + sig := btcecdsa.NewSignature(R, S) + + pkCompressed := signer.TSS().PubKey().Bytes(true) + hashType := txscript.SigHashAll + txWitness := wire.TxWitness{append(sig.Serialize(), byte(hashType)), pkCompressed} + tx.TxIn[ix].Witness = txWitness + } + + return nil +} diff --git a/zetaclient/chains/bitcoin/signer/sign_withdraw_rbf.go b/zetaclient/chains/bitcoin/signer/sign_withdraw_rbf.go new file mode 100644 index 0000000000..19a3d53600 --- /dev/null +++ b/zetaclient/chains/bitcoin/signer/sign_withdraw_rbf.go @@ -0,0 +1,79 @@ +package signer + +import ( + "context" + "fmt" + "strconv" + + "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/wire" + "github.com/pkg/errors" + + "github.com/zeta-chain/node/x/crosschain/types" + "github.com/zeta-chain/node/zetaclient/chains/bitcoin/rpc" + "github.com/zeta-chain/node/zetaclient/logs" +) + +const ( + // rbfTxInSequenceNum is the sequence number used to signal an opt-in full-RBF (Replace-By-Fee) transaction + // Setting sequenceNum to "1" effectively makes the transaction timelocks irrelevant. + // See: https://github.com/bitcoin/bips/blob/master/bip-0125.mediawiki + // Also see: https://github.com/BlockchainCommons/Learning-Bitcoin-from-the-Command-Line/blob/master/05_2_Resending_a_Transaction_with_RBF.md + rbfTxInSequenceNum uint32 = 1 +) + +func (signer *Signer) SignRBFTx( + ctx context.Context, + cctx *types.CrossChainTx, + oldTx *btcutil.Tx, + minRelayFee float64, +) (*wire.MsgTx, error) { + var ( + params = cctx.GetCurrentOutboundParam() + lf = map[string]any{ + logs.FieldMethod: "SignRBFTx", + logs.FieldNonce: params.TssNonce, + logs.FieldTx: oldTx.MsgTx().TxID(), + } + logger = signer.Logger().Std.With().Fields(lf).Logger() + ) + + // parse recent fee rate from CCTX + cctxRate, err := strconv.ParseInt(params.GasPrice, 10, 64) + if err != nil || cctxRate <= 0 { + return nil, fmt.Errorf("cannot convert fee rate %s", params.GasPrice) + } + + // initiate fee bumper + fb := NewCPFPFeeBumper(signer.client, oldTx, cctxRate, minRelayFee) + err = fb.FetchFeeBumpInfo(rpc.GetTotalMempoolParentsSizeNFees, logger) + if err != nil { + return nil, errors.Wrap(err, "FetchFeeBumpInfo failed") + } + + // bump tx fees + newTx, additionalFees, err := fb.BumpTxFee() + if err != nil { + return nil, errors.Wrap(err, "BumpTxFee failed") + } + logger.Info().Msgf("BumpTxFee success, additional fees: %d satoshis", additionalFees) + + // collect input amounts for later signing + inAmounts := make([]int64, len(newTx.TxIn)) + for i, input := range newTx.TxIn { + preOut := input.PreviousOutPoint + preTx, err := signer.client.GetRawTransaction(&preOut.Hash) + if err != nil { + return nil, errors.Wrapf(err, "unable to get previous tx %s", preOut.Hash) + } + inAmounts[i] = preTx.MsgTx().TxOut[preOut.Index].Value + } + + // sign the RBF tx + err = signer.SignTx(ctx, newTx, inAmounts, 0, params.TssNonce) + if err != nil { + return nil, errors.Wrap(err, "SignTx failed") + } + + return newTx, nil +} diff --git a/zetaclient/chains/bitcoin/signer/signer.go b/zetaclient/chains/bitcoin/signer/signer.go index 2769a46845..499af8c94d 100644 --- a/zetaclient/chains/bitcoin/signer/signer.go +++ b/zetaclient/chains/bitcoin/signer/signer.go @@ -6,13 +6,8 @@ import ( "context" "encoding/hex" "fmt" - "math/big" "time" - "github.com/btcsuite/btcd/btcec/v2" - btcecdsa "github.com/btcsuite/btcd/btcec/v2/ecdsa" - "github.com/btcsuite/btcd/btcutil" - "github.com/btcsuite/btcd/chaincfg/chainhash" "github.com/btcsuite/btcd/rpcclient" "github.com/btcsuite/btcd/txscript" "github.com/btcsuite/btcd/wire" @@ -20,15 +15,10 @@ import ( "github.com/pkg/errors" "github.com/zeta-chain/node/pkg/chains" - "github.com/zeta-chain/node/pkg/coin" - "github.com/zeta-chain/node/pkg/constant" "github.com/zeta-chain/node/x/crosschain/types" - observertypes "github.com/zeta-chain/node/x/observer/types" "github.com/zeta-chain/node/zetaclient/chains/base" - "github.com/zeta-chain/node/zetaclient/chains/bitcoin" "github.com/zeta-chain/node/zetaclient/chains/bitcoin/observer" "github.com/zeta-chain/node/zetaclient/chains/interfaces" - "github.com/zeta-chain/node/zetaclient/compliance" "github.com/zeta-chain/node/zetaclient/config" "github.com/zeta-chain/node/zetaclient/logs" "github.com/zeta-chain/node/zetaclient/metrics" @@ -36,18 +26,6 @@ import ( ) const ( - // the maximum number of inputs per outbound - MaxNoOfInputsPerTx = 20 - - // the rank below (or equal to) which we consolidate UTXOs - consolidationRank = 10 - - // rbfTxInSequenceNum is the sequence number used to signal an opt-in full-RBF (Replace-By-Fee) transaction - // Setting sequenceNum to "1" effectively makes the transaction timelocks irrelevant. - // See bip125: https://github.com/bitcoin/bips/blob/master/bip-0125.mediawiki - // Also see: https://github.com/BlockchainCommons/Learning-Bitcoin-from-the-Command-Line/blob/master/05_2_Resending_a_Transaction_with_RBF.md - rbfTxInSequenceNum uint32 = 1 - // broadcastBackoff is the initial backoff duration for retrying broadcast broadcastBackoff = 1000 * time.Millisecond @@ -127,200 +105,13 @@ func (signer *Signer) GetGatewayAddress() string { return "" } -// AddWithdrawTxOutputs adds the 3 outputs to the withdraw tx -// 1st output: the nonce-mark btc to TSS itself -// 2nd output: the payment to the recipient -// 3rd output: the remaining btc to TSS itself -func (signer *Signer) AddWithdrawTxOutputs( - tx *wire.MsgTx, - to btcutil.Address, - total float64, - amount float64, - nonceMark int64, - fees *big.Int, - cancelTx bool, -) error { - // convert withdraw amount to satoshis - amountSatoshis, err := bitcoin.GetSatoshis(amount) - if err != nil { - return err - } - - // calculate remaining btc (the change) to TSS self - remaining := total - amount - remainingSats, err := bitcoin.GetSatoshis(remaining) - if err != nil { - return err - } - remainingSats -= fees.Int64() - remainingSats -= nonceMark - if remainingSats < 0 { - return fmt.Errorf("remainder value is negative: %d", remainingSats) - } else if remainingSats == nonceMark { - signer.Logger().Std.Info().Msgf("adjust remainder value to avoid duplicate nonce-mark: %d", remainingSats) - remainingSats-- - } - - // 1st output: the nonce-mark btc to TSS self +// PkScriptTSS returns the TSS pkScript +func (signer *Signer) PkScriptTSS() ([]byte, error) { tssAddrP2WPKH, err := signer.TSS().PubKey().AddressBTC(signer.Chain().ChainId) - if err != nil { - return err - } - payToSelfScript, err := txscript.PayToAddrScript(tssAddrP2WPKH) - if err != nil { - return err - } - txOut1 := wire.NewTxOut(nonceMark, payToSelfScript) - tx.AddTxOut(txOut1) - - // 2nd output: the payment to the recipient - if !cancelTx { - pkScript, err := txscript.PayToAddrScript(to) - if err != nil { - return err - } - txOut2 := wire.NewTxOut(amountSatoshis, pkScript) - tx.AddTxOut(txOut2) - } else { - // send the amount to TSS self if tx is cancelled - remainingSats += amountSatoshis - } - - // 3rd output: the remaining btc to TSS self - if remainingSats > 0 { - txOut3 := wire.NewTxOut(remainingSats, payToSelfScript) - tx.AddTxOut(txOut3) - } - return nil -} - -// SignWithdrawTx receives utxos sorted by value, amount in BTC, feeRate in BTC per Kb -// TODO(revamp): simplify the function -func (signer *Signer) SignWithdrawTx( - ctx context.Context, - to btcutil.Address, - amount float64, - gasPrice *big.Int, - sizeLimit uint64, - observer *observer.Observer, - height uint64, - nonce uint64, - chain chains.Chain, - cancelTx bool, -) (*wire.MsgTx, error) { - estimateFee := float64(gasPrice.Uint64()*bitcoin.OutboundBytesMax) / 1e8 - nonceMark := chains.NonceMarkAmount(nonce) - - // refresh unspent UTXOs and continue with keysign regardless of error - err := observer.FetchUTXOs(ctx) - if err != nil { - signer.Logger(). - Std.Error(). - Err(err). - Msgf("SignGasWithdraw: FetchUTXOs error: nonce %d chain %d", nonce, chain.ChainId) - } - - // select N UTXOs to cover the total expense - prevOuts, total, consolidatedUtxo, consolidatedValue, err := observer.SelectUTXOs( - ctx, - amount+estimateFee+float64(nonceMark)*1e-8, - MaxNoOfInputsPerTx, - nonce, - consolidationRank, - false, - ) - if err != nil { - return nil, err - } - - // build tx with selected unspents - tx := wire.NewMsgTx(wire.TxVersion) - for _, prevOut := range prevOuts { - hash, err := chainhash.NewHashFromStr(prevOut.TxID) - if err != nil { - return nil, err - } - - // add input and set 'nSequence' to opt-in for RBF - outpoint := wire.NewOutPoint(hash, prevOut.Vout) - txIn := wire.NewTxIn(outpoint, nil, nil) - txIn.Sequence = rbfTxInSequenceNum - tx.AddTxIn(txIn) - } - - // size checking - // #nosec G115 always positive - txSize, err := bitcoin.EstimateOutboundSize(uint64(len(prevOuts)), []btcutil.Address{to}) if err != nil { return nil, err } - if sizeLimit < bitcoin.BtcOutboundBytesWithdrawer { // ZRC20 'withdraw' charged less fee from end user - signer.Logger().Std.Info(). - Msgf("sizeLimit %d is less than BtcOutboundBytesWithdrawer %d for nonce %d", sizeLimit, txSize, nonce) - } - if txSize < bitcoin.OutboundBytesMin { // outbound shouldn't be blocked a low sizeLimit - signer.Logger().Std.Warn(). - Msgf("txSize %d is less than outboundBytesMin %d; use outboundBytesMin", txSize, bitcoin.OutboundBytesMin) - txSize = bitcoin.OutboundBytesMin - } - if txSize > bitcoin.OutboundBytesMax { // in case of accident - signer.Logger().Std.Warn(). - Msgf("txSize %d is greater than outboundBytesMax %d; use outboundBytesMax", txSize, bitcoin.OutboundBytesMax) - txSize = bitcoin.OutboundBytesMax - } - - // fee calculation - // #nosec G115 always in range (checked above) - fees := new(big.Int).Mul(big.NewInt(int64(txSize)), gasPrice) - signer.Logger(). - Std.Info(). - Msgf("bitcoin outbound nonce %d gasPrice %s size %d fees %s consolidated %d utxos of value %v", - nonce, gasPrice.String(), txSize, fees.String(), consolidatedUtxo, consolidatedValue) - - // add tx outputs - err = signer.AddWithdrawTxOutputs(tx, to, total, amount, nonceMark, fees, cancelTx) - if err != nil { - return nil, err - } - - // sign the tx - sigHashes := txscript.NewTxSigHashes(tx, txscript.NewCannedPrevOutputFetcher([]byte{}, 0)) - witnessHashes := make([][]byte, len(tx.TxIn)) - for ix := range tx.TxIn { - amt, err := bitcoin.GetSatoshis(prevOuts[ix].Amount) - if err != nil { - return nil, err - } - pkScript, err := hex.DecodeString(prevOuts[ix].ScriptPubKey) - if err != nil { - return nil, err - } - witnessHashes[ix], err = txscript.CalcWitnessSigHash(pkScript, sigHashes, txscript.SigHashAll, tx, ix, amt) - if err != nil { - return nil, err - } - } - - sig65Bs, err := signer.TSS().SignBatch(ctx, witnessHashes, height, nonce, chain.ChainId) - if err != nil { - return nil, fmt.Errorf("SignBatch error: %v", err) - } - - for ix := range tx.TxIn { - sig65B := sig65Bs[ix] - R := &btcec.ModNScalar{} - R.SetBytes((*[32]byte)(sig65B[:32])) - S := &btcec.ModNScalar{} - S.SetBytes((*[32]byte)(sig65B[32:64])) - sig := btcecdsa.NewSignature(R, S) - - pkCompressed := signer.TSS().PubKey().Bytes(true) - hashType := txscript.SigHashAll - txWitness := wire.TxWitness{append(sig.Serialize(), byte(hashType)), pkCompressed} - tx.TxIn[ix].Witness = txWitness - } - - return tx, nil + return txscript.PayToAddrScript(tssAddrP2WPKH) } // Broadcast sends the signed transaction to the network @@ -345,7 +136,6 @@ func (signer *Signer) Broadcast(signedTx *wire.MsgTx) error { } // TryProcessOutbound signs and broadcasts a BTC transaction from a new outbound -// TODO(revamp): simplify the function func (signer *Signer) TryProcessOutbound( ctx context.Context, cctx *types.CrossChainTx, @@ -364,21 +154,18 @@ func (signer *Signer) TryProcessOutbound( }() // prepare logger + chain := signer.Chain() params := cctx.GetCurrentOutboundParam() - // prepare logger fields lf := map[string]any{ logs.FieldMethod: "TryProcessOutbound", logs.FieldCctx: cctx.Index, logs.FieldNonce: params.TssNonce, } - logger := signer.Logger().Std.With().Fields(lf).Logger() - - // support gas token only for Bitcoin outbound - coinType := cctx.InboundParams.CoinType - if coinType == coin.CoinType_Zeta || coinType == coin.CoinType_ERC20 { - logger.Error().Msg("can only send BTC to a BTC network") - return + signerAddress, err := zetacoreClient.GetKeys().GetAddress() + if err == nil { + lf["signer"] = signerAddress.String() } + logger := signer.Logger().Std.With().Fields(lf).Logger() // convert chain observer to BTC observer btcObserver, ok := chainObserver.(*observer.Observer) @@ -387,120 +174,105 @@ func (signer *Signer) TryProcessOutbound( return } - chain := btcObserver.Chain() - outboundTssNonce := params.TssNonce - signerAddress, err := zetacoreClient.GetKeys().GetAddress() + // query network info to get minRelayFee (typically 1000 satoshis) + networkInfo, err := signer.client.GetNetworkInfo() if err != nil { - logger.Error().Err(err).Msg("cannot get signer address") + logger.Error().Err(err).Msgf("failed get bitcoin network info") return } - lf["signer"] = signerAddress.String() + minRelayFee := networkInfo.RelayFee - // get size limit and gas price - sizelimit := params.CallOptions.GasLimit - gasprice, ok := new(big.Int).SetString(params.GasPrice, 10) - if !ok || gasprice.Cmp(big.NewInt(0)) < 0 { - logger.Error().Msgf("cannot convert gas price %s ", params.GasPrice) - return - } + // sign RBF replacement tx if outbound is stuck + if btcObserver.IsOutboundStuck() { + lastTx, nonce, err := btcObserver.GetLastOutbound(ctx) + if err != nil { + logger.Error().Err(err).Msg("GetLastOutbound failed") + return + } + if params.TssNonce == nonce { + tx, err := signer.SignRBFTx(ctx, cctx, lastTx, minRelayFee) + if err != nil { + logger.Error().Err(err).Msg("SignRBFTx failed") + return + } + logger.Info().Msg("SignRBFTx success") - // Check receiver P2WPKH address - to, err := chains.DecodeBtcAddress(params.Receiver, params.ReceiverChainId) - if err != nil { - logger.Error().Err(err).Msgf("cannot decode address %s ", params.Receiver) - return - } - if !chains.IsBtcAddressSupported(to) { - logger.Error().Msgf("unsupported address %s", params.Receiver) - return + // broadcast tx + signer.broadcastOutbound(ctx, tx, params.TssNonce, cctx, btcObserver, zetacoreClient) + } } - amount := float64(params.Amount.Uint64()) / 1e8 - // Add 1 satoshi/byte to gasPrice to avoid minRelayTxFee issue - networkInfo, err := signer.client.GetNetworkInfo() + // setup transaction data + txData, err := NewOutboundData(cctx, chain.ChainId, height, minRelayFee, logger, signer.Logger().Compliance) if err != nil { - logger.Error().Err(err).Msgf("cannot get bitcoin network info") + logger.Error().Err(err).Msg("failed to setup Bitcoin outbound data") return } - satPerByte := bitcoin.FeeRateToSatPerByte(networkInfo.RelayFee) - gasprice.Add(gasprice, satPerByte) - - // compliance check - restrictedCCTX := compliance.IsCctxRestricted(cctx) - if restrictedCCTX { - compliance.PrintComplianceLog(logger, signer.Logger().Compliance, - true, chain.ChainId, cctx.Index, cctx.InboundParams.Sender, params.Receiver, "BTC") - } - - // check dust amount - dustAmount := params.Amount.Uint64() < constant.BTCWithdrawalDustAmount - if dustAmount { - logger.Warn().Msgf("dust amount %d sats, canceling tx", params.Amount.Uint64()) - } - - // set the amount to 0 when the tx should be cancelled - cancelTx := restrictedCCTX || dustAmount - if cancelTx { - amount = 0.0 - } // sign withdraw tx - tx, err := signer.SignWithdrawTx( - ctx, - to, - amount, - gasprice, - sizelimit, - btcObserver, - height, - outboundTssNonce, - chain, - cancelTx, - ) + tx, err := signer.SignWithdrawTx(ctx, txData, btcObserver) if err != nil { logger.Warn().Err(err).Msg("SignWithdrawTx failed") return } - logger.Info().Msg("Key-sign success") + logger.Info().Msg("SignWithdrawTx success") - // FIXME: add prometheus metrics - _, err = zetacoreClient.GetObserverList(ctx) - if err != nil { - logger.Warn(). - Err(err).Stringer("observation_type", observertypes.ObservationType_OutboundTx). - Msg("unable to get observer list, observation") + // broadcast tx + signer.broadcastOutbound(ctx, tx, params.TssNonce, cctx, btcObserver, zetacoreClient) +} + +// broadcastOutbound sends the signed transaction to the Bitcoin network +func (signer *Signer) broadcastOutbound( + ctx context.Context, + tx *wire.MsgTx, + nonce uint64, + cctx *types.CrossChainTx, + ob *observer.Observer, + zetacoreClient interfaces.ZetacoreClient, +) { + txHash := tx.TxID() + + // prepare logger fields + lf := map[string]any{ + logs.FieldMethod: "broadcastOutbound", + logs.FieldNonce: nonce, + logs.FieldTx: txHash, + logs.FieldCctx: cctx.Index, } - if tx != nil { - outboundHash := tx.TxHash().String() - lf[logs.FieldTx] = outboundHash - - // try broacasting tx with increasing backoff (1s, 2s, 4s, 8s, 16s) in case of RPC error - backOff := broadcastBackoff - for i := 0; i < broadcastRetries; i++ { - time.Sleep(backOff) - err := signer.Broadcast(tx) - if err != nil { - logger.Warn().Err(err).Fields(lf).Msgf("Broadcasting Bitcoin tx, retry %d", i) - backOff *= 2 - continue - } - logger.Info().Fields(lf).Msgf("Broadcast Bitcoin tx successfully") - zetaHash, err := zetacoreClient.PostOutboundTracker( - ctx, - chain.ChainId, - outboundTssNonce, - outboundHash, - ) - if err != nil { - logger.Err(err).Fields(lf).Msgf("Unable to add Bitcoin outbound tracker") - } - lf[logs.FieldZetaTx] = zetaHash - logger.Info().Fields(lf).Msgf("Add Bitcoin outbound tracker successfully") + logger := signer.Logger().Std + + // try broacasting tx with increasing backoff (1s, 2s, 4s, 8s, 16s) in case of RPC error + backOff := broadcastBackoff + for i := 0; i < broadcastRetries; i++ { + time.Sleep(backOff) - // Save successfully broadcasted transaction to btc chain observer - btcObserver.SaveBroadcastedTx(outboundHash, outboundTssNonce) + // broadcast tx + err := signer.Broadcast(tx) + if err != nil { + logger.Warn().Err(err).Fields(lf).Msgf("broadcasting Bitcoin outbound, retry %d", i) + backOff *= 2 + continue + } + logger.Info().Fields(lf).Msgf("broadcasted Bitcoin outbound successfully") - break // successful broadcast; no need to retry + // save tx local db + ob.SaveBroadcastedTx(txHash, nonce) + + // add tx to outbound tracker so that all observers know about it + zetaHash, err := zetacoreClient.PostOutboundTracker(ctx, ob.Chain().ChainId, nonce, txHash) + if err != nil { + logger.Err(err).Fields(lf).Msgf("unable to add Bitcoin outbound tracker") } + lf[logs.FieldZetaTx] = zetaHash + logger.Info().Fields(lf).Msgf("add Bitcoin outbound tracker successfully") + + // try including this outbound as early as possible + _, included := ob.TryIncludeOutbound(ctx, cctx, txHash) + if included { + logger.Info().Fields(lf).Msgf("included newly broadcasted Bitcoin outbound") + } + + // successful broadcast; no need to retry + break } } diff --git a/zetaclient/chains/bitcoin/signer/signer_test.go b/zetaclient/chains/bitcoin/signer/signer_test.go index 131fbe963f..b739989f13 100644 --- a/zetaclient/chains/bitcoin/signer/signer_test.go +++ b/zetaclient/chains/bitcoin/signer/signer_test.go @@ -3,7 +3,6 @@ package signer import ( "encoding/hex" "fmt" - "math/big" "reflect" "testing" @@ -259,7 +258,7 @@ func TestAddWithdrawTxOutputs(t *testing.T) { total float64 amount float64 nonce int64 - fees *big.Int + fees int64 cancelTx bool fail bool message string @@ -272,7 +271,7 @@ func TestAddWithdrawTxOutputs(t *testing.T) { total: 1.00012000, amount: 0.2, nonce: 10000, - fees: big.NewInt(2000), + fees: 2000, fail: false, txout: []*wire.TxOut{ {Value: 10000, PkScript: tssScript}, @@ -287,7 +286,7 @@ func TestAddWithdrawTxOutputs(t *testing.T) { total: 0.20012000, amount: 0.2, nonce: 10000, - fees: big.NewInt(2000), + fees: 2000, fail: false, txout: []*wire.TxOut{ {Value: 10000, PkScript: tssScript}, @@ -301,7 +300,7 @@ func TestAddWithdrawTxOutputs(t *testing.T) { total: 1.00012000, amount: 0.2, nonce: 10000, - fees: big.NewInt(2000), + fees: 2000, cancelTx: true, fail: false, txout: []*wire.TxOut{ @@ -332,7 +331,7 @@ func TestAddWithdrawTxOutputs(t *testing.T) { total: 0.20011000, amount: 0.2, nonce: 10000, - fees: big.NewInt(2000), + fees: 2000, fail: true, message: "remainder value is negative", }, @@ -343,7 +342,7 @@ func TestAddWithdrawTxOutputs(t *testing.T) { total: 0.20022000, // 0.2 + fee + nonceMark * 2 amount: 0.2, nonce: 10000, - fees: big.NewInt(2000), + fees: 2000, fail: false, txout: []*wire.TxOut{ {Value: 10000, PkScript: tssScript}, @@ -358,7 +357,7 @@ func TestAddWithdrawTxOutputs(t *testing.T) { total: 1.00012000, amount: 0.2, nonce: 10000, - fees: big.NewInt(2000), + fees: 2000, fail: true, }, } diff --git a/zetaclient/chains/interfaces/interfaces.go b/zetaclient/chains/interfaces/interfaces.go index 8e2b8e2a3a..5fcb0ad0d5 100644 --- a/zetaclient/chains/interfaces/interfaces.go +++ b/zetaclient/chains/interfaces/interfaces.go @@ -163,6 +163,7 @@ type BTCRPCClient interface { GetTransaction(txHash *chainhash.Hash) (*btcjson.GetTransactionResult, error) GetRawTransaction(txHash *chainhash.Hash) (*btcutil.Tx, error) GetRawTransactionVerbose(txHash *chainhash.Hash) (*btcjson.TxRawResult, error) + GetMempoolEntry(txHash string) (*btcjson.GetMempoolEntryResult, error) GetBlockCount() (int64, error) GetBlockHash(blockHeight int64) (*chainhash.Hash, error) GetBlockVerbose(blockHash *chainhash.Hash) (*btcjson.GetBlockVerboseResult, error) diff --git a/zetaclient/common/constant.go b/zetaclient/common/constant.go index c54acade92..acf80a6e24 100644 --- a/zetaclient/common/constant.go +++ b/zetaclient/common/constant.go @@ -14,4 +14,7 @@ const ( // RPCStatusCheckInterval is the interval to check RPC status, 1 minute RPCStatusCheckInterval = time.Minute + + // MempoolStuckTxCheckInterval is the interval to check for stuck transactions in the mempool + MempoolStuckTxCheckInterval = time.Minute ) diff --git a/zetaclient/logs/fields.go b/zetaclient/logs/fields.go index 58880543af..c42314241b 100644 --- a/zetaclient/logs/fields.go +++ b/zetaclient/logs/fields.go @@ -9,6 +9,7 @@ const ( FieldChainNetwork = "chain_network" FieldNonce = "nonce" FieldTx = "tx" + FieldOutboundID = "outbound_id" FieldCctx = "cctx" FieldZetaTx = "zeta_tx" FieldBallot = "ballot" diff --git a/zetaclient/orchestrator/orchestrator.go b/zetaclient/orchestrator/orchestrator.go index c29b41466a..016bd560f5 100644 --- a/zetaclient/orchestrator/orchestrator.go +++ b/zetaclient/orchestrator/orchestrator.go @@ -387,11 +387,11 @@ func (oc *Orchestrator) runScheduler(ctx context.Context) error { switch { case chain.IsEVM(): - oc.ScheduleCctxEVM(ctx, zetaHeight, chainID, cctxList, ob, signer) + oc.ScheduleCCTXEVM(ctx, zetaHeight, chainID, cctxList, ob, signer) case chain.IsBitcoin(): - oc.ScheduleCctxBTC(ctx, zetaHeight, chainID, cctxList, ob, signer) + oc.ScheduleCCTXBTC(ctx, zetaHeight, chainID, cctxList, ob, signer) case chain.IsSolana(): - oc.ScheduleCctxSolana(ctx, zetaHeight, chainID, cctxList, ob, signer) + oc.ScheduleCCTXSolana(ctx, zetaHeight, chainID, cctxList, ob, signer) case chain.IsTON(): oc.ScheduleCCTXTON(ctx, zetaHeight, chainID, cctxList, ob, signer) default: @@ -409,8 +409,8 @@ func (oc *Orchestrator) runScheduler(ctx context.Context) error { } } -// ScheduleCctxEVM schedules evm outbound keysign on each ZetaChain block (the ticker) -func (oc *Orchestrator) ScheduleCctxEVM( +// ScheduleCCTXEVM schedules evm outbound keysign on each ZetaChain block (the ticker) +func (oc *Orchestrator) ScheduleCCTXEVM( ctx context.Context, zetaHeight uint64, chainID int64, @@ -508,11 +508,11 @@ func (oc *Orchestrator) ScheduleCctxEVM( } } -// ScheduleCctxBTC schedules bitcoin outbound keysign on each ZetaChain block (the ticker) +// ScheduleCCTXBTC schedules bitcoin outbound keysign on each ZetaChain block (the ticker) // 1. schedule at most one keysign per ticker // 2. schedule keysign only when nonce-mark UTXO is available // 3. stop keysign when lookahead is reached -func (oc *Orchestrator) ScheduleCctxBTC( +func (oc *Orchestrator) ScheduleCCTXBTC( ctx context.Context, zetaHeight uint64, chainID int64, @@ -583,8 +583,8 @@ func (oc *Orchestrator) ScheduleCctxBTC( } } -// ScheduleCctxSolana schedules solana outbound keysign on each ZetaChain block (the ticker) -func (oc *Orchestrator) ScheduleCctxSolana( +// ScheduleCCTXSolana schedules solana outbound keysign on each ZetaChain block (the ticker) +func (oc *Orchestrator) ScheduleCCTXSolana( ctx context.Context, zetaHeight uint64, chainID int64, diff --git a/zetaclient/testutils/mocks/btc_rpc.go b/zetaclient/testutils/mocks/btc_rpc.go index 487f4b0632..06d7934a4f 100644 --- a/zetaclient/testutils/mocks/btc_rpc.go +++ b/zetaclient/testutils/mocks/btc_rpc.go @@ -293,6 +293,36 @@ func (_m *BTCRPCClient) GetBlockVerboseTx(blockHash *chainhash.Hash) (*btcjson.G return r0, r1 } +// GetMempoolEntry provides a mock function with given fields: txHash +func (_m *BTCRPCClient) GetMempoolEntry(txHash string) (*btcjson.GetMempoolEntryResult, error) { + ret := _m.Called(txHash) + + if len(ret) == 0 { + panic("no return value specified for GetMempoolEntry") + } + + var r0 *btcjson.GetMempoolEntryResult + var r1 error + if rf, ok := ret.Get(0).(func(string) (*btcjson.GetMempoolEntryResult, error)); ok { + return rf(txHash) + } + if rf, ok := ret.Get(0).(func(string) *btcjson.GetMempoolEntryResult); ok { + r0 = rf(txHash) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*btcjson.GetMempoolEntryResult) + } + } + + if rf, ok := ret.Get(1).(func(string) error); ok { + r1 = rf(txHash) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // GetNetworkInfo provides a mock function with given fields: func (_m *BTCRPCClient) GetNetworkInfo() (*btcjson.GetNetworkInfoResult, error) { ret := _m.Called() From 2cbc42a4681b2de5dacd501fd5accb59be34d367 Mon Sep 17 00:00:00 2001 From: Charlie Chen Date: Sun, 5 Jan 2025 00:40:31 -0600 Subject: [PATCH 04/20] move functions to separate files; add unit tests for signer, RBF fee bumper, etc. --- zetaclient/chains/bitcoin/observer/db.go | 2 + zetaclient/chains/bitcoin/observer/db_test.go | 116 +++++ .../chains/bitcoin/observer/event_test.go | 6 +- .../chains/bitcoin/observer/inbound_test.go | 2 +- zetaclient/chains/bitcoin/observer/mempool.go | 47 +- .../chains/bitcoin/observer/observer.go | 64 ++- .../chains/bitcoin/observer/observer_test.go | 129 +++--- .../chains/bitcoin/observer/outbound.go | 22 +- zetaclient/chains/bitcoin/observer/utxos.go | 2 +- .../chains/bitcoin/signer/fee_bumper.go | 117 ++--- .../chains/bitcoin/signer/fee_bumper_test.go | 330 ++++++++++++++ .../chains/bitcoin/signer/outbound_data.go | 7 +- .../bitcoin/signer/outbound_data_test.go | 186 ++++++++ .../chains/bitcoin/signer/sign_withdraw.go | 3 +- .../bitcoin/signer/sign_withdraw_rbf.go | 23 +- .../bitcoin/signer/sign_withdraw_test.go | 183 ++++++++ zetaclient/chains/bitcoin/signer/signer.go | 95 ++-- .../chains/bitcoin/signer/signer_test.go | 408 +++++++++--------- zetaclient/orchestrator/bootstrap.go | 6 +- ...20c6d8465e4528fc0f3410b599dc0b26753a0.json | 95 ++++ ...20c6d8465e4528fc0f3410b599dc0b26753a0.json | 58 +++ .../testutils/mocks/zetacore_client_opts.go | 11 + zetaclient/testutils/testdata.go | 8 + zetaclient/testutils/testdata_naming.go | 5 + 24 files changed, 1473 insertions(+), 452 deletions(-) create mode 100644 zetaclient/chains/bitcoin/observer/db_test.go create mode 100644 zetaclient/chains/bitcoin/signer/fee_bumper_test.go create mode 100644 zetaclient/chains/bitcoin/signer/outbound_data_test.go create mode 100644 zetaclient/chains/bitcoin/signer/sign_withdraw_test.go create mode 100644 zetaclient/testdata/btc/chain_8332_msgtx_030cd813443f7b70cc6d8a544d320c6d8465e4528fc0f3410b599dc0b26753a0.json create mode 100644 zetaclient/testdata/btc/chain_8332_tx_030cd813443f7b70cc6d8a544d320c6d8465e4528fc0f3410b599dc0b26753a0.json diff --git a/zetaclient/chains/bitcoin/observer/db.go b/zetaclient/chains/bitcoin/observer/db.go index a8c76d88c2..8c16b7292d 100644 --- a/zetaclient/chains/bitcoin/observer/db.go +++ b/zetaclient/chains/bitcoin/observer/db.go @@ -11,6 +11,7 @@ import ( func (ob *Observer) SaveBroadcastedTx(txHash string, nonce uint64) { outboundID := ob.OutboundID(nonce) ob.Mu().Lock() + ob.tssOutboundHashes[txHash] = true ob.broadcastedTx[outboundID] = txHash ob.Mu().Unlock() @@ -59,6 +60,7 @@ func (ob *Observer) LoadBroadcastedTxMap() error { return err } for _, entry := range broadcastedTransactions { + ob.tssOutboundHashes[entry.Hash] = true ob.broadcastedTx[entry.Key] = entry.Hash } return nil diff --git a/zetaclient/chains/bitcoin/observer/db_test.go b/zetaclient/chains/bitcoin/observer/db_test.go new file mode 100644 index 0000000000..1a10fc8920 --- /dev/null +++ b/zetaclient/chains/bitcoin/observer/db_test.go @@ -0,0 +1,116 @@ +package observer_test + +import ( + "os" + "testing" + + "github.com/pkg/errors" + "github.com/stretchr/testify/require" + "github.com/zeta-chain/node/pkg/chains" + "github.com/zeta-chain/node/testutil/sample" + "github.com/zeta-chain/node/zetaclient/chains/base" + "github.com/zeta-chain/node/zetaclient/chains/bitcoin/observer" +) + +func Test_SaveBroadcastedTx(t *testing.T) { + t.Run("should be able to save broadcasted tx", func(t *testing.T) { + // test data + nonce := uint64(1) + txHash := sample.BtcHash().String() + + // create observer and open db + ob := newTestSuite(t, chains.BitcoinMainnet, "") + + // save a test tx + ob.SaveBroadcastedTx(txHash, nonce) + + // check if the txHash is a TSS outbound + require.True(t, ob.IsTSSTransaction(txHash)) + + // get the broadcasted tx + gotHash, found := ob.GetBroadcastedTx(nonce) + require.True(t, found) + require.Equal(t, txHash, gotHash) + }) +} + +func Test_LoadLastBlockScanned(t *testing.T) { + // use Bitcoin mainnet chain for testing + chain := chains.BitcoinMainnet + + t.Run("should load last block scanned", func(t *testing.T) { + // create observer and write 199 as last block scanned + ob := newTestSuite(t, chain, "") + ob.WriteLastBlockScannedToDB(199) + + // load last block scanned + err := ob.LoadLastBlockScanned() + require.NoError(t, err) + require.EqualValues(t, 199, ob.LastBlockScanned()) + }) + t.Run("should fail on invalid env var", func(t *testing.T) { + // create observer + ob := newTestSuite(t, chain, "") + + // set invalid environment variable + envvar := base.EnvVarLatestBlockByChain(chain) + os.Setenv(envvar, "invalid") + defer os.Unsetenv(envvar) + + // load last block scanned + err := ob.LoadLastBlockScanned() + require.ErrorContains(t, err, "error LoadLastBlockScanned") + }) + t.Run("should fail on RPC error", func(t *testing.T) { + // create observer on separate path, as we need to reset last block scanned + obOther := newTestSuite(t, chain, "") + + // reset last block scanned to 0 so that it will be loaded from RPC + obOther.WithLastBlockScanned(0) + + // attach a mock btc client that returns rpc error + obOther.client.ExpectedCalls = nil + obOther.client.On("GetBlockCount").Return(int64(0), errors.New("rpc error")) + + // load last block scanned + err := obOther.LoadLastBlockScanned() + require.ErrorContains(t, err, "rpc error") + }) + t.Run("should use hardcode block 100 for regtest", func(t *testing.T) { + // use regtest chain + obRegnet := newTestSuite(t, chains.BitcoinRegtest, "") + + // load last block scanned + err := obRegnet.LoadLastBlockScanned() + require.NoError(t, err) + require.EqualValues(t, observer.RegnetStartBlock, obRegnet.LastBlockScanned()) + }) +} + +func Test_LoadBroadcastedTxMap(t *testing.T) { + t.Run("should load broadcasted tx map", func(t *testing.T) { + // test data + nonce := uint64(1) + txHash := sample.BtcHash().String() + + // create observer and save a test tx + dbPath := sample.CreateTempDir(t) + obOld := newTestSuite(t, chains.BitcoinMainnet, dbPath) + obOld.SaveBroadcastedTx(txHash, nonce) + + // create new observer using same db path + obNew := newTestSuite(t, chains.BitcoinMainnet, dbPath) + + // load broadcasted tx map to new observer + err := obNew.LoadBroadcastedTxMap() + require.NoError(t, err) + + // check if the txHash is a TSS outbound + require.True(t, obNew.IsTSSTransaction(txHash)) + + // get the broadcasted tx + gotHash, found := obNew.GetBroadcastedTx(nonce) + require.True(t, found) + require.Equal(t, txHash, gotHash) + }) +} diff --git a/zetaclient/chains/bitcoin/observer/event_test.go b/zetaclient/chains/bitcoin/observer/event_test.go index ab78269527..2264ff21d0 100644 --- a/zetaclient/chains/bitcoin/observer/event_test.go +++ b/zetaclient/chains/bitcoin/observer/event_test.go @@ -306,7 +306,7 @@ func Test_IsEventProcessable(t *testing.T) { chain := chains.BitcoinMainnet // create test observer - ob := newTestSuite(t, chain) + ob := newTestSuite(t, chain, "") // setup compliance config cfg := config.Config{ @@ -354,7 +354,7 @@ func Test_NewInboundVoteFromLegacyMemo(t *testing.T) { chain := chains.BitcoinMainnet // create test observer - ob := newTestSuite(t, chain) + ob := newTestSuite(t, chain, "") ob.zetacore.WithKeys(&keys.Keys{}).WithZetaChain() t.Run("should create new inbound vote msg V1", func(t *testing.T) { @@ -394,7 +394,7 @@ func Test_NewInboundVoteFromStdMemo(t *testing.T) { chain := chains.BitcoinMainnet // create test observer - ob := newTestSuite(t, chain) + ob := newTestSuite(t, chain, "") ob.zetacore.WithKeys(&keys.Keys{}).WithZetaChain() t.Run("should create new inbound vote msg with standard memo", func(t *testing.T) { diff --git a/zetaclient/chains/bitcoin/observer/inbound_test.go b/zetaclient/chains/bitcoin/observer/inbound_test.go index b1cfe8d369..8f6f83e2aa 100644 --- a/zetaclient/chains/bitcoin/observer/inbound_test.go +++ b/zetaclient/chains/bitcoin/observer/inbound_test.go @@ -155,7 +155,7 @@ func Test_GetInboundVoteFromBtcEvent(t *testing.T) { chain := chains.BitcoinMainnet // create test observer - ob := newTestSuite(t, chain) + ob := newTestSuite(t, chain, "") ob.zetacore.WithKeys(&keys.Keys{}).WithZetaChain() // test cases diff --git a/zetaclient/chains/bitcoin/observer/mempool.go b/zetaclient/chains/bitcoin/observer/mempool.go index 4777dca7ef..7909d64c39 100644 --- a/zetaclient/chains/bitcoin/observer/mempool.go +++ b/zetaclient/chains/bitcoin/observer/mempool.go @@ -2,6 +2,7 @@ package observer import ( "context" + "time" "github.com/btcsuite/btcd/btcutil" "github.com/pkg/errors" @@ -12,11 +13,32 @@ import ( "github.com/zeta-chain/node/zetaclient/logs" ) +// LastStuckOutbound contains the last stuck outbound tx information. +type LastStuckOutbound struct { + // Nonce is the nonce of the outbound. + Nonce uint64 + + // Tx is the original transaction. + Tx *btcutil.Tx + + // StuckFor is the duration for which the tx has been stuck. + StuckFor time.Duration +} + +// NewLastStuckOutbound creates a new LastStuckOutbound struct. +func NewLastStuckOutbound(nonce uint64, tx *btcutil.Tx, stuckFor time.Duration) *LastStuckOutbound { + return &LastStuckOutbound{ + Nonce: nonce, + Tx: tx, + StuckFor: stuckFor, + } +} + // WatchMempoolTxs monitors pending outbound txs in the Bitcoin mempool. func (ob *Observer) WatchMempoolTxs(ctx context.Context) error { task := func(ctx context.Context, _ *ticker.Ticker) error { - if err := ob.checkLastStuckTx(ctx); err != nil { - ob.Logger().Chain.Err(err).Msg("checkLastStuckTx error") + if err := ob.refreshLastStuckOutbound(ctx); err != nil { + ob.Logger().Chain.Err(err).Msg("refreshLastStuckOutbound error") } return nil } @@ -30,11 +52,11 @@ func (ob *Observer) WatchMempoolTxs(ctx context.Context) error { ) } -// checkLastStuckTx checks the last stuck tx in the Bitcoin mempool. -func (ob *Observer) checkLastStuckTx(ctx context.Context) error { +// refreshLastStuckOutbound refreshes the information about the last stuck tx in the Bitcoin mempool. +func (ob *Observer) refreshLastStuckOutbound(ctx context.Context) error { // log fields lf := map[string]any{ - logs.FieldMethod: "checkLastStuckTx", + logs.FieldMethod: "refreshLastStuckOutbound", } // step 1: get last TSS transaction @@ -53,7 +75,7 @@ func (ob *Observer) checkLastStuckTx(ctx context.Context) error { return errors.Wrapf(err, "cannot determine if tx %s nonce %d is stuck", txHash, lastNonce) } - // step 3: update outbound stuck flag + // step 3: update last outbound stuck tx information // // the key ideas to determine if Bitcoin outbound is stuck/unstuck: // 1. outbound txs are a sequence of txs chained by nonce-mark UTXOs. @@ -70,14 +92,11 @@ func (ob *Observer) checkLastStuckTx(ctx context.Context) error { // Note: reserved RBF bumping fee might be not enough to clear the stuck txs during extreme traffic surges, two options: // 1. wait for the gas rate to drop. // 2. manually clear the stuck txs by using offline accelerator services. - stuckAlready := ob.IsOutboundStuck() if stuck { - ob.logger.Outbound.Warn().Fields(lf).Msgf("Bitcoin outbound is stuck for %f minutes", stuckFor.Minutes()) - } - if !stuck && stuckAlready { - ob.logger.Outbound.Info().Fields(lf).Msgf("Bitcoin outbound is no longer stuck") + ob.SetLastStuckOutbound(NewLastStuckOutbound(lastNonce, lastTx, stuckFor)) + } else { + ob.SetLastStuckOutbound(nil) } - ob.setOutboundStuck(stuck) return nil } @@ -103,7 +122,7 @@ func (ob *Observer) GetLastOutbound(ctx context.Context) (*btcutil.Tx, uint64, e // source 1: // pick highest nonce tx from included txs lastNonce = pendingNonce - 1 - txResult := ob.getIncludedTx(lastNonce) + txResult := ob.GetIncludedTx(lastNonce) if txResult == nil { // should NEVER happen by design return nil, 0, errors.New("last included tx not found") @@ -118,7 +137,7 @@ func (ob *Observer) GetLastOutbound(ctx context.Context) (*btcutil.Tx, uint64, e } for nonce := uint64(p.NonceLow); nonce < uint64(p.NonceHigh); nonce++ { if nonce > lastNonce { - txID, found := ob.getBroadcastedTx(nonce) + txID, found := ob.GetBroadcastedTx(nonce) if found { lastNonce = nonce lastHash = txID diff --git a/zetaclient/chains/bitcoin/observer/observer.go b/zetaclient/chains/bitcoin/observer/observer.go index 93da5c36a3..e0c374c842 100644 --- a/zetaclient/chains/bitcoin/observer/observer.go +++ b/zetaclient/chains/bitcoin/observer/observer.go @@ -17,6 +17,7 @@ import ( "github.com/zeta-chain/node/zetaclient/chains/base" "github.com/zeta-chain/node/zetaclient/chains/interfaces" "github.com/zeta-chain/node/zetaclient/db" + "github.com/zeta-chain/node/zetaclient/logs" "github.com/zeta-chain/node/zetaclient/metrics" ) @@ -65,14 +66,15 @@ type Observer struct { // pendingNonce is the outbound artificial pending nonce pendingNonce uint64 - // outboundStuck is the flag to indicate if the outbound is stuck in the mempool - outboundStuck bool + // lastStuckTx contains the last stuck outbound tx information + // Note: nil if outbound is not stuck + lastStuckTx *LastStuckOutbound // utxos contains the UTXOs owned by the TSS address utxos []btcjson.ListUnspentResult - // includedTxHashes indexes included tx with tx hash - includedTxHashes map[string]bool + // tssOutboundHashes keeps track of outbound hashes sent from TSS address + tssOutboundHashes map[string]bool // includedTxResults indexes tx results with the outbound tx identifier includedTxResults map[string]*btcjson.GetTransactionResult @@ -122,7 +124,7 @@ func NewObserver( netParams: netParams, btcClient: btcClient, utxos: []btcjson.ListUnspentResult{}, - includedTxHashes: make(map[string]bool), + tssOutboundHashes: make(map[string]bool), includedTxResults: make(map[string]*btcjson.GetTransactionResult), broadcastedTx: make(map[string]string), logger: Logger{ @@ -188,6 +190,13 @@ func (ob *Observer) GetPendingNonce() uint64 { return ob.pendingNonce } +// SetPendingNonce sets the artificial pending nonce +func (ob *Observer) SetPendingNonce(nonce uint64) { + ob.Mu().Lock() + defer ob.Mu().Unlock() + ob.pendingNonce = nonce +} + // ConfirmationsThreshold returns number of required Bitcoin confirmations depending on sent BTC amount. func (ob *Observer) ConfirmationsThreshold(amount *big.Int) int64 { if amount.Cmp(big.NewInt(BigValueSats)) >= 0 { @@ -234,36 +243,47 @@ func (ob *Observer) GetBlockByNumberCached(blockNumber int64) (*BTCBlockNHeader, return blockNheader, nil } -// IsOutboundStuck returns true if the outbound is stuck in the mempool -func (ob *Observer) IsOutboundStuck() bool { +// GetLastStuckOutbound returns the last stuck outbound tx information +func (ob *Observer) GetLastStuckOutbound() *LastStuckOutbound { ob.Mu().Lock() defer ob.Mu().Unlock() - return ob.outboundStuck + return ob.lastStuckTx } -// isTSSTransaction checks if a given transaction was sent by TSS itself. -// An unconfirmed transaction is safe to spend only if it was sent by TSS and verified by ourselves. -func (ob *Observer) isTSSTransaction(txid string) bool { - _, found := ob.includedTxHashes[txid] - return found -} +// SetLastStuckOutbound sets the information of last stuck outbound +func (ob *Observer) SetLastStuckOutbound(stuckTx *LastStuckOutbound) { + lf := map[string]any{ + logs.FieldMethod: "SetLastStuckOutbound", + } -// setPendingNonce sets the artificial pending nonce -func (ob *Observer) setPendingNonce(nonce uint64) { ob.Mu().Lock() defer ob.Mu().Unlock() - ob.pendingNonce = nonce + + if stuckTx != nil { + lf[logs.FieldNonce] = stuckTx.Nonce + lf[logs.FieldTx] = stuckTx.Tx.MsgTx().TxID() + ob.logger.Outbound.Warn(). + Fields(lf). + Msgf("Bitcoin outbound is stuck for %f minutes", stuckTx.StuckFor.Minutes()) + } else if ob.lastStuckTx != nil { + lf[logs.FieldNonce] = ob.lastStuckTx.Nonce + lf[logs.FieldTx] = ob.lastStuckTx.Tx.MsgTx().TxID() + ob.logger.Outbound.Info().Fields(lf).Msgf("Bitcoin outbound is no longer stuck") + } + ob.lastStuckTx = stuckTx } -// setOutboundStuck sets the outbound stuck flag -func (ob *Observer) setOutboundStuck(stuck bool) { +// IsTSSTransaction checks if a given transaction was sent by TSS itself. +// An unconfirmed transaction is safe to spend only if it was sent by TSS self. +func (ob *Observer) IsTSSTransaction(txid string) bool { ob.Mu().Lock() defer ob.Mu().Unlock() - ob.outboundStuck = stuck + _, found := ob.tssOutboundHashes[txid] + return found } -// getBroadcastedTx gets successfully broadcasted transaction by nonce -func (ob *Observer) getBroadcastedTx(nonce uint64) (string, bool) { +// GetBroadcastedTx gets successfully broadcasted transaction by nonce +func (ob *Observer) GetBroadcastedTx(nonce uint64) (string, bool) { ob.Mu().Lock() defer ob.Mu().Unlock() diff --git a/zetaclient/chains/bitcoin/observer/observer_test.go b/zetaclient/chains/bitcoin/observer/observer_test.go index ab5415ef66..88dcfd0b27 100644 --- a/zetaclient/chains/bitcoin/observer/observer_test.go +++ b/zetaclient/chains/bitcoin/observer/observer_test.go @@ -5,14 +5,15 @@ import ( "os" "strconv" "testing" + "time" "github.com/btcsuite/btcd/btcjson" + "github.com/btcsuite/btcd/btcutil" "github.com/btcsuite/btcd/wire" - "github.com/pkg/errors" - "github.com/rs/zerolog" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" "github.com/zeta-chain/node/zetaclient/db" + "github.com/zeta-chain/node/zetaclient/testutils" "gorm.io/gorm" "github.com/zeta-chain/node/pkg/chains" @@ -164,7 +165,7 @@ func Test_NewObserver(t *testing.T) { func Test_BlockCache(t *testing.T) { t.Run("should add and get block from cache", func(t *testing.T) { // create observer - ob := newTestSuite(t, chains.BitcoinMainnet) + ob := newTestSuite(t, chains.BitcoinMainnet, "") // feed block hash, header and block to btc client hash := sample.BtcHash() @@ -188,7 +189,7 @@ func Test_BlockCache(t *testing.T) { }) t.Run("should fail if stored type is not BlockNHeader", func(t *testing.T) { // create observer - ob := newTestSuite(t, chains.BitcoinMainnet) + ob := newTestSuite(t, chains.BitcoinMainnet, "") // add a string to cache blockNumber := int64(100) @@ -201,62 +202,22 @@ func Test_BlockCache(t *testing.T) { }) } -func Test_LoadLastBlockScanned(t *testing.T) { - // use Bitcoin mainnet chain for testing - chain := chains.BitcoinMainnet - - t.Run("should load last block scanned", func(t *testing.T) { - // create observer and write 199 as last block scanned - ob := newTestSuite(t, chain) - ob.WriteLastBlockScannedToDB(199) - - // load last block scanned - err := ob.LoadLastBlockScanned() - require.NoError(t, err) - require.EqualValues(t, 199, ob.LastBlockScanned()) - }) - t.Run("should fail on invalid env var", func(t *testing.T) { - // create observer - ob := newTestSuite(t, chain) +func Test_SetPendingNonce(t *testing.T) { + // create observer + ob := newTestSuite(t, chains.BitcoinMainnet, "") - // set invalid environment variable - envvar := base.EnvVarLatestBlockByChain(chain) - os.Setenv(envvar, "invalid") - defer os.Unsetenv(envvar) + // ensure pending nonce is 0 + require.Zero(t, ob.GetPendingNonce()) - // load last block scanned - err := ob.LoadLastBlockScanned() - require.ErrorContains(t, err, "error LoadLastBlockScanned") - }) - t.Run("should fail on RPC error", func(t *testing.T) { - // create observer on separate path, as we need to reset last block scanned - obOther := newTestSuite(t, chain) - - // reset last block scanned to 0 so that it will be loaded from RPC - obOther.WithLastBlockScanned(0) - - // attach a mock btc client that returns rpc error - obOther.client.ExpectedCalls = nil - obOther.client.On("GetBlockCount").Return(int64(0), errors.New("rpc error")) - - // load last block scanned - err := obOther.LoadLastBlockScanned() - require.ErrorContains(t, err, "rpc error") - }) - t.Run("should use hardcode block 100 for regtest", func(t *testing.T) { - // use regtest chain - obRegnet := newTestSuite(t, chains.BitcoinRegtest) - - // load last block scanned - err := obRegnet.LoadLastBlockScanned() - require.NoError(t, err) - require.EqualValues(t, observer.RegnetStartBlock, obRegnet.LastBlockScanned()) - }) + // set and get pending nonce + nonce := uint64(100) + ob.SetPendingNonce(nonce) + require.Equal(t, nonce, ob.GetPendingNonce()) } func TestConfirmationThreshold(t *testing.T) { chain := chains.BitcoinMainnet - ob := newTestSuite(t, chain) + ob := newTestSuite(t, chain, "") t.Run("should return confirmations in chain param", func(t *testing.T) { ob.SetChainParams(observertypes.ChainParams{ConfirmationCount: 3}) @@ -278,6 +239,39 @@ func TestConfirmationThreshold(t *testing.T) { }) } +func Test_SetLastStuckOutbound(t *testing.T) { + // create observer and example stuck tx + ob := newTestSuite(t, chains.BitcoinMainnet, "") + tx := btcutil.NewTx(wire.NewMsgTx(wire.TxVersion)) + + // STEP 1 + // initial stuck outbound is nil + require.Nil(t, ob.GetLastStuckOutbound()) + + // STEP 2 + // set stuck outbound + stuckTx := observer.NewLastStuckOutbound(100, tx, 30*time.Minute) + ob.SetLastStuckOutbound(stuckTx) + + // retrieve stuck outbound + require.Equal(t, stuckTx, ob.GetLastStuckOutbound()) + + // STEP 3 + // update stuck outbound + stuckTxUpdate := observer.NewLastStuckOutbound(101, tx, 40*time.Minute) + ob.SetLastStuckOutbound(stuckTxUpdate) + + // retrieve updated stuck outbound + require.Equal(t, stuckTxUpdate, ob.GetLastStuckOutbound()) + + // STEP 4 + // clear stuck outbound + ob.SetLastStuckOutbound(nil) + + // stuck outbound should be nil + require.Nil(t, ob.GetLastStuckOutbound()) +} + func TestSubmittedTx(t *testing.T) { // setup db db, submittedTx := setupDBTxResults(t) @@ -304,7 +298,7 @@ type testSuite struct { db *db.DB } -func newTestSuite(t *testing.T, chain chains.Chain) *testSuite { +func newTestSuite(t *testing.T, chain chains.Chain, dbPath string) *testSuite { require.True(t, chain.IsBitcoinChain()) chainParams := mocks.MockChainParams(chain.ChainId, 10) @@ -314,19 +308,34 @@ func newTestSuite(t *testing.T, chain chains.Chain) *testSuite { zetacore := mocks.NewZetacoreClient(t) - database, err := db.NewFromSqliteInMemory(true) - require.NoError(t, err) + var tss interfaces.TSSSigner + if chains.IsBitcoinMainnet(chain.ChainId) { + tss = mocks.NewTSS(t).FakePubKey(testutils.TSSPubKeyMainnet) + } else { + tss = mocks.NewTSS(t).FakePubKey(testutils.TSSPubkeyAthens3) + } - log := zerolog.New(zerolog.NewTestWriter(t)) + // create test database + var err error + var database *db.DB + if dbPath == "" { + database, err = db.NewFromSqliteInMemory(true) + } else { + database, err = db.NewFromSqlite(dbPath, "test.db", true) + } + require.NoError(t, err) + // create observer + //log := zerolog.New(zerolog.NewTestWriter(t)) ob, err := observer.NewObserver( chain, client, chainParams, zetacore, - nil, + tss, database, - base.Logger{Std: log, Compliance: log}, + base.DefaultLogger(), + //base.Logger{Std: log, Compliance: log}, nil, ) require.NoError(t, err) diff --git a/zetaclient/chains/bitcoin/observer/outbound.go b/zetaclient/chains/bitcoin/observer/outbound.go index ab56c4acd0..1c7983043c 100644 --- a/zetaclient/chains/bitcoin/observer/outbound.go +++ b/zetaclient/chains/bitcoin/observer/outbound.go @@ -131,7 +131,7 @@ func (ob *Observer) TryIncludeOutbound( // check tx inclusion and save tx result txResult, included := ob.checkTxInclusion(ctx, cctx, txHash) if included { - ob.setIncludedTx(nonce, txResult) + ob.SetIncludedTx(nonce, txResult) } return txResult, included @@ -277,7 +277,7 @@ func (ob *Observer) refreshPendingNonce(ctx context.Context) { } // set 'NonceLow' as the new pending nonce - ob.setPendingNonce(nonceLow) + ob.SetPendingNonce(nonceLow) ob.logger.Chain.Info(). Msgf("refreshPendingNonce: increase pending nonce to %d with txid %s", nonceLow, txid) } @@ -289,7 +289,7 @@ func (ob *Observer) getOutboundHashByNonce(ctx context.Context, nonce uint64, te // There are 2 types of txids an observer can trust // 1. The ones had been verified and saved by observer self. // 2. The ones had been finalized in zetacore based on majority vote. - if res := ob.getIncludedTx(nonce); res != nil { + if res := ob.GetIncludedTx(nonce); res != nil { return res.TxID, nil } if !test { // if not unit test, get cctx from zetacore @@ -352,10 +352,10 @@ func (ob *Observer) checkTxInclusion( return txResult, true } -// setIncludedTx saves included tx result in memory. +// SetIncludedTx saves included tx result in memory. // - the outbounds are chained (by nonce) txs sequentially included. // - tx results may still be set in arbitrary order as the method is called across goroutines, and it doesn't matter. -func (ob *Observer) setIncludedTx(nonce uint64, getTxResult *btcjson.GetTransactionResult) { +func (ob *Observer) SetIncludedTx(nonce uint64, getTxResult *btcjson.GetTransactionResult) { var ( txHash = getTxResult.TxID outboundID = ob.OutboundID(nonce) @@ -374,7 +374,7 @@ func (ob *Observer) setIncludedTx(nonce uint64, getTxResult *btcjson.GetTransact // for new hash: // - include new outbound and enforce rigid 1-to-1 mapping: nonce <===> txHash // - try increasing pending nonce on every newly included outbound - ob.includedTxHashes[txHash] = true + ob.tssOutboundHashes[txHash] = true ob.includedTxResults[outboundID] = getTxResult if nonce >= ob.pendingNonce { ob.pendingNonce = nonce + 1 @@ -392,17 +392,17 @@ func (ob *Observer) setIncludedTx(nonce uint64, getTxResult *btcjson.GetTransact ob.logger.Outbound.Info().Fields(lf).Msgf("replaced bitcoin outbound %s", res.TxID) // remove prior txHash and txResult - delete(ob.includedTxHashes, res.TxID) + delete(ob.tssOutboundHashes, res.TxID) delete(ob.includedTxResults, outboundID) // add new txHash and txResult - ob.includedTxHashes[txHash] = true + ob.tssOutboundHashes[txHash] = true ob.includedTxResults[outboundID] = getTxResult } } -// getIncludedTx gets the receipt and transaction from memory -func (ob *Observer) getIncludedTx(nonce uint64) *btcjson.GetTransactionResult { +// GetIncludedTx gets the receipt and transaction from memory +func (ob *Observer) GetIncludedTx(nonce uint64) *btcjson.GetTransactionResult { ob.Mu().Lock() defer ob.Mu().Unlock() return ob.includedTxResults[ob.OutboundID(nonce)] @@ -429,7 +429,7 @@ func (ob *Observer) checkTssOutboundResult( nonce := params.TssNonce rawResult, err := rpc.GetRawTxResult(ob.btcClient, hash, res) if err != nil { - return errors.Wrapf(err, "checkTssOutboundResult: error GetRawTxResultByHash %s", hash.String()) + return errors.Wrapf(err, "checkTssOutboundResult: error GetRawTxResult %s", hash.String()) } err = ob.checkTSSVin(ctx, rawResult.Vin, nonce) if err != nil { diff --git a/zetaclient/chains/bitcoin/observer/utxos.go b/zetaclient/chains/bitcoin/observer/utxos.go index fa9f65e915..5b0275101f 100644 --- a/zetaclient/chains/bitcoin/observer/utxos.go +++ b/zetaclient/chains/bitcoin/observer/utxos.go @@ -100,7 +100,7 @@ func (ob *Observer) FetchUTXOs(ctx context.Context) error { } // we don't want to spend other people's unconfirmed UTXOs as they may not be safe to spend if utxo.Confirmations == 0 { - if !ob.isTSSTransaction(utxo.TxID) { + if !ob.IsTSSTransaction(utxo.TxID) { continue } } diff --git a/zetaclient/chains/bitcoin/signer/fee_bumper.go b/zetaclient/chains/bitcoin/signer/fee_bumper.go index c4665b0280..f5b203b521 100644 --- a/zetaclient/chains/bitcoin/signer/fee_bumper.go +++ b/zetaclient/chains/bitcoin/signer/fee_bumper.go @@ -28,58 +28,77 @@ type MempoolTxsInfoFetcher func(interfaces.BTCRPCClient, string) (int64, float64 // CPFPFeeBumper is a helper struct to contain CPFP (child-pays-for-parent) fee bumping logic type CPFPFeeBumper struct { - // client is the RPC client to interact with the Bitcoin chain - client interfaces.BTCRPCClient + // Client is the RPC Client to interact with the Bitcoin chain + Client interfaces.BTCRPCClient - // tx is the stuck transaction to bump - tx *btcutil.Tx + // Tx is the stuck transaction to bump + Tx *btcutil.Tx - // minRelayFee is the minimum relay fee in BTC - minRelayFee float64 + // MinRelayFee is the minimum relay fee in BTC + MinRelayFee float64 - // cctxRate is the most recent fee rate of the CCTX - cctxRate int64 + // CCTXRate is the most recent fee rate of the CCTX + CCTXRate int64 - // liveRate is the most recent market fee rate - liveRate int64 + // LiveRate is the most recent market fee rate + LiveRate int64 - // totalTxs is the total number of stuck TSS txs - totalTxs int64 + // TotalTxs is the total number of stuck TSS txs + TotalTxs int64 - // totalFees is the total fees of all stuck TSS txs - totalFees int64 + // TotalFees is the total fees of all stuck TSS txs + TotalFees int64 - // totalVSize is the total vsize of all stuck TSS txs - totalVSize int64 + // TotalVSize is the total vsize of all stuck TSS txs + TotalVSize int64 - // avgFeeRate is the average fee rate of all stuck TSS txs - avgFeeRate int64 + // AvgFeeRate is the average fee rate of all stuck TSS txs + AvgFeeRate int64 } // NewCPFPFeeBumper creates a new CPFPFeeBumper func NewCPFPFeeBumper( client interfaces.BTCRPCClient, + memplTxsInfoFetcher MempoolTxsInfoFetcher, tx *btcutil.Tx, cctxRate int64, minRelayFee float64, -) *CPFPFeeBumper { - return &CPFPFeeBumper{ - client: client, - tx: tx, - minRelayFee: minRelayFee, - cctxRate: cctxRate, + logger zerolog.Logger, +) (*CPFPFeeBumper, error) { + fb := &CPFPFeeBumper{ + Client: client, + Tx: tx, + MinRelayFee: minRelayFee, + CCTXRate: cctxRate, } + + err := fb.FetchFeeBumpInfo(memplTxsInfoFetcher, logger) + if err != nil { + return nil, err + } + return fb, nil } -// BumpTxFee bumps the fee of the stuck transactions +// BumpTxFee bumps the fee of the stuck transaction using reserved bump fees func (b *CPFPFeeBumper) BumpTxFee() (*wire.MsgTx, int64, error) { + // reuse old tx body and clear witness data (e.g., signatures) + newTx := b.Tx.MsgTx().Copy() + for idx := range newTx.TxIn { + newTx.TxIn[idx].Witness = wire.TxWitness{} + } + + // check reserved bump fees amount in the original tx + if len(newTx.TxOut) < 3 { + return nil, 0, errors.New("original tx has no reserved bump fees") + } + // tx replacement is triggered only when market fee rate goes 20% higher than current paid fee rate. // zetacore updates the cctx fee rate evey 10 minutes, we could hold on and retry later. - minBumpRate := int64(math.Ceil(float64(b.avgFeeRate) * minCPFPFeeBumpFactor)) - if b.cctxRate < minBumpRate { + minBumpRate := int64(math.Ceil(float64(b.AvgFeeRate) * minCPFPFeeBumpFactor)) + if b.CCTXRate < minBumpRate { return nil, 0, fmt.Errorf( "hold on RBF: cctx rate %d is lower than the min bumped rate %d", - b.cctxRate, + b.CCTXRate, minBumpRate, ) } @@ -87,19 +106,19 @@ func (b *CPFPFeeBumper) BumpTxFee() (*wire.MsgTx, int64, error) { // the live rate may continue increasing during network congestion, we should wait until it stabilizes a bit. // this is to ensure the live rate is not 20%+ higher than the cctx rate, otherwise, the replacement tx may // also get stuck and need another replacement. - bumpedRate := int64(math.Ceil(float64(b.cctxRate) * minCPFPFeeBumpFactor)) - if b.liveRate > bumpedRate { + bumpedRate := int64(math.Ceil(float64(b.CCTXRate) * minCPFPFeeBumpFactor)) + if b.LiveRate > bumpedRate { return nil, 0, fmt.Errorf( "hold on RBF: live rate %d is much higher than the cctx rate %d", - b.liveRate, - b.cctxRate, + b.LiveRate, + b.CCTXRate, ) } // calculate minmimum relay fees of the new replacement tx // the new tx will have almost same size as the old one because the tx body stays the same - txVSize := mempool.GetTxVirtualSize(b.tx) - minRelayFeeRate := rpc.FeeRateToSatPerByte(b.minRelayFee) + txVSize := mempool.GetTxVirtualSize(b.Tx) + minRelayFeeRate := rpc.FeeRateToSatPerByte(b.MinRelayFee) minRelayTxFees := txVSize * minRelayFeeRate.Int64() // calculate the RBF additional fees required by Bitcoin protocol @@ -108,28 +127,18 @@ func (b *CPFPFeeBumper) BumpTxFee() (*wire.MsgTx, int64, error) { // 2. additionalFees >= minRelayTxFees // // see: https://github.com/bitcoin/bitcoin/blob/master/src/policy/rbf.cpp#L166-L183 - additionalFees := b.totalVSize*b.cctxRate - b.totalFees + additionalFees := b.TotalVSize*b.CCTXRate - b.TotalFees if additionalFees < minRelayTxFees { additionalFees = minRelayTxFees } - // copy the old tx and clear witness data (e.g., signatures) - newTx := b.tx.MsgTx().Copy() - for idx := range newTx.TxIn { - newTx.TxIn[idx].Witness = wire.TxWitness{} - } - - // check reserved bump fees amount in the original tx - if len(newTx.TxOut) < 3 { - return nil, 0, errors.New("original tx has no reserved bump fees") - } - // bump fees in two ways: // 1. deduct additional fees from the change amount // 2. give up the whole change amount if it's not enough if newTx.TxOut[2].Value >= additionalFees+constant.BTCWithdrawalDustAmount { newTx.TxOut[2].Value -= additionalFees } else { + additionalFees = newTx.TxOut[2].Value newTx.TxOut = newTx.TxOut[:2] } @@ -139,26 +148,26 @@ func (b *CPFPFeeBumper) BumpTxFee() (*wire.MsgTx, int64, error) { // fetchFeeBumpInfo fetches all necessary information needed to bump the stuck tx func (b *CPFPFeeBumper) FetchFeeBumpInfo(memplTxsInfoFetcher MempoolTxsInfoFetcher, logger zerolog.Logger) error { // query live network fee rate - liveRate, err := rpc.GetEstimatedFeeRate(b.client, 1) + liveRate, err := rpc.GetEstimatedFeeRate(b.Client, 1) if err != nil { return errors.Wrap(err, "GetEstimatedFeeRate failed") } - b.liveRate = liveRate + b.LiveRate = liveRate // query total fees and sizes of all pending parent TSS txs - totalTxs, totalFees, totalVSize, avgFeeRate, err := memplTxsInfoFetcher(b.client, b.tx.MsgTx().TxID()) + totalTxs, totalFees, totalVSize, avgFeeRate, err := memplTxsInfoFetcher(b.Client, b.Tx.MsgTx().TxID()) if err != nil { - return errors.Wrap(err, "GetTotalMempoolParentsSizeNFees failed") + return errors.Wrap(err, "unable to fetch mempool txs info") } totalFeesSats, err := bitcoin.GetSatoshis(totalFees) if err != nil { return errors.Wrapf(err, "cannot convert total fees %f", totalFees) } - b.totalTxs = totalTxs - b.totalFees = totalFeesSats - b.totalVSize = totalVSize - b.avgFeeRate = avgFeeRate + b.TotalTxs = totalTxs + b.TotalFees = totalFeesSats + b.TotalVSize = totalVSize + b.AvgFeeRate = avgFeeRate logger.Info(). Msgf("totalTxs %d, totalFees %f, totalVSize %d, avgFeeRate %d", totalTxs, totalFees, totalVSize, avgFeeRate) diff --git a/zetaclient/chains/bitcoin/signer/fee_bumper_test.go b/zetaclient/chains/bitcoin/signer/fee_bumper_test.go new file mode 100644 index 0000000000..ea509a44e4 --- /dev/null +++ b/zetaclient/chains/bitcoin/signer/fee_bumper_test.go @@ -0,0 +1,330 @@ +package signer_test + +import ( + "testing" + + "github.com/btcsuite/btcd/btcjson" + "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/wire" + "github.com/pkg/errors" + "github.com/rs/zerolog/log" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + "github.com/zeta-chain/node/pkg/chains" + "github.com/zeta-chain/node/pkg/constant" + "github.com/zeta-chain/node/zetaclient/chains/bitcoin/signer" + "github.com/zeta-chain/node/zetaclient/chains/interfaces" + "github.com/zeta-chain/node/zetaclient/testutils" + "github.com/zeta-chain/node/zetaclient/testutils/mocks" +) + +func Test_NewCPFPFeeBumper(t *testing.T) { + tests := []struct { + name string + client *mocks.BTCRPCClient + tx *btcutil.Tx + cctxRate int64 + liveRate float64 + minRelayFee float64 + memplTxsInfoFetcher signer.MempoolTxsInfoFetcher + errMsg string + expected *signer.CPFPFeeBumper + }{ + { + name: "should create new CPFPFeeBumper successfully", + client: mocks.NewBTCRPCClient(t), + tx: btcutil.NewTx(wire.NewMsgTx(wire.TxVersion)), + cctxRate: 10, + liveRate: 0.00012, + minRelayFee: 0.00001, + memplTxsInfoFetcher: makeMempoolTxsInfoFetcher( + 2, // 2 stuck TSS txs + 0.0001, // total fees 0.0001 BTC + 1000, // total vsize 1000 + 10, // average fee rate 10 sat/vbyte + "", // no error + ), + expected: &signer.CPFPFeeBumper{ + Tx: btcutil.NewTx(wire.NewMsgTx(wire.TxVersion)), + MinRelayFee: 0.00001, + CCTXRate: 10, + LiveRate: 12, + TotalTxs: 2, + TotalFees: 10000, + TotalVSize: 1000, + AvgFeeRate: 10, + }, + }, + { + name: "should fail when mempool txs info fetcher returns error", + client: mocks.NewBTCRPCClient(t), + tx: btcutil.NewTx(wire.NewMsgTx(wire.TxVersion)), + liveRate: 0.00012, + //memplTxsInfoFetcher: makeMempoolTxsInfoFetcher(0, 0.0, 0, 0, "rpc error"), + memplTxsInfoFetcher: makeMempoolTxsInfoFetcher( + 2, // 2 stuck TSS txs + 0.0001, // total fees 0.0001 BTC + 1000, // total vsize 1000 + 10, // average fee rate 10 sat/vbyte + "err", // no error + ), + errMsg: "unable to fetch mempool txs info", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // mock RPC fee rate + tt.client.On("EstimateSmartFee", mock.Anything, mock.Anything).Return(&btcjson.EstimateSmartFeeResult{ + FeeRate: &tt.liveRate, + }, nil) + + bumper, err := signer.NewCPFPFeeBumper( + tt.client, + tt.memplTxsInfoFetcher, + tt.tx, + tt.cctxRate, + tt.minRelayFee, + log.Logger, + ) + if tt.errMsg != "" { + require.Nil(t, bumper) + require.ErrorContains(t, err, tt.errMsg) + } else { + bumper.Client = nil // ignore the RPC client + require.NoError(t, err) + require.Equal(t, tt.expected, bumper) + } + }) + } +} + +func Test_BumpTxFee(t *testing.T) { + // https://mempool.space/tx/030cd813443f7b70cc6d8a544d320c6d8465e4528fc0f3410b599dc0b26753a0 + chain := chains.BitcoinMainnet + txid := "030cd813443f7b70cc6d8a544d320c6d8465e4528fc0f3410b599dc0b26753a0" + msgTx := testutils.LoadBTCMsgTx(t, TestDataDir, chain.ChainId, txid).Copy() + + // cleanMsgTx is a helper function to clean witness data + cleanMsgTx := func(tx *wire.MsgTx) *wire.MsgTx { + newTx := tx.Copy() + for idx := range newTx.TxIn { + newTx.TxIn[idx].Witness = wire.TxWitness{} + } + return newTx + } + + tests := []struct { + name string + feeBumper *signer.CPFPFeeBumper + errMsg string + additionalFees int64 + expectedTx *wire.MsgTx + }{ + { + name: "should bump tx fee successfully", + feeBumper: &signer.CPFPFeeBumper{ + Tx: btcutil.NewTx(msgTx), + MinRelayFee: 0.00001, + CCTXRate: 57, + LiveRate: 60, + TotalFees: 27213, + TotalVSize: 579, + AvgFeeRate: 47, + }, + additionalFees: 5790, + expectedTx: func() *wire.MsgTx { + // deduct additional fees + newTx := cleanMsgTx(msgTx) + newTx.TxOut[2].Value -= 5790 + return newTx + }(), + }, + { + name: "should cover min relay fees", + feeBumper: &signer.CPFPFeeBumper{ + Tx: btcutil.NewTx(msgTx), + MinRelayFee: 0.00002, // min relay fee will be 579vB * 2 = 1158 sats + CCTXRate: 6, + LiveRate: 8, + TotalFees: 2895, + TotalVSize: 579, + AvgFeeRate: 5, + }, + additionalFees: 1158, + expectedTx: func() *wire.MsgTx { + // deduct additional fees + newTx := cleanMsgTx(msgTx) + newTx.TxOut[2].Value -= 1158 + return newTx + }(), + }, + { + name: "should give up all reserved bump fees", + feeBumper: &signer.CPFPFeeBumper{ + Tx: func() *btcutil.Tx { + // modify reserved bump fees to barely cover bump fees + newTx := msgTx.Copy() + newTx.TxOut[2].Value = 5790 + constant.BTCWithdrawalDustAmount - 1 + return btcutil.NewTx(newTx) + }(), + MinRelayFee: 0.00001, + CCTXRate: 57, + LiveRate: 60, + TotalFees: 27213, + TotalVSize: 579, + AvgFeeRate: 47, + }, + additionalFees: 5790 + constant.BTCWithdrawalDustAmount - 1, // 6789 + expectedTx: func() *wire.MsgTx { + // give up all reserved bump fees + newTx := cleanMsgTx(msgTx) + newTx.TxOut = newTx.TxOut[:2] + return newTx + }(), + }, + { + name: "should fail if original tx has no reserved bump fees", + feeBumper: &signer.CPFPFeeBumper{ + Tx: func() *btcutil.Tx { + // remove the change output + newTx := msgTx.Copy() + newTx.TxOut = newTx.TxOut[:2] + return btcutil.NewTx(newTx) + }(), + }, + errMsg: "original tx has no reserved bump fees", + }, + { + name: "should hold on RBF if CCTX rate is lower than minimum bumpeed rate", + feeBumper: &signer.CPFPFeeBumper{ + Tx: btcutil.NewTx(msgTx), + CCTXRate: 56, // 56 < 47 * 120% + AvgFeeRate: 47, + }, + errMsg: "lower than the min bumped rate", + }, + { + name: "should hold on RBF if live rate is much higher than CCTX rate", + feeBumper: &signer.CPFPFeeBumper{ + Tx: btcutil.NewTx(msgTx), + CCTXRate: 57, + LiveRate: 70, // 70 > 57 * 120% + AvgFeeRate: 47, + }, + errMsg: "much higher than the cctx rate", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + newTx, additionalFees, err := tt.feeBumper.BumpTxFee() + if tt.errMsg != "" { + require.Nil(t, newTx) + require.Zero(t, additionalFees) + require.ErrorContains(t, err, tt.errMsg) + } else { + require.NoError(t, err) + require.Equal(t, tt.expectedTx, newTx) + require.Equal(t, tt.additionalFees, additionalFees) + } + }) + } +} + +func Test_FetchFeeBumpInfo(t *testing.T) { + liveRate := 0.00012 + mockClient := mocks.NewBTCRPCClient(t) + mockClient.On("EstimateSmartFee", mock.Anything, mock.Anything).Return(&btcjson.EstimateSmartFeeResult{ + FeeRate: &liveRate, + }, nil) + + tests := []struct { + name string + client *mocks.BTCRPCClient + tx *btcutil.Tx + memplTxsInfoFetcher signer.MempoolTxsInfoFetcher + expected *signer.CPFPFeeBumper + errMsg string + }{ + { + name: "should fetch fee bump info successfully", + client: mockClient, + tx: btcutil.NewTx(wire.NewMsgTx(wire.TxVersion)), + memplTxsInfoFetcher: makeMempoolTxsInfoFetcher( + 2, // 2 stuck TSS txs + 0.0001, // total fees 0.0001 BTC + 1000, // total vsize 1000 + 10, // average fee rate 10 sat/vbyte + "", // no error + ), + expected: &signer.CPFPFeeBumper{ + Tx: btcutil.NewTx(wire.NewMsgTx(wire.TxVersion)), + LiveRate: 12, + TotalTxs: 2, + TotalFees: 10000, + TotalVSize: 1000, + AvgFeeRate: 10, + }, + }, + { + name: "should fail if unable to estimate smart fee", + client: func() *mocks.BTCRPCClient { + client := mocks.NewBTCRPCClient(t) + client.On("EstimateSmartFee", mock.Anything, mock.Anything).Return(nil, errors.New("rpc error")) + return client + }(), + errMsg: "GetEstimatedFeeRate failed", + }, + { + name: "should fail if unable to fetch mempool txs info", + client: mockClient, + tx: btcutil.NewTx(wire.NewMsgTx(wire.TxVersion)), + memplTxsInfoFetcher: makeMempoolTxsInfoFetcher(0, 0.0, 0, 0, "rpc error"), + errMsg: "unable to fetch mempool txs info", + }, + { + name: "should fail on invalid total fees", + client: mockClient, + tx: btcutil.NewTx(wire.NewMsgTx(wire.TxVersion)), + memplTxsInfoFetcher: makeMempoolTxsInfoFetcher(2, 21000000.1, 1000, 10, ""), // fee exceeds max BTC supply + errMsg: "cannot convert total fees", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + bumper := &signer.CPFPFeeBumper{ + Client: tt.client, + Tx: tt.tx, + } + err := bumper.FetchFeeBumpInfo(tt.memplTxsInfoFetcher, log.Logger) + + if tt.errMsg != "" { + require.ErrorContains(t, err, tt.errMsg) + } else { + bumper.Client = nil // ignore the RPC client + require.NoError(t, err) + require.Equal(t, tt.expected, bumper) + } + }) + } +} + +// makeMempoolTxsInfoFetcher is a helper function to create a mock MempoolTxsInfoFetcher +func makeMempoolTxsInfoFetcher( + totalTxs int64, + totalFees float64, + totalVSize int64, + avgFeeRate int64, + errMsg string, +) signer.MempoolTxsInfoFetcher { + var err error + if errMsg != "" { + err = errors.New(errMsg) + } + + return func(interfaces.BTCRPCClient, string) (int64, float64, int64, int64, error) { + return totalTxs, totalFees, totalVSize, avgFeeRate, err + } +} diff --git a/zetaclient/chains/bitcoin/signer/outbound_data.go b/zetaclient/chains/bitcoin/signer/outbound_data.go index 6eea1d866d..f33da64604 100644 --- a/zetaclient/chains/bitcoin/signer/outbound_data.go +++ b/zetaclient/chains/bitcoin/signer/outbound_data.go @@ -59,8 +59,7 @@ func NewOutboundData( // support gas token only for Bitcoin outbound if cctx.InboundParams.CoinType != coin.CoinType_Gas { - logger.Error().Msg("can only send gas token to a Bitcoin network") - return nil, nil + return nil, errors.New("can only send gas token to a Bitcoin network") } // fee rate @@ -72,10 +71,10 @@ func NewOutboundData( // check receiver address to, err := chains.DecodeBtcAddress(params.Receiver, params.ReceiverChainId) if err != nil { - return nil, errors.Wrapf(err, "cannot decode address %s", params.Receiver) + return nil, errors.Wrapf(err, "cannot decode receiver address %s", params.Receiver) } if !chains.IsBtcAddressSupported(to) { - return nil, fmt.Errorf("unsupported address %s", params.Receiver) + return nil, fmt.Errorf("unsupported receiver address %s", params.Receiver) } amount := float64(params.Amount.Uint64()) / 1e8 diff --git a/zetaclient/chains/bitcoin/signer/outbound_data_test.go b/zetaclient/chains/bitcoin/signer/outbound_data_test.go new file mode 100644 index 0000000000..6c7aea3100 --- /dev/null +++ b/zetaclient/chains/bitcoin/signer/outbound_data_test.go @@ -0,0 +1,186 @@ +package signer + +import ( + "testing" + + sdk "github.com/cosmos/cosmos-sdk/types" + "github.com/rs/zerolog/log" + "github.com/stretchr/testify/require" + "github.com/zeta-chain/node/pkg/chains" + "github.com/zeta-chain/node/pkg/coin" + "github.com/zeta-chain/node/pkg/constant" + "github.com/zeta-chain/node/testutil/sample" + crosschaintypes "github.com/zeta-chain/node/x/crosschain/types" + "github.com/zeta-chain/node/zetaclient/config" +) + +func Test_NewOutboundData(t *testing.T) { + // sample address + chain := chains.BitcoinMainnet + receiver, err := chains.DecodeBtcAddress("bc1qaxf82vyzy8y80v000e7t64gpten7gawewzu42y", chain.ChainId) + require.NoError(t, err) + + // setup compliance config + cfg := config.Config{ + ComplianceConfig: sample.ComplianceConfig(), + } + config.LoadComplianceConfig(cfg) + + // test cases + tests := []struct { + name string + cctx *crosschaintypes.CrossChainTx + cctxModifier func(cctx *crosschaintypes.CrossChainTx) + chainID int64 + height uint64 + minRelayFee float64 + expected *OutboundData + errMsg string + }{ + { + name: "create new outbound data successfully", + cctx: sample.CrossChainTx(t, "0x123"), + cctxModifier: func(cctx *crosschaintypes.CrossChainTx) { + cctx.InboundParams.CoinType = coin.CoinType_Gas + cctx.GetCurrentOutboundParam().Receiver = receiver.String() + cctx.GetCurrentOutboundParam().ReceiverChainId = chain.ChainId + cctx.GetCurrentOutboundParam().Amount = sdk.NewUint(1e7) // 0.1 BTC + cctx.GetCurrentOutboundParam().CallOptions.GasLimit = 254 // 254 bytes + cctx.GetCurrentOutboundParam().GasPrice = "10" // 10 sats/vByte + cctx.GetCurrentOutboundParam().TssNonce = 1 + }, + chainID: chain.ChainId, + height: 101, + minRelayFee: 0.00001, // 1000 sat/KB + expected: &OutboundData{ + chainID: chain.ChainId, + to: receiver, + amount: 0.1, + feeRate: 11, // 10 + 1 (minRelayFee) + txSize: 254, + nonce: 1, + height: 101, + cancelTx: false, + }, + errMsg: "", + }, + { + name: "cctx is nil", + cctx: nil, + cctxModifier: nil, + expected: nil, + errMsg: "cctx is nil", + }, + { + name: "coin type is not gas", + cctx: sample.CrossChainTx(t, "0x123"), + cctxModifier: func(cctx *crosschaintypes.CrossChainTx) { + cctx.InboundParams.CoinType = coin.CoinType_ERC20 + }, + expected: nil, + errMsg: "can only send gas token to a Bitcoin network", + }, + { + name: "invalid gas price", + cctx: sample.CrossChainTx(t, "0x123"), + cctxModifier: func(cctx *crosschaintypes.CrossChainTx) { + cctx.InboundParams.CoinType = coin.CoinType_Gas + cctx.GetCurrentOutboundParam().GasPrice = "invalid" + }, + expected: nil, + errMsg: "cannot convert gas price", + }, + { + name: "invalid receiver address", + cctx: sample.CrossChainTx(t, "0x123"), + cctxModifier: func(cctx *crosschaintypes.CrossChainTx) { + cctx.InboundParams.CoinType = coin.CoinType_Gas + cctx.GetCurrentOutboundParam().Receiver = "invalid" + }, + expected: nil, + errMsg: "cannot decode receiver address", + }, + { + name: "unsupported receiver address", + cctx: sample.CrossChainTx(t, "0x123"), + cctxModifier: func(cctx *crosschaintypes.CrossChainTx) { + cctx.InboundParams.CoinType = coin.CoinType_Gas + cctx.GetCurrentOutboundParam().Receiver = "035e4ae279bd416b5da724972c9061ec6298dac020d1e3ca3f06eae715135cdbec" + cctx.GetCurrentOutboundParam().ReceiverChainId = chain.ChainId + }, + expected: nil, + errMsg: "unsupported receiver address", + }, + { + name: "should cancel restricted CCTX", + cctx: sample.CrossChainTx(t, "0x123"), + cctxModifier: func(cctx *crosschaintypes.CrossChainTx) { + cctx.InboundParams.CoinType = coin.CoinType_Gas + cctx.InboundParams.Sender = sample.RestrictedEVMAddressTest + cctx.GetCurrentOutboundParam().Receiver = receiver.String() + cctx.GetCurrentOutboundParam().ReceiverChainId = chain.ChainId + cctx.GetCurrentOutboundParam().Amount = sdk.NewUint(1e7) // 0.1 BTC + cctx.GetCurrentOutboundParam().CallOptions.GasLimit = 254 // 254 bytes + cctx.GetCurrentOutboundParam().GasPrice = "10" // 10 sats/vByte + cctx.GetCurrentOutboundParam().TssNonce = 1 + }, + chainID: chain.ChainId, + height: 101, + minRelayFee: 0.00001, // 1000 sat/KB + expected: &OutboundData{ + chainID: chain.ChainId, + to: receiver, + amount: 0, // should cancel the tx + feeRate: 11, // 10 + 1 (minRelayFee) + txSize: 254, + nonce: 1, + height: 101, + cancelTx: true, + }, + }, + { + name: "should cancel dust amount CCTX", + cctx: sample.CrossChainTx(t, "0x123"), + cctxModifier: func(cctx *crosschaintypes.CrossChainTx) { + cctx.InboundParams.CoinType = coin.CoinType_Gas + cctx.GetCurrentOutboundParam().Receiver = receiver.String() + cctx.GetCurrentOutboundParam().ReceiverChainId = chain.ChainId + cctx.GetCurrentOutboundParam().Amount = sdk.NewUint(constant.BTCWithdrawalDustAmount - 1) + cctx.GetCurrentOutboundParam().CallOptions.GasLimit = 254 // 254 bytes + cctx.GetCurrentOutboundParam().GasPrice = "10" // 10 sats/vByte + cctx.GetCurrentOutboundParam().TssNonce = 1 + }, + chainID: chain.ChainId, + height: 101, + minRelayFee: 0.00001, // 1000 sat/KB + expected: &OutboundData{ + chainID: chain.ChainId, + to: receiver, + amount: 0, // should cancel the tx + feeRate: 11, // 10 + 1 (minRelayFee) + txSize: 254, + nonce: 1, + height: 101, + cancelTx: true, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // modify cctx if needed + if tt.cctxModifier != nil { + tt.cctxModifier(tt.cctx) + } + + outboundData, err := NewOutboundData(tt.cctx, tt.chainID, tt.height, tt.minRelayFee, log.Logger, log.Logger) + if tt.errMsg != "" { + require.Nil(t, outboundData) + require.ErrorContains(t, err, tt.errMsg) + } else { + require.NoError(t, err) + require.Equal(t, tt.expected, outboundData) + } + }) + } +} diff --git a/zetaclient/chains/bitcoin/signer/sign_withdraw.go b/zetaclient/chains/bitcoin/signer/sign_withdraw.go index 2fd617a7c8..7b2a6e9c85 100644 --- a/zetaclient/chains/bitcoin/signer/sign_withdraw.go +++ b/zetaclient/chains/bitcoin/signer/sign_withdraw.go @@ -14,6 +14,7 @@ import ( "github.com/pkg/errors" "github.com/zeta-chain/node/pkg/chains" + "github.com/zeta-chain/node/pkg/constant" "github.com/zeta-chain/node/zetaclient/chains/bitcoin" "github.com/zeta-chain/node/zetaclient/chains/bitcoin/observer" ) @@ -197,7 +198,7 @@ func (signer *Signer) AddWithdrawTxOutputs( } // 3rd output: the remaining btc to TSS self - if remainingSats > 0 { + if remainingSats >= constant.BTCWithdrawalDustAmount { txOut3 := wire.NewTxOut(remainingSats, payToSelfScript) tx.AddTxOut(txOut3) } diff --git a/zetaclient/chains/bitcoin/signer/sign_withdraw_rbf.go b/zetaclient/chains/bitcoin/signer/sign_withdraw_rbf.go index 19a3d53600..d2c5717021 100644 --- a/zetaclient/chains/bitcoin/signer/sign_withdraw_rbf.go +++ b/zetaclient/chains/bitcoin/signer/sign_withdraw_rbf.go @@ -22,10 +22,15 @@ const ( rbfTxInSequenceNum uint32 = 1 ) +// SignRBFTx signs a RBF (Replace-By-Fee) to unblock last stuck outbound transaction. +// +// The key points: +// - It reuses the stuck tx's inputs and outputs but gives a higher fee to miners. +// - Funding the last stuck outbound will be considered as CPFP (child-pays-for-parent) by miners. func (signer *Signer) SignRBFTx( ctx context.Context, cctx *types.CrossChainTx, - oldTx *btcutil.Tx, + lastTx *btcutil.Tx, minRelayFee float64, ) (*wire.MsgTx, error) { var ( @@ -33,7 +38,7 @@ func (signer *Signer) SignRBFTx( lf = map[string]any{ logs.FieldMethod: "SignRBFTx", logs.FieldNonce: params.TssNonce, - logs.FieldTx: oldTx.MsgTx().TxID(), + logs.FieldTx: lastTx.MsgTx().TxID(), } logger = signer.Logger().Std.With().Fields(lf).Logger() ) @@ -44,11 +49,17 @@ func (signer *Signer) SignRBFTx( return nil, fmt.Errorf("cannot convert fee rate %s", params.GasPrice) } - // initiate fee bumper - fb := NewCPFPFeeBumper(signer.client, oldTx, cctxRate, minRelayFee) - err = fb.FetchFeeBumpInfo(rpc.GetTotalMempoolParentsSizeNFees, logger) + // create fee bumper + fb, err := NewCPFPFeeBumper( + signer.client, + rpc.GetTotalMempoolParentsSizeNFees, + lastTx, + cctxRate, + minRelayFee, + logger, + ) if err != nil { - return nil, errors.Wrap(err, "FetchFeeBumpInfo failed") + return nil, errors.Wrap(err, "NewCPFPFeeBumper failed") } // bump tx fees diff --git a/zetaclient/chains/bitcoin/signer/sign_withdraw_test.go b/zetaclient/chains/bitcoin/signer/sign_withdraw_test.go new file mode 100644 index 0000000000..981ab4adac --- /dev/null +++ b/zetaclient/chains/bitcoin/signer/sign_withdraw_test.go @@ -0,0 +1,183 @@ +package signer + +import ( + "fmt" + "reflect" + "testing" + + "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/txscript" + "github.com/btcsuite/btcd/wire" + "github.com/stretchr/testify/require" + "github.com/zeta-chain/node/zetaclient/testutils" + + "github.com/zeta-chain/node/pkg/chains" + "github.com/zeta-chain/node/zetaclient/chains/base" + "github.com/zeta-chain/node/zetaclient/testutils/mocks" +) + +func TestAddWithdrawTxOutputs(t *testing.T) { + // Create test signer and receiver address + signer := NewSigner( + chains.BitcoinMainnet, + mocks.NewBTCRPCClient(t), + mocks.NewTSS(t).FakePubKey(testutils.TSSPubKeyMainnet), + base.DefaultLogger(), + ) + + // tss address and script + tssAddr, err := signer.TSS().PubKey().AddressBTC(chains.BitcoinMainnet.ChainId) + require.NoError(t, err) + tssScript, err := txscript.PayToAddrScript(tssAddr) + require.NoError(t, err) + fmt.Printf("tss address: %s", tssAddr.EncodeAddress()) + + // receiver addresses + receiver := "bc1qaxf82vyzy8y80v000e7t64gpten7gawewzu42y" + to, err := chains.DecodeBtcAddress(receiver, chains.BitcoinMainnet.ChainId) + require.NoError(t, err) + toScript, err := txscript.PayToAddrScript(to) + require.NoError(t, err) + + // test cases + tests := []struct { + name string + tx *wire.MsgTx + to btcutil.Address + total float64 + amount float64 + nonceMark int64 + fees int64 + cancelTx bool + fail bool + message string + txout []*wire.TxOut + }{ + { + name: "should add outputs successfully", + tx: wire.NewMsgTx(wire.TxVersion), + to: to, + total: 1.00012000, + amount: 0.2, + nonceMark: 10000, + fees: 2000, + fail: false, + txout: []*wire.TxOut{ + {Value: 10000, PkScript: tssScript}, + {Value: 20000000, PkScript: toScript}, + {Value: 80000000, PkScript: tssScript}, + }, + }, + { + name: "should add outputs without change successfully", + tx: wire.NewMsgTx(wire.TxVersion), + to: to, + total: 0.20012000, + amount: 0.2, + nonceMark: 10000, + fees: 2000, + fail: false, + txout: []*wire.TxOut{ + {Value: 10000, PkScript: tssScript}, + {Value: 20000000, PkScript: toScript}, + }, + }, + { + name: "should cancel tx successfully", + tx: wire.NewMsgTx(wire.TxVersion), + to: to, + total: 1.00012000, + amount: 0.2, + nonceMark: 10000, + fees: 2000, + cancelTx: true, + fail: false, + txout: []*wire.TxOut{ + {Value: 10000, PkScript: tssScript}, + {Value: 100000000, PkScript: tssScript}, + }, + }, + { + name: "should fail on invalid amount", + tx: wire.NewMsgTx(wire.TxVersion), + to: to, + total: 1.00012000, + amount: -0.5, + fail: true, + }, + { + name: "should fail when total < amount", + tx: wire.NewMsgTx(wire.TxVersion), + to: to, + total: 0.00012000, + amount: 0.2, + fail: true, + }, + { + name: "should fail when total < fees + amount + nonce", + tx: wire.NewMsgTx(wire.TxVersion), + to: to, + total: 0.20011000, + amount: 0.2, + nonceMark: 10000, + fees: 2000, + fail: true, + message: "remainder value is negative", + }, + { + name: "should not produce duplicate nonce mark", + tx: wire.NewMsgTx(wire.TxVersion), + to: to, + total: 0.20022000, // 0.2 + fee + nonceMark * 2 + amount: 0.2, + nonceMark: 10000, + fees: 2000, + fail: false, + txout: []*wire.TxOut{ + {Value: 10000, PkScript: tssScript}, + {Value: 20000000, PkScript: toScript}, + {Value: 9999, PkScript: tssScript}, // nonceMark - 1 + }, + }, + { + name: "should not produce dust change to TSS self", + tx: wire.NewMsgTx(wire.TxVersion), + to: to, + total: 0.20012999, // 0.2 + fee + nonceMark + amount: 0.2, + nonceMark: 10000, + fees: 2000, + fail: false, + txout: []*wire.TxOut{ // 3rd output 999 is dust and removed + {Value: 10000, PkScript: tssScript}, + {Value: 20000000, PkScript: toScript}, + }, + }, + { + name: "should fail on invalid to address", + tx: wire.NewMsgTx(wire.TxVersion), + to: nil, + total: 1.00012000, + amount: 0.2, + nonceMark: 10000, + fees: 2000, + fail: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := signer.AddWithdrawTxOutputs(tt.tx, tt.to, tt.total, tt.amount, tt.nonceMark, tt.fees, tt.cancelTx) + if tt.fail { + require.Error(t, err) + if tt.message != "" { + require.ErrorContains(t, err, tt.message) + } + return + } else { + require.NoError(t, err) + require.True(t, reflect.DeepEqual(tt.txout, tt.tx.TxOut)) + } + }) + } +} diff --git a/zetaclient/chains/bitcoin/signer/signer.go b/zetaclient/chains/bitcoin/signer/signer.go index 901330bd1f..4004e3493d 100644 --- a/zetaclient/chains/bitcoin/signer/signer.go +++ b/zetaclient/chains/bitcoin/signer/signer.go @@ -5,21 +5,17 @@ import ( "bytes" "context" "encoding/hex" - "fmt" "time" - "github.com/btcsuite/btcd/rpcclient" "github.com/btcsuite/btcd/txscript" "github.com/btcsuite/btcd/wire" ethcommon "github.com/ethereum/go-ethereum/common" - "github.com/pkg/errors" "github.com/zeta-chain/node/pkg/chains" "github.com/zeta-chain/node/x/crosschain/types" "github.com/zeta-chain/node/zetaclient/chains/base" "github.com/zeta-chain/node/zetaclient/chains/bitcoin/observer" "github.com/zeta-chain/node/zetaclient/chains/interfaces" - "github.com/zeta-chain/node/zetaclient/config" "github.com/zeta-chain/node/zetaclient/logs" "github.com/zeta-chain/node/zetaclient/outboundprocessor" ) @@ -45,31 +41,17 @@ type Signer struct { // NewSigner creates a new Bitcoin signer func NewSigner( chain chains.Chain, + client interfaces.BTCRPCClient, tss interfaces.TSSSigner, logger base.Logger, - cfg config.BTCConfig, -) (*Signer, error) { +) *Signer { // create base signer baseSigner := base.NewSigner(chain, tss, logger) - // create the bitcoin rpc client using the provided config - connCfg := &rpcclient.ConnConfig{ - Host: cfg.RPCHost, - User: cfg.RPCUsername, - Pass: cfg.RPCPassword, - HTTPPostMode: true, - DisableTLS: true, - Params: cfg.RPCParams, - } - client, err := rpcclient.New(connCfg, nil) - if err != nil { - return nil, errors.Wrap(err, "unable to create bitcoin rpc client") - } - return &Signer{ Signer: baseSigner, client: client, - }, nil + } } // TODO: get rid of below four get/set functions for Bitcoin, as they are not needed in future @@ -114,22 +96,19 @@ func (signer *Signer) PkScriptTSS() ([]byte, error) { // Broadcast sends the signed transaction to the network func (signer *Signer) Broadcast(signedTx *wire.MsgTx) error { - fmt.Printf("BTCSigner: Broadcasting: %s\n", signedTx.TxHash().String()) - var outBuff bytes.Buffer err := signedTx.Serialize(&outBuff) if err != nil { return err } str := hex.EncodeToString(outBuff.Bytes()) - fmt.Printf("BTCSigner: Transaction Data: %s\n", str) - hash, err := signer.client.SendRawTransaction(signedTx, true) + _, err = signer.client.SendRawTransaction(signedTx, true) if err != nil { return err } - signer.Logger().Std.Info().Msgf("Broadcasting BTC tx , hash %s ", hash) + signer.Logger().Std.Info().Msgf("Broadcasted transaction data: %s ", str) return nil } @@ -180,47 +159,42 @@ func (signer *Signer) TryProcessOutbound( } minRelayFee := networkInfo.RelayFee - // sign RBF replacement tx if outbound is stuck - if btcObserver.IsOutboundStuck() { - lastTx, nonce, err := btcObserver.GetLastOutbound(ctx) + var ( + signedTx *wire.MsgTx + stuckTx = btcObserver.GetLastStuckOutbound() + ) + + // sign outbound + if stuckTx != nil && params.TssNonce == stuckTx.Nonce { + signedTx, err = signer.SignRBFTx(ctx, cctx, stuckTx.Tx, minRelayFee) if err != nil { - logger.Error().Err(err).Msg("GetLastOutbound failed") + logger.Error().Err(err).Msg("SignRBFTx failed") return } - if params.TssNonce == nonce { - tx, err := signer.SignRBFTx(ctx, cctx, lastTx, minRelayFee) - if err != nil { - logger.Error().Err(err).Msg("SignRBFTx failed") - return - } - logger.Info().Msg("SignRBFTx success") - - // broadcast tx - signer.broadcastOutbound(ctx, tx, params.TssNonce, cctx, btcObserver, zetacoreClient) + logger.Info().Msg("SignRBFTx success") + } else { + // setup outbound data + txData, err := NewOutboundData(cctx, chain.ChainId, height, minRelayFee, logger, signer.Logger().Compliance) + if err != nil { + logger.Error().Err(err).Msg("failed to setup Bitcoin outbound data") + return } - } - // setup transaction data - txData, err := NewOutboundData(cctx, chain.ChainId, height, minRelayFee, logger, signer.Logger().Compliance) - if err != nil { - logger.Error().Err(err).Msg("failed to setup Bitcoin outbound data") - return - } - - // sign withdraw tx - tx, err := signer.SignWithdrawTx(ctx, txData, btcObserver) - if err != nil { - logger.Warn().Err(err).Msg("SignWithdrawTx failed") - return + // sign withdraw tx + signedTx, err = signer.SignWithdrawTx(ctx, txData, btcObserver) + if err != nil { + logger.Error().Err(err).Msg("SignWithdrawTx failed") + return + } + logger.Info().Msg("SignWithdrawTx success") } - logger.Info().Msg("SignWithdrawTx success") - // broadcast tx - signer.broadcastOutbound(ctx, tx, params.TssNonce, cctx, btcObserver, zetacoreClient) + // broadcast signed outbound + signer.BroadcastOutbound(ctx, signedTx, params.TssNonce, cctx, btcObserver, zetacoreClient) } -// broadcastOutbound sends the signed transaction to the Bitcoin network -func (signer *Signer) broadcastOutbound( +// BroadcastOutbound sends the signed transaction to the Bitcoin network +func (signer *Signer) BroadcastOutbound( ctx context.Context, tx *wire.MsgTx, nonce uint64, @@ -260,9 +234,10 @@ func (signer *Signer) broadcastOutbound( zetaHash, err := zetacoreClient.PostOutboundTracker(ctx, ob.Chain().ChainId, nonce, txHash) if err != nil { logger.Err(err).Fields(lf).Msgf("unable to add Bitcoin outbound tracker") + } else { + lf[logs.FieldZetaTx] = zetaHash + logger.Info().Fields(lf).Msgf("add Bitcoin outbound tracker successfully") } - lf[logs.FieldZetaTx] = zetaHash - logger.Info().Fields(lf).Msgf("add Bitcoin outbound tracker successfully") // try including this outbound as early as possible _, included := ob.TryIncludeOutbound(ctx, cctx, txHash) diff --git a/zetaclient/chains/bitcoin/signer/signer_test.go b/zetaclient/chains/bitcoin/signer/signer_test.go index fbf4dc7953..61e5c1946e 100644 --- a/zetaclient/chains/bitcoin/signer/signer_test.go +++ b/zetaclient/chains/bitcoin/signer/signer_test.go @@ -1,71 +1,173 @@ -package signer +package signer_test import ( + "context" "encoding/hex" - "fmt" - "reflect" "testing" "github.com/btcsuite/btcd/btcec/v2" - btcecdsa "github.com/btcsuite/btcd/btcec/v2/ecdsa" + "github.com/btcsuite/btcd/btcec/v2/ecdsa" + "github.com/btcsuite/btcd/btcjson" "github.com/btcsuite/btcd/btcutil" "github.com/btcsuite/btcd/chaincfg" "github.com/btcsuite/btcd/chaincfg/chainhash" "github.com/btcsuite/btcd/txscript" "github.com/btcsuite/btcd/wire" "github.com/ethereum/go-ethereum/crypto" + "github.com/rs/zerolog" + "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" - "github.com/zeta-chain/node/zetaclient/testutils" - . "gopkg.in/check.v1" "github.com/zeta-chain/node/pkg/chains" + crosschaintypes "github.com/zeta-chain/node/x/crosschain/types" + observertypes "github.com/zeta-chain/node/x/observer/types" "github.com/zeta-chain/node/zetaclient/chains/base" + "github.com/zeta-chain/node/zetaclient/chains/bitcoin/observer" + "github.com/zeta-chain/node/zetaclient/chains/bitcoin/signer" "github.com/zeta-chain/node/zetaclient/config" + zctx "github.com/zeta-chain/node/zetaclient/context" + "github.com/zeta-chain/node/zetaclient/db" + "github.com/zeta-chain/node/zetaclient/keys" + "github.com/zeta-chain/node/zetaclient/metrics" + "github.com/zeta-chain/node/zetaclient/outboundprocessor" + "github.com/zeta-chain/node/zetaclient/testutils" "github.com/zeta-chain/node/zetaclient/testutils/mocks" ) -type BTCSignerSuite struct { - btcSigner *Signer +// the relative path to the testdata directory +var TestDataDir = "../../../" + +type testSuite struct { + *signer.Signer + tss *mocks.TSS + client *mocks.BTCRPCClient + zetacoreClient *mocks.ZetacoreClient } -var _ = Suite(&BTCSignerSuite{}) +func newTestSuite(t *testing.T, chain chains.Chain) *testSuite { + // mock BTC RPC client + rpcClient := mocks.NewBTCRPCClient(t) + rpcClient.On("GetBlockCount", mock.Anything).Maybe().Return(int64(101), nil) + + // mock TSS + var tss *mocks.TSS + if chains.IsBitcoinMainnet(chain.ChainId) { + tss = mocks.NewTSS(t).FakePubKey(testutils.TSSPubKeyMainnet) + } else { + tss = mocks.NewTSS(t).FakePubKey(testutils.TSSPubkeyAthens3) + } -func (s *BTCSignerSuite) SetUpTest(c *C) { + // mock Zetacore client + zetacoreClient := mocks.NewZetacoreClient(t). + WithKeys(&keys.Keys{}). + WithZetaChain() + + signer := signer.NewSigner( + chain, + rpcClient, + tss, + base.DefaultLogger(), + ) + + return &testSuite{ + Signer: signer, + tss: tss, + client: rpcClient, + zetacoreClient: zetacoreClient, + } +} + +func Test_NewSigner(t *testing.T) { // test private key with EVM address - //// EVM: 0x236C7f53a90493Bb423411fe4117Cb4c2De71DfB + // EVM: 0x236C7f53a90493Bb423411fe4117Cb4c2De71DfB // BTC testnet3: muGe9prUBjQwEnX19zG26fVRHNi8z7kSPo skHex := "7b8507ba117e069f4a3f456f505276084f8c92aee86ac78ae37b4d1801d35fa8" privateKey, err := crypto.HexToECDSA(skHex) - pkBytes := crypto.FromECDSAPub(&privateKey.PublicKey) - c.Logf("pubkey: %d", len(pkBytes)) - // Uncomment the following code to generate new random private key pairs - //privateKey, err := crypto.GenerateKey() - //privkeyBytes := crypto.FromECDSA(privateKey) - //c.Logf("privatekey %s", hex.EncodeToString(privkeyBytes)) - c.Assert(err, IsNil) - - tss := mocks.NewTSSFromPrivateKey(c, privateKey) - - s.btcSigner, err = NewSigner( - chains.Chain{}, - tss, - base.DefaultLogger(), - config.BTCConfig{}, - ) - c.Assert(err, IsNil) + require.NoError(t, err) + tss := mocks.NewTSSFromPrivateKey(t, privateKey) + signer := signer.NewSigner(chains.BitcoinMainnet, mocks.NewBTCRPCClient(t), tss, base.DefaultLogger()) + require.NotNil(t, signer) } -func (s *BTCSignerSuite) TestP2PH(c *C) { +func Test_BroadcastOutbound(t *testing.T) { + // test cases + tests := []struct { + name string + chain chains.Chain + nonce uint64 + failTracker bool + }{ + { + name: "should successfully broadcast and include outbound", + chain: chains.BitcoinMainnet, + nonce: uint64(148), + }, + { + name: "should successfully broadcast and include outbound, but failed to post outbound tracker", + chain: chains.BitcoinMainnet, + nonce: uint64(148), + failTracker: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // setup s and observer + s := newTestSuite(t, tt.chain) + observer := s.getNewObserver(t) + + // load tx and result + chainID := tt.chain.ChainId + rawResult, cctx := testutils.LoadBTCTxRawResultNCctx(t, TestDataDir, chainID, tt.nonce) + txResult := testutils.LoadBTCTransaction(t, TestDataDir, chainID, rawResult.Txid) + msgTx := testutils.LoadBTCMsgTx(t, TestDataDir, chainID, rawResult.Txid) + + // mock RPC response + s.client.On("SendRawTransaction", mock.Anything, mock.Anything).Return(nil, nil) + s.client.On("GetTransaction", mock.Anything).Return(txResult, nil) + s.client.On("GetRawTransactionVerbose", mock.Anything).Return(rawResult, nil) + + // mock Zetacore client response + if tt.failTracker { + s.zetacoreClient.WithPostOutboundTracker("") + } else { + s.zetacoreClient.WithPostOutboundTracker("0x123") + } + + // mock the previous tx as included + // this is necessary to allow the 'checkTSSVin' function to pass + observer.SetIncludedTx(tt.nonce-1, &btcjson.GetTransactionResult{ + TxID: rawResult.Vin[0].Txid, + }) + + ctx := makeCtx(t) + s.BroadcastOutbound( + ctx, + msgTx, + tt.nonce, + cctx, + observer, + s.zetacoreClient, + ) + + // check if outbound is included + gotResult := observer.GetIncludedTx(tt.nonce) + require.Equal(t, txResult, gotResult) + }) + } +} + +func Test_P2PH(t *testing.T) { // Ordinarily the private key would come from whatever storage mechanism // is being used, but for this example just hard code it. privKeyBytes, err := hex.DecodeString("22a47fa09a223f2aa079edf85a7c2" + "d4f8720ee63e502ee2869afab7de234b80c") - c.Assert(err, IsNil) + require.NoError(t, err) privKey, pubKey := btcec.PrivKeyFromBytes(privKeyBytes) pubKeyHash := btcutil.Hash160(pubKey.SerializeCompressed()) addr, err := btcutil.NewAddressPubKeyHash(pubKeyHash, &chaincfg.RegressionNetParams) - c.Assert(err, IsNil) + require.NoError(t, err) // For this example, create a fake transaction that represents what // would ordinarily be the real transaction that is being spent. It @@ -75,8 +177,7 @@ func (s *BTCSignerSuite) TestP2PH(c *C) { txIn := wire.NewTxIn(prevOut, []byte{txscript.OP_0, txscript.OP_0}, nil) originTx.AddTxIn(txIn) pkScript, err := txscript.PayToAddrScript(addr) - - c.Assert(err, IsNil) + require.NoError(t, err) txOut := wire.NewTxOut(100000000, pkScript) originTx.AddTxOut(txOut) @@ -107,7 +208,7 @@ func (s *BTCSignerSuite) TestP2PH(c *C) { sigScript, err := txscript.SignTxOutput(&chaincfg.MainNetParams, redeemTx, 0, originTx.TxOut[0].PkScript, txscript.SigHashAll, txscript.KeyClosure(lookupKey), nil, nil) - c.Assert(err, IsNil) + require.NoError(t, err) redeemTx.TxIn[0].SignatureScript = sigScript @@ -118,26 +219,24 @@ func (s *BTCSignerSuite) TestP2PH(c *C) { txscript.ScriptDiscourageUpgradableNops vm, err := txscript.NewEngine(originTx.TxOut[0].PkScript, redeemTx, 0, flags, nil, nil, -1, txscript.NewMultiPrevOutFetcher(nil)) - c.Assert(err, IsNil) + require.NoError(t, err) err = vm.Execute() - c.Assert(err, IsNil) - - fmt.Println("Transaction successfully signed") + require.NoError(t, err) } -func (s *BTCSignerSuite) TestP2WPH(c *C) { +func Test_P2WPH(t *testing.T) { // Ordinarily the private key would come from whatever storage mechanism // is being used, but for this example just hard code it. privKeyBytes, err := hex.DecodeString("22a47fa09a223f2aa079edf85a7c2" + "d4f8720ee63e502ee2869afab7de234b80c") - c.Assert(err, IsNil) + require.NoError(t, err) privKey, pubKey := btcec.PrivKeyFromBytes(privKeyBytes) pubKeyHash := btcutil.Hash160(pubKey.SerializeCompressed()) //addr, err := btcutil.NewAddressPubKeyHash(pubKeyHash, &chaincfg.RegressionNetParams) addr, err := btcutil.NewAddressWitnessPubKeyHash(pubKeyHash, &chaincfg.RegressionNetParams) - c.Assert(err, IsNil) + require.NoError(t, err) // For this example, create a fake transaction that represents what // would ordinarily be the real transaction that is being spent. It @@ -147,7 +246,7 @@ func (s *BTCSignerSuite) TestP2WPH(c *C) { txIn := wire.NewTxIn(prevOut, []byte{txscript.OP_0, txscript.OP_0}, nil) originTx.AddTxIn(txIn) pkScript, err := txscript.PayToAddrScript(addr) - c.Assert(err, IsNil) + require.NoError(t, err) txOut := wire.NewTxOut(100000000, pkScript) originTx.AddTxOut(txOut) originTxHash := originTx.TxHash() @@ -168,7 +267,7 @@ func (s *BTCSignerSuite) TestP2WPH(c *C) { redeemTx.AddTxOut(txOut) txSigHashes := txscript.NewTxSigHashes(redeemTx, txscript.NewCannedPrevOutputFetcher([]byte{}, 0)) pkScript, err = txscript.PayToAddrScript(addr) - c.Assert(err, IsNil) + require.NoError(t, err) { txWitness, err := txscript.WitnessSignature( @@ -181,7 +280,7 @@ func (s *BTCSignerSuite) TestP2WPH(c *C) { privKey, true, ) - c.Assert(err, IsNil) + require.NoError(t, err) redeemTx.TxIn[0].Witness = txWitness // Prove that the transaction has been validly signed by executing the // script pair. @@ -190,10 +289,10 @@ func (s *BTCSignerSuite) TestP2WPH(c *C) { txscript.ScriptDiscourageUpgradableNops vm, err := txscript.NewEngine(originTx.TxOut[0].PkScript, redeemTx, 0, flags, nil, nil, -1, txscript.NewCannedPrevOutputFetcher([]byte{}, 0)) - c.Assert(err, IsNil) + require.NoError(t, err) err = vm.Execute() - c.Assert(err, IsNil) + require.NoError(t, err) } { @@ -205,8 +304,8 @@ func (s *BTCSignerSuite) TestP2WPH(c *C) { 0, 100000000, ) - c.Assert(err, IsNil) - sig := btcecdsa.Sign(privKey, witnessHash) + require.NoError(t, err) + sig := ecdsa.Sign(privKey, witnessHash) txWitness := wire.TxWitness{append(sig.Serialize(), byte(txscript.SigHashAll)), pubKeyHash} redeemTx.TxIn[0].Witness = txWitness @@ -215,182 +314,65 @@ func (s *BTCSignerSuite) TestP2WPH(c *C) { txscript.ScriptDiscourageUpgradableNops vm, err := txscript.NewEngine(originTx.TxOut[0].PkScript, redeemTx, 0, flags, nil, nil, -1, txscript.NewMultiPrevOutFetcher(nil)) - c.Assert(err, IsNil) + require.NoError(t, err) err = vm.Execute() - c.Assert(err, IsNil) + require.NoError(t, err) } - - fmt.Println("Transaction successfully signed") } -func TestAddWithdrawTxOutputs(t *testing.T) { - // Create test signer and receiver address - signer, err := NewSigner( - chains.BitcoinMainnet, - mocks.NewTSS(t).FakePubKey(testutils.TSSPubKeyMainnet), - base.DefaultLogger(), - config.BTCConfig{}, - ) - require.NoError(t, err) - - // tss address and script - tssAddr, err := signer.TSS().PubKey().AddressBTC(chains.BitcoinMainnet.ChainId) - require.NoError(t, err) - tssScript, err := txscript.PayToAddrScript(tssAddr) - require.NoError(t, err) - fmt.Printf("tss address: %s", tssAddr.EncodeAddress()) +func makeCtx(t *testing.T) context.Context { + app := zctx.New(config.New(false), nil, zerolog.Nop()) - // receiver addresses - receiver := "bc1qaxf82vyzy8y80v000e7t64gpten7gawewzu42y" - to, err := chains.DecodeBtcAddress(receiver, chains.BitcoinMainnet.ChainId) - require.NoError(t, err) - toScript, err := txscript.PayToAddrScript(to) - require.NoError(t, err) + chain := chains.BitcoinMainnet + btcParams := mocks.MockChainParams(chain.ChainId, 2) - // test cases - tests := []struct { - name string - tx *wire.MsgTx - to btcutil.Address - total float64 - amount float64 - nonce int64 - fees int64 - cancelTx bool - fail bool - message string - txout []*wire.TxOut - }{ - { - name: "should add outputs successfully", - tx: wire.NewMsgTx(wire.TxVersion), - to: to, - total: 1.00012000, - amount: 0.2, - nonce: 10000, - fees: 2000, - fail: false, - txout: []*wire.TxOut{ - {Value: 10000, PkScript: tssScript}, - {Value: 20000000, PkScript: toScript}, - {Value: 80000000, PkScript: tssScript}, - }, - }, - { - name: "should add outputs without change successfully", - tx: wire.NewMsgTx(wire.TxVersion), - to: to, - total: 0.20012000, - amount: 0.2, - nonce: 10000, - fees: 2000, - fail: false, - txout: []*wire.TxOut{ - {Value: 10000, PkScript: tssScript}, - {Value: 20000000, PkScript: toScript}, - }, - }, - { - name: "should cancel tx successfully", - tx: wire.NewMsgTx(wire.TxVersion), - to: to, - total: 1.00012000, - amount: 0.2, - nonce: 10000, - fees: 2000, - cancelTx: true, - fail: false, - txout: []*wire.TxOut{ - {Value: 10000, PkScript: tssScript}, - {Value: 100000000, PkScript: tssScript}, - }, - }, - { - name: "should fail on invalid amount", - tx: wire.NewMsgTx(wire.TxVersion), - to: to, - total: 1.00012000, - amount: -0.5, - fail: true, - }, - { - name: "should fail when total < amount", - tx: wire.NewMsgTx(wire.TxVersion), - to: to, - total: 0.00012000, - amount: 0.2, - fail: true, - }, - { - name: "should fail when total < fees + amount + nonce", - tx: wire.NewMsgTx(wire.TxVersion), - to: to, - total: 0.20011000, - amount: 0.2, - nonce: 10000, - fees: 2000, - fail: true, - message: "remainder value is negative", - }, - { - name: "should not produce duplicate nonce mark", - tx: wire.NewMsgTx(wire.TxVersion), - to: to, - total: 0.20022000, // 0.2 + fee + nonceMark * 2 - amount: 0.2, - nonce: 10000, - fees: 2000, - fail: false, - txout: []*wire.TxOut{ - {Value: 10000, PkScript: tssScript}, - {Value: 20000000, PkScript: toScript}, - {Value: 9999, PkScript: tssScript}, // nonceMark - 1 - }, - }, - { - name: "should fail on invalid to address", - tx: wire.NewMsgTx(wire.TxVersion), - to: nil, - total: 1.00012000, - amount: 0.2, - nonce: 10000, - fees: 2000, - fail: true, + err := app.Update( + []chains.Chain{chain, chains.ZetaChainMainnet}, + nil, + map[int64]*observertypes.ChainParams{ + chain.ChainId: &btcParams, }, - } + observertypes.CrosschainFlags{}, + ) + require.NoError(t, err, "unable to update app context") - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - err := signer.AddWithdrawTxOutputs(tt.tx, tt.to, tt.total, tt.amount, tt.nonce, tt.fees, tt.cancelTx) - if tt.fail { - require.Error(t, err) - if tt.message != "" { - require.ErrorContains(t, err, tt.message) - } - return - } else { - require.NoError(t, err) - require.True(t, reflect.DeepEqual(tt.txout, tt.tx.TxOut)) - } - }) - } + return zctx.WithAppContext(context.Background(), app) } -// Coverage doesn't seem to pick this up from the suite -func TestNewBTCSigner(t *testing.T) { - // test private key with EVM address - //// EVM: 0x236C7f53a90493Bb423411fe4117Cb4c2De71DfB - // BTC testnet3: muGe9prUBjQwEnX19zG26fVRHNi8z7kSPo - skHex := "7b8507ba117e069f4a3f456f505276084f8c92aee86ac78ae37b4d1801d35fa8" - privateKey, err := crypto.HexToECDSA(skHex) +// getCCTX returns a CCTX for testing +func getCCTX(t *testing.T) *crosschaintypes.CrossChainTx { + return testutils.LoadCctxByNonce(t, 8332, 148) +} + +// getNewOutboundProcessor creates a new outbound processor for testing +func getNewOutboundProcessor() *outboundprocessor.Processor { + logger := zerolog.Logger{} + return outboundprocessor.NewProcessor(logger) +} + +// getNewObserver creates a new BTC chain observer for testing +func (s *testSuite) getNewObserver(t *testing.T) *observer.Observer { + // prepare mock arguments to create observer + params := mocks.MockChainParams(s.Chain().ChainId, 2) + logger := base.DefaultLogger() + ts := &metrics.TelemetryServer{} + + // create in-memory db + database, err := db.NewFromSqliteInMemory(true) require.NoError(t, err) - tss := mocks.NewTSSFromPrivateKey(t, privateKey) - btcSigner, err := NewSigner( - chains.Chain{}, - tss, - base.DefaultLogger(), - config.BTCConfig{}) + + ob, err := observer.NewObserver( + s.Chain(), + s.client, + params, + s.zetacoreClient, + s.tss, + database, + logger, + ts, + ) require.NoError(t, err) - require.NotNil(t, btcSigner) + + return ob } diff --git a/zetaclient/orchestrator/bootstrap.go b/zetaclient/orchestrator/bootstrap.go index 3228610e76..b6bb729860 100644 --- a/zetaclient/orchestrator/bootstrap.go +++ b/zetaclient/orchestrator/bootstrap.go @@ -145,12 +145,14 @@ func syncSignerMap( continue } - signer, err := btcsigner.NewSigner(*rawChain, tss, logger, cfg) + rpcClient, err := rpc.NewRPCClient(cfg) if err != nil { - logger.Std.Error().Err(err).Msgf("Unable to construct signer for BTC chain %d", chainID) + logger.Std.Error().Err(err).Msgf("unable to create rpc client for BTC chain %d", chainID) continue } + signer := btcsigner.NewSigner(*rawChain, rpcClient, tss, logger) + addSigner(chainID, signer) case chain.IsSolana(): cfg, found := app.Config().GetSolanaConfig() diff --git a/zetaclient/testdata/btc/chain_8332_msgtx_030cd813443f7b70cc6d8a544d320c6d8465e4528fc0f3410b599dc0b26753a0.json b/zetaclient/testdata/btc/chain_8332_msgtx_030cd813443f7b70cc6d8a544d320c6d8465e4528fc0f3410b599dc0b26753a0.json new file mode 100644 index 0000000000..a96ef05a83 --- /dev/null +++ b/zetaclient/testdata/btc/chain_8332_msgtx_030cd813443f7b70cc6d8a544d320c6d8465e4528fc0f3410b599dc0b26753a0.json @@ -0,0 +1,95 @@ +{ + "Version": 1, + "TxIn": [ + { + "PreviousOutPoint": { + "Hash": "efca302a18bd8cebb3b8afef13e98ecaac47157755a62ab241ef3848140cfe92", + "Index": 0 + }, + "SignatureScript": "", + "Witness": [ + "MEUCIQD+vXB5QFfmG4PiqsCTAtiZEOO3mgMbCEtPFIxVKaGJxgIgfGCjg07rfdmJwQjHNbwX4NU853oBbowIkNvB5dxXO2wB", + "AvrTNIt8fXPoWnz+U69CtTW3UEGe/FXDbIB/ON0LoG7c" + ], + "Sequence": 4294967295 + }, + { + "PreviousOutPoint": { + "Hash": "3dc005eb0c1d393e717070ea84aa13e334a458a4fb7c7f9f98dbf8b231b5ceef", + "Index": 0 + }, + "SignatureScript": "", + "Witness": [ + "MEUCIQDip9szSAtGI8GRvjSFJfSNLGx/2MepdquH1Vaj2fG/DAIgYMUfOFQvE8MywRSqqiCTcoNDqVUGkw1cgQvd3koxIVMB", + "AvrTNIt8fXPoWnz+U69CtTW3UEGe/FXDbIB/ON0LoG7c" + ], + "Sequence": 4294967295 + }, + { + "PreviousOutPoint": { + "Hash": "74c3aca825f3b21b82ee344d939c40d4c1e836a89c18abbd521bfa69f5f6e5d7", + "Index": 0 + }, + "SignatureScript": "", + "Witness": [ + "MEQCIDzr8YVsCvLFwtjs5DVBjpmecAUH6mR7tc8QmUmzN9VzAiBnU/AbfIG3MQRrGK/3WJ6EcVJK7+Y0mjRocLwJyh3o1wE=", + "AvrTNIt8fXPoWnz+U69CtTW3UEGe/FXDbIB/ON0LoG7c" + ], + "Sequence": 4294967295 + }, + { + "PreviousOutPoint": { + "Hash": "87264cef0e581f4aab3c99c53221bec3219686b48088d651a8cf8a98e4c2c5bf", + "Index": 0 + }, + "SignatureScript": "", + "Witness": [ + "MEQCIFM1gzPXKK/6SpXiP2ZZn2bJQB5PgCu7E/AUrPrighdoAiB5PFg1YmenwAUoiafag9N+sBMGJ3SWs+BE5KW0q9xEYQE=", + "AvrTNIt8fXPoWnz+U69CtTW3UEGe/FXDbIB/ON0LoG7c" + ], + "Sequence": 4294967295 + }, + { + "PreviousOutPoint": { + "Hash": "5af24933973df03d96624ae1341d79a860e8dbc2ffc841420aa6710f3abc0074", + "Index": 0 + }, + "SignatureScript": "", + "Witness": [ + "MEQCICFVKukAkYOXm1NN7bJR1VWqyqaPFAwBbr+x5nh6NcXgAiAwnfdr1RESQ1LDlV+S0NscurZQT+VkmwWFsMdABANXCwE=", + "AvrTNIt8fXPoWnz+U69CtTW3UEGe/FXDbIB/ON0LoG7c" + ], + "Sequence": 4294967295 + }, + { + "PreviousOutPoint": { + "Hash": "efca302a18bd8cebb3b8afef13e98ecaac47157755a62ab241ef3848140cfe92", + "Index": 2 + }, + "SignatureScript": "", + "Witness": [ + "MEUCIQD6vL28zA0kaK9gdD+oFxWf3Qmj+XGT8Rl4DulatAFMkgIgX3KMst6jqScmUdCcI4ImSbOMFg0MwiJhPLddsbzeXhgB", + "AvrTNIt8fXPoWnz+U69CtTW3UEGe/FXDbIB/ON0LoG7c" + ], + "Sequence": 4294967295 + }, + { + "PreviousOutPoint": { + "Hash": "b85755938ac026b2d13e5fbacf015288f453712b4eb4a02d7e4c98ee76ada530", + "Index": 0 + }, + "SignatureScript": "", + "Witness": [ + "MEUCIQCFNdqVZQvNeGSV8/2/GRA/wNZAjQAtYCErth+8e/aJRQIgK6Xl4ymJrD7yk/VWGWwmM+bnN1DjJT7UdONmxWSawd0B", + "AvrTNIt8fXPoWnz+U69CtTW3UEGe/FXDbIB/ON0LoG7c" + ], + "Sequence": 4294967295 + } + ], + "TxOut": [ + { "Value": 2148, "PkScript": "ABTaquDT3p2P3uMWYeYa6oKLWb54ZA==" }, + { "Value": 12000, "PkScript": "ABQMG/t9ON/wlG/exWJtUa1Y1+m8VA==" }, + { "Value": 39041489, "PkScript": "ABTaquDT3p2P3uMWYeYa6oKLWb54ZA==" } + ], + "LockTime": 0 +} diff --git a/zetaclient/testdata/btc/chain_8332_tx_030cd813443f7b70cc6d8a544d320c6d8465e4528fc0f3410b599dc0b26753a0.json b/zetaclient/testdata/btc/chain_8332_tx_030cd813443f7b70cc6d8a544d320c6d8465e4528fc0f3410b599dc0b26753a0.json new file mode 100644 index 0000000000..554dcbdd1b --- /dev/null +++ b/zetaclient/testdata/btc/chain_8332_tx_030cd813443f7b70cc6d8a544d320c6d8465e4528fc0f3410b599dc0b26753a0.json @@ -0,0 +1,58 @@ +{ + "amount": -0.00012, + "fee": -0.00027213, + "confirmations": 0, + "blockhash": "000000000000000000019881b7ae81a9bfac866989c8b976b1aff7ace01b85e7", + "blockindex": 150, + "blocktime": 1708608596, + "txid": "030cd813443f7b70cc6d8a544d320c6d8465e4528fc0f3410b599dc0b26753a0", + "walletconflicts": [], + "time": 1708608291, + "timereceived": 1708608291, + "details": [ + { + "account": "", + "address": "bc1qm24wp577nk8aacckv8np465z3dvmu7ry45el6y", + "amount": -0.00002148, + "category": "send", + "involveswatchonly": true, + "fee": -0.00027213, + "vout": 0 + }, + { + "account": "", + "address": "bc1qpsdlklfcmlcfgm77c43x65ddtrt7n0z57hsyjp", + "amount": -0.00012, + "category": "send", + "involveswatchonly": true, + "fee": -0.00027213, + "vout": 1 + }, + { + "account": "", + "address": "bc1qm24wp577nk8aacckv8np465z3dvmu7ry45el6y", + "amount": -0.39041489, + "category": "send", + "involveswatchonly": true, + "fee": -0.00027213, + "vout": 2 + }, + { + "account": "", + "address": "bc1qm24wp577nk8aacckv8np465z3dvmu7ry45el6y", + "amount": 0.00002148, + "category": "receive", + "involveswatchonly": true, + "vout": 0 + }, + { + "account": "", + "address": "bc1qm24wp577nk8aacckv8np465z3dvmu7ry45el6y", + "amount": 0.39041489, + "category": "receive", + "involveswatchonly": true, + "vout": 2 + } + ], + "hex": "0100000000010792fe0c144838ef41b22aa655771547acca8ee913efafb8b3eb8cbd182a30caef0000000000ffffffffefceb531b2f8db989f7f7cfba458a434e313aa84ea7070713e391d0ceb05c03d0000000000ffffffffd7e5f6f569fa1b52bdab189ca836e8c1d4409c934d34ee821bb2f325a8acc3740000000000ffffffffbfc5c2e4988acfa851d68880b4869621c3be2132c5993cab4a1f580eef4c26870000000000ffffffff7400bc3a0f71a60a4241c8ffc2dbe860a8791d34e14a62963df03d973349f25a0000000000ffffffff92fe0c144838ef41b22aa655771547acca8ee913efafb8b3eb8cbd182a30caef0200000000ffffffff30a5ad76ee984c7e2da0b44e2b7153f4885201cfba5f3ed1b226c08a935557b80000000000ffffffff036408000000000000160014daaae0d3de9d8fdee31661e61aea828b59be7864e02e0000000000001600140c1bfb7d38dff0946fdec5626d51ad58d7e9bc54d1b9530200000000160014daaae0d3de9d8fdee31661e61aea828b59be786402483045022100febd70794057e61b83e2aac09302d89910e3b79a031b084b4f148c5529a189c602207c60a3834eeb7dd989c108c735bc17e0d53ce77a016e8c0890dbc1e5dc573b6c012102fad3348b7c7d73e85a7cfe53af42b535b750419efc55c36c807f38dd0ba06edc02483045022100e2a7db33480b4623c191be348525f48d2c6c7fd8c7a976ab87d556a3d9f1bf0c022060c51f38542f13c332c114aaaa2093728343a95506930d5c810bddde4a312153012102fad3348b7c7d73e85a7cfe53af42b535b750419efc55c36c807f38dd0ba06edc0247304402203cebf1856c0af2c5c2d8ece435418e999e700507ea647bb5cf109949b337d57302206753f01b7c81b731046b18aff7589e8471524aefe6349a346870bc09ca1de8d7012102fad3348b7c7d73e85a7cfe53af42b535b750419efc55c36c807f38dd0ba06edc02473044022053358333d728affa4a95e23f66599f66c9401e4f802bbb13f014acfae28217680220793c58356267a7c0052889a7da83d37eb01306277496b3e044e4a5b4abdc4461012102fad3348b7c7d73e85a7cfe53af42b535b750419efc55c36c807f38dd0ba06edc02473044022021552ae9009183979b534dedb251d555aacaa68f140c016ebfb1e6787a35c5e00220309df76bd511124352c3955f92d0db1cbab6504fe5649b0585b0c7400403570b012102fad3348b7c7d73e85a7cfe53af42b535b750419efc55c36c807f38dd0ba06edc02483045022100fabcbdbccc0d2468af60743fa817159fdd09a3f97193f119780ee95ab4014c9202205f728cb2dea3a9272651d09c23822649b38c160d0cc222613cb75db1bcde5e18012102fad3348b7c7d73e85a7cfe53af42b535b750419efc55c36c807f38dd0ba06edc024830450221008535da95650bcd786495f3fdbf19103fc0d6408d002d60212bb61fbc7bf6894502202ba5e5e32989ac3ef293f556196c2633e6e73750e3253ed474e366c5649ac1dd012102fad3348b7c7d73e85a7cfe53af42b535b750419efc55c36c807f38dd0ba06edc00000000" +} diff --git a/zetaclient/testutils/mocks/zetacore_client_opts.go b/zetaclient/testutils/mocks/zetacore_client_opts.go index 503264d867..723daff490 100644 --- a/zetaclient/testutils/mocks/zetacore_client_opts.go +++ b/zetaclient/testutils/mocks/zetacore_client_opts.go @@ -34,6 +34,17 @@ func (_m *ZetacoreClient) WithPostVoteOutbound(zetaTxHash string, ballotIndex st return _m } +func (_m *ZetacoreClient) WithPostOutboundTracker(zetaTxHash string) *ZetacoreClient { + on := _m.On("PostOutboundTracker", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Maybe() + if zetaTxHash != "" { + on.Return(zetaTxHash, nil) + } else { + on.Return("", errSomethingIsWrong) + } + + return _m +} + func (_m *ZetacoreClient) WithPostVoteInbound(zetaTxHash string, ballotIndex string) *ZetacoreClient { _m.On("PostVoteInbound", mock.Anything, mock.Anything, mock.Anything, mock.Anything). Maybe(). diff --git a/zetaclient/testutils/testdata.go b/zetaclient/testutils/testdata.go index f79c53e5c8..9dbc0e20e3 100644 --- a/zetaclient/testutils/testdata.go +++ b/zetaclient/testutils/testdata.go @@ -117,6 +117,14 @@ func LoadBTCMsgTx(t *testing.T, dir string, chainID int64, txHash string) *wire. return msgTx } +// LoadBTCTransaction loads archived Bitcoin transaction from file +func LoadBTCTransaction(t *testing.T, dir string, chainID int64, txHash string) *btcjson.GetTransactionResult { + name := path.Join(dir, TestDataPathBTC, FileNameBTCTransaction(chainID, txHash)) + tx := &btcjson.GetTransactionResult{} + LoadObjectFromJSONFile(t, tx, name) + return tx +} + // LoadBTCTxRawResult loads archived Bitcoin tx raw result from file func LoadBTCTxRawResult(t *testing.T, dir string, chainID int64, txType string, txHash string) *btcjson.TxRawResult { name := path.Join(dir, TestDataPathBTC, FileNameBTCTxByType(chainID, txType, txHash)) diff --git a/zetaclient/testutils/testdata_naming.go b/zetaclient/testutils/testdata_naming.go index be09b0b0fa..03751c7722 100644 --- a/zetaclient/testutils/testdata_naming.go +++ b/zetaclient/testutils/testdata_naming.go @@ -64,6 +64,11 @@ func FileNameBTCMsgTx(chainID int64, txHash string) string { return fmt.Sprintf("chain_%d_msgtx_%s.json", chainID, txHash) } +// FileNameBTCTransaction returns unified archive file name for btc transaction +func FileNameBTCTransaction(chainID int64, txHash string) string { + return fmt.Sprintf("chain_%d_tx_%s.json", chainID, txHash) +} + // FileNameEVMOutbound returns unified archive file name for outbound tx func FileNameEVMOutbound(chainID int64, txHash string, coinType coin.CoinType) string { return fmt.Sprintf("chain_%d_outbound_%s_%s.json", chainID, coinType, txHash) From 7cb6118492ef4b8ca26229ee4a7800b5b292a439 Mon Sep 17 00:00:00 2001 From: Charlie Chen Date: Sun, 5 Jan 2025 23:54:21 -0600 Subject: [PATCH 05/20] mark mempool test as live test --- zetaclient/chains/bitcoin/rpc/rpc_live_test.go | 2 +- zetaclient/chains/bitcoin/rpc/rpc_rbf_live_test.go | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/zetaclient/chains/bitcoin/rpc/rpc_live_test.go b/zetaclient/chains/bitcoin/rpc/rpc_live_test.go index 452b2953df..0dff97f52d 100644 --- a/zetaclient/chains/bitcoin/rpc/rpc_live_test.go +++ b/zetaclient/chains/bitcoin/rpc/rpc_live_test.go @@ -33,7 +33,7 @@ func createRPCClient(chainID int64) (*rpcclient.Client, error) { var connCfg *rpcclient.ConnConfig rpcMainnet := os.Getenv(common.EnvBtcRPCMainnet) rpcTestnet := os.Getenv(common.EnvBtcRPCTestnet) - rpcTestnet4 := "localhost:48332" // os.Getenv(common.EnvBtcRPCTestnet4) + rpcTestnet4 := os.Getenv(common.EnvBtcRPCTestnet4) switch chainID { case chains.BitcoinMainnet.ChainId: diff --git a/zetaclient/chains/bitcoin/rpc/rpc_rbf_live_test.go b/zetaclient/chains/bitcoin/rpc/rpc_rbf_live_test.go index e6a765cf98..c91f303898 100644 --- a/zetaclient/chains/bitcoin/rpc/rpc_rbf_live_test.go +++ b/zetaclient/chains/bitcoin/rpc/rpc_rbf_live_test.go @@ -62,7 +62,7 @@ func Test_BitcoinRBFLive(t *testing.T) { return } - //LiveTest_PendingMempoolTx(t) + LiveTest_PendingMempoolTx(t) } func Test_RBFTransaction(t *testing.T) { @@ -241,7 +241,7 @@ func Test_RBFTransaction_Chained_CPFP(t *testing.T) { fmt.Println("tx1 dropped") } -func Test_PendingMempoolTx(t *testing.T) { +func LiveTest_PendingMempoolTx(t *testing.T) { // setup Bitcoin client client, err := createRPCClient(chains.BitcoinMainnet.ChainId) require.NoError(t, err) From 5d993a6a8064fb5f2d1820ce4a1e7ac6d15419d5 Mon Sep 17 00:00:00 2001 From: Charlie Chen Date: Tue, 7 Jan 2025 12:46:16 -0600 Subject: [PATCH 06/20] update BTC CCTX gas rate in block beginner --- x/crosschain/keeper/abci.go | 66 +++++++-- x/crosschain/keeper/abci_test.go | 235 +++++++++++++++++++++++++++---- x/crosschain/module.go | 2 +- 3 files changed, 266 insertions(+), 37 deletions(-) diff --git a/x/crosschain/keeper/abci.go b/x/crosschain/keeper/abci.go index d18648e6e4..b5bb5dff01 100644 --- a/x/crosschain/keeper/abci.go +++ b/x/crosschain/keeper/abci.go @@ -52,8 +52,18 @@ func (k Keeper) IterateAndUpdateCctxGasPrice( IterateChains: for _, chain := range chains { + if zetachains.IsZetaChain(chain.ChainId, additionalChains) { + continue + } + // support only external evm chains and bitcoin chain - if IsGasStabilityPoolEnabledChain(chain.ChainId, additionalChains) { + // use provided updateFunc if available, otherwise get updater based on chain type + updater, found := GetCctxGasPriceUpdater(chain.ChainId, additionalChains) + if found && updateFunc == nil { + updateFunc = updater + } + + if updateFunc != nil { res, err := k.ListPendingCctx(sdk.UnwrapSDKContext(ctx), &types.QueryListPendingCctxRequest{ ChainId: chain.ChainId, Limit: gasPriceIncreaseFlags.MaxPendingCctxs, @@ -100,9 +110,9 @@ IterateChains: return cctxCount, gasPriceIncreaseFlags } -// CheckAndUpdateCctxGasPrice checks if the retry interval is reached and updates the gas price if so +// CheckAndUpdateCctxGasPriceEVM checks if the retry interval is reached and updates the gas price if so // The function returns the gas price increase and the additional fees paid from the gas stability pool -func CheckAndUpdateCctxGasPrice( +func CheckAndUpdateCctxGasPriceEVM( ctx sdk.Context, k Keeper, cctx types.CrossChainTx, @@ -176,14 +186,54 @@ func CheckAndUpdateCctxGasPrice( return gasPriceIncrease, additionalFees, nil } -// IsGasStabilityPoolEnabledChain returns true if given chainID is enabled for gas stability pool -func IsGasStabilityPoolEnabledChain(chainID int64, additionalChains []zetachains.Chain) bool { +// CheckAndUpdateCctxGasRateBTC checks if the retry interval is reached and updates the gas rate if so +// Zetacore only needs to update the gas rate in CCTX and fee bumping will be handled by zetaclient +func CheckAndUpdateCctxGasRateBTC( + ctx sdk.Context, + k Keeper, + cctx types.CrossChainTx, + flags observertypes.GasPriceIncreaseFlags, +) (math.Uint, math.Uint, error) { + // skip if gas price or gas limit is not set + if cctx.GetCurrentOutboundParam().GasPrice == "" || cctx.GetCurrentOutboundParam().CallOptions.GasLimit == 0 { + return math.ZeroUint(), math.ZeroUint(), nil + } + + // skip if retry interval is not reached + lastUpdated := time.Unix(cctx.CctxStatus.LastUpdateTimestamp, 0) + if ctx.BlockTime().Before(lastUpdated.Add(flags.RetryInterval)) { + return math.ZeroUint(), math.ZeroUint(), nil + } + + // compute gas price increase + chainID := cctx.GetCurrentOutboundParam().ReceiverChainId + medianGasPrice, _, isFound := k.GetMedianGasValues(ctx, chainID) + if !isFound { + return math.ZeroUint(), math.ZeroUint(), cosmoserrors.Wrap( + types.ErrUnableToGetGasPrice, + fmt.Sprintf("cannot get gas price for chain %d", chainID), + ) + } + + // set new gas rate and last update timestamp + // there is no priority fee in Bitcoin, we reuse 'GasPriorityFee' to store latest gas rate in satoshi/vByte + cctx.GetCurrentOutboundParam().GasPriorityFee = medianGasPrice.String() + k.SetCrossChainTx(ctx, cctx) + + return math.ZeroUint(), math.ZeroUint(), nil +} + +// GetCctxGasPriceUpdater returns the function to update gas price according to chain type +func GetCctxGasPriceUpdater(chainID int64, additionalChains []zetachains.Chain) (CheckAndUpdateCctxGasPriceFunc, bool) { switch { case zetachains.IsEVMChain(chainID, additionalChains): - return !zetachains.IsZetaChain(chainID, additionalChains) + if !zetachains.IsZetaChain(chainID, additionalChains) { + return CheckAndUpdateCctxGasPriceEVM, true + } + return nil, false case zetachains.IsBitcoinChain(chainID, additionalChains): - return true + return CheckAndUpdateCctxGasRateBTC, true default: - return false + return nil, false } } diff --git a/x/crosschain/keeper/abci_test.go b/x/crosschain/keeper/abci_test.go index 2e1cbd246b..5db8261904 100644 --- a/x/crosschain/keeper/abci_test.go +++ b/x/crosschain/keeper/abci_test.go @@ -2,6 +2,7 @@ package keeper_test import ( "errors" + "reflect" "testing" "time" @@ -88,15 +89,21 @@ func TestKeeper_IterateAndUpdateCctxGasPrice(t *testing.T) { ctx = ctx.WithBlockHeight(observertypes.DefaultCrosschainFlags().GasPriceIncreaseFlags.EpochLength * 2) cctxCount, flags = k.IterateAndUpdateCctxGasPrice(ctx, supportedChains, updateFunc) - // 2 eth + 5 bsc = 7 - require.Equal(t, 7, cctxCount) + // 2 eth + 5 btc + 5 bsc = 12 + require.Equal(t, 12, cctxCount) require.Equal(t, customFlags, flags) // check that the update function was called with the cctx index - require.Equal(t, 7, len(updateFuncMap)) + require.Equal(t, 12, len(updateFuncMap)) require.Contains(t, updateFuncMap, sample.GetCctxIndexFromString("1-10")) require.Contains(t, updateFuncMap, sample.GetCctxIndexFromString("1-11")) + require.Contains(t, updateFuncMap, sample.GetCctxIndexFromString("8332-20")) + require.Contains(t, updateFuncMap, sample.GetCctxIndexFromString("8332-21")) + require.Contains(t, updateFuncMap, sample.GetCctxIndexFromString("8332-22")) + require.Contains(t, updateFuncMap, sample.GetCctxIndexFromString("8332-23")) + require.Contains(t, updateFuncMap, sample.GetCctxIndexFromString("8332-24")) + require.Contains(t, updateFuncMap, sample.GetCctxIndexFromString("56-30")) require.Contains(t, updateFuncMap, sample.GetCctxIndexFromString("56-31")) require.Contains(t, updateFuncMap, sample.GetCctxIndexFromString("56-32")) @@ -104,7 +111,7 @@ func TestKeeper_IterateAndUpdateCctxGasPrice(t *testing.T) { require.Contains(t, updateFuncMap, sample.GetCctxIndexFromString("56-34")) } -func TestCheckAndUpdateCctxGasPrice(t *testing.T) { +func Test_CheckAndUpdateCctxGasPriceEVM(t *testing.T) { sampleTimestamp := time.Now() retryIntervalReached := sampleTimestamp.Add(observertypes.DefaultGasPriceIncreaseFlags.RetryInterval + time.Second) retryIntervalNotReached := sampleTimestamp.Add( @@ -407,7 +414,7 @@ func TestCheckAndUpdateCctxGasPrice(t *testing.T) { } // check and update gas price - gasPriceIncrease, feesPaid, err := keeper.CheckAndUpdateCctxGasPrice(ctx, *k, tc.cctx, tc.flags) + gasPriceIncrease, feesPaid, err := keeper.CheckAndUpdateCctxGasPriceEVM(ctx, *k, tc.cctx, tc.flags) if tc.isError { require.Error(t, err) @@ -451,47 +458,219 @@ func TestCheckAndUpdateCctxGasPrice(t *testing.T) { } } -func TestIsGasStabilityPoolEnabledChain(t *testing.T) { +func Test_CheckAndUpdateCctxGasRateBTC(t *testing.T) { + sampleTimestamp := time.Now() + gasRateUpdateInterval := observertypes.DefaultGasPriceIncreaseFlags.RetryInterval + retryIntervalReached := sampleTimestamp.Add(gasRateUpdateInterval + time.Second) + retryIntervalNotReached := sampleTimestamp.Add(gasRateUpdateInterval - time.Second) + + tt := []struct { + name string + cctx types.CrossChainTx + flags observertypes.GasPriceIncreaseFlags + blockTimestamp time.Time + medianGasPrice uint64 + medianPriorityFee uint64 + shouldUpdate bool + isError bool + }{ + { + name: "can update gas rate when retry interval is reached", + cctx: types.CrossChainTx{ + Index: "a", + CctxStatus: &types.Status{ + CreatedTimestamp: sampleTimestamp.Unix(), + LastUpdateTimestamp: sampleTimestamp.Unix(), + }, + OutboundParams: []*types.OutboundParams{ + { + ReceiverChainId: 8332, + CallOptions: &types.CallOptions{ + GasLimit: 254, + }, + GasPrice: "10", + }, + }, + }, + flags: observertypes.DefaultGasPriceIncreaseFlags, + blockTimestamp: retryIntervalReached, + medianGasPrice: 12, + shouldUpdate: true, + }, + { + name: "skip if gas price is not set", + cctx: types.CrossChainTx{ + Index: "b1", + OutboundParams: []*types.OutboundParams{ + { + GasPrice: "", + }, + }, + }, + flags: observertypes.DefaultGasPriceIncreaseFlags, + blockTimestamp: retryIntervalReached, + medianGasPrice: 12, + }, + { + name: "skip if gas limit is not set", + cctx: types.CrossChainTx{ + Index: "b2", + OutboundParams: []*types.OutboundParams{ + { + CallOptions: &types.CallOptions{ + GasLimit: 0, + }, + GasPrice: "10", + }, + }, + }, + flags: observertypes.DefaultGasPriceIncreaseFlags, + blockTimestamp: retryIntervalReached, + medianGasPrice: 12, + }, + { + name: "skip if retry interval is not reached", + cctx: types.CrossChainTx{ + Index: "b3", + CctxStatus: &types.Status{ + CreatedTimestamp: sampleTimestamp.Unix(), + LastUpdateTimestamp: sampleTimestamp.Unix(), + }, + OutboundParams: []*types.OutboundParams{ + { + CallOptions: &types.CallOptions{ + GasLimit: 254, + }, + GasPrice: "10", + }, + }, + }, + flags: observertypes.DefaultGasPriceIncreaseFlags, + blockTimestamp: retryIntervalNotReached, + medianGasPrice: 12, + }, + { + name: "returns error if can't find median gas price", + cctx: types.CrossChainTx{ + Index: "b4", + CctxStatus: &types.Status{ + CreatedTimestamp: sampleTimestamp.Unix(), + LastUpdateTimestamp: sampleTimestamp.Unix(), + }, + OutboundParams: []*types.OutboundParams{ + { + ReceiverChainId: 8332, + CallOptions: &types.CallOptions{ + GasLimit: 254, + }, + GasPrice: "10", + }, + }, + }, + flags: observertypes.DefaultGasPriceIncreaseFlags, + blockTimestamp: retryIntervalReached, + medianGasPrice: 0, + isError: true, + }, + } + for _, tc := range tt { + tc := tc + t.Run(tc.name, func(t *testing.T) { + k, ctx := testkeeper.CrosschainKeeperAllMocks(t) + chainID := tc.cctx.GetCurrentOutboundParam().ReceiverChainId + + // set median gas price if not zero + if tc.medianGasPrice != 0 { + k.SetGasPrice(ctx, types.GasPrice{ + ChainId: chainID, + Prices: []uint64{tc.medianGasPrice}, + MedianIndex: 0, + }) + + // ensure median gas price is set + medianGasPrice, _, isFound := k.GetMedianGasValues(ctx, chainID) + require.True(t, isFound) + require.True(t, medianGasPrice.Equal(math.NewUint(tc.medianGasPrice))) + } + + // set block timestamp + ctx = ctx.WithBlockTime(tc.blockTimestamp) + + // check and update gas rate + gasPriceIncrease, feesPaid, err := keeper.CheckAndUpdateCctxGasRateBTC(ctx, *k, tc.cctx, tc.flags) + if tc.isError { + require.Error(t, err) + return + } + require.NoError(t, err) + + // check values + require.True(t, gasPriceIncrease.IsZero()) + require.True(t, feesPaid.IsZero()) + + // check cctx if gas rate is updated + if tc.shouldUpdate { + cctx, found := k.GetCrossChainTx(ctx, tc.cctx.Index) + require.True(t, found) + newGasPrice, err := cctx.GetCurrentOutboundParam().GetGasPriorityFeeUInt64() + require.NoError(t, err) + require.Equal(t, tc.medianGasPrice, newGasPrice) + require.EqualValues(t, tc.blockTimestamp.Unix(), cctx.CctxStatus.LastUpdateTimestamp) + } + }) + } +} + +func Test_GetCctxGasPriceUpdater(t *testing.T) { tests := []struct { - name string - chainID int64 - expected bool + name string + chainID int64 + found bool + updateFunc keeper.CheckAndUpdateCctxGasPriceFunc }{ { - name: "Ethereum is enabled", - chainID: chains.Ethereum.ChainId, - expected: true, + name: "Ethereum is enabled", + chainID: chains.Ethereum.ChainId, + found: true, + updateFunc: keeper.CheckAndUpdateCctxGasPriceEVM, }, { - name: "Binance Smart Chain is enabled", - chainID: chains.BscMainnet.ChainId, - expected: true, + name: "Binance Smart Chain is enabled", + chainID: chains.BscMainnet.ChainId, + found: true, + updateFunc: keeper.CheckAndUpdateCctxGasPriceEVM, }, { - name: "Bitcoin is enabled", - chainID: chains.BitcoinMainnet.ChainId, - expected: true, + name: "Bitcoin is enabled", + chainID: chains.BitcoinMainnet.ChainId, + found: true, + updateFunc: keeper.CheckAndUpdateCctxGasRateBTC, }, { - name: "ZetaChain is not enabled", - chainID: chains.ZetaChainMainnet.ChainId, - expected: false, + name: "ZetaChain is not enabled", + chainID: chains.ZetaChainMainnet.ChainId, + found: false, + updateFunc: nil, }, { - name: "Solana is not enabled", - chainID: chains.SolanaMainnet.ChainId, - expected: false, + name: "Solana is not enabled", + chainID: chains.SolanaMainnet.ChainId, + found: false, + updateFunc: nil, }, { - name: "TON is not enabled", - chainID: chains.TONMainnet.ChainId, - expected: false, + name: "TON is not enabled", + chainID: chains.TONMainnet.ChainId, + found: false, + updateFunc: nil, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - require.Equal(t, tt.expected, keeper.IsGasStabilityPoolEnabledChain(tt.chainID, []zetachains.Chain{})) + updateFunc, found := keeper.GetCctxGasPriceUpdater(tt.chainID, []zetachains.Chain{}) + require.Equal(t, tt.found, found) + require.Equal(t, reflect.ValueOf(tt.updateFunc).Pointer(), reflect.ValueOf(updateFunc).Pointer()) }) } } diff --git a/x/crosschain/module.go b/x/crosschain/module.go index e3b71cfeb0..743043c190 100644 --- a/x/crosschain/module.go +++ b/x/crosschain/module.go @@ -172,7 +172,7 @@ func (am AppModule) BeginBlock(ctx sdk.Context, _ abci.RequestBeginBlock) { // iterate and update gas price for cctx that are pending for too long // error is logged in the function - am.keeper.IterateAndUpdateCctxGasPrice(ctx, supportedChains, keeper.CheckAndUpdateCctxGasPrice) + am.keeper.IterateAndUpdateCctxGasPrice(ctx, supportedChains, nil) } // EndBlock executes all ABCI EndBlock logic respective to the crosschain module. It From e0845bda2c3d0009906cbba0cec502f375cfe666 Mon Sep 17 00:00:00 2001 From: Charlie Chen Date: Tue, 7 Jan 2025 20:29:35 -0600 Subject: [PATCH 07/20] add mempool tests --- zetaclient/chains/bitcoin/observer/mempool.go | 59 +-- .../chains/bitcoin/observer/mempool_test.go | 348 ++++++++++++++++++ .../chains/bitcoin/observer/observer_test.go | 3 +- 3 files changed, 386 insertions(+), 24 deletions(-) create mode 100644 zetaclient/chains/bitcoin/observer/mempool_test.go diff --git a/zetaclient/chains/bitcoin/observer/mempool.go b/zetaclient/chains/bitcoin/observer/mempool.go index 7909d64c39..a6287ef805 100644 --- a/zetaclient/chains/bitcoin/observer/mempool.go +++ b/zetaclient/chains/bitcoin/observer/mempool.go @@ -9,6 +9,7 @@ import ( "github.com/zeta-chain/node/pkg/ticker" "github.com/zeta-chain/node/zetaclient/chains/bitcoin/rpc" + "github.com/zeta-chain/node/zetaclient/chains/interfaces" "github.com/zeta-chain/node/zetaclient/common" "github.com/zeta-chain/node/zetaclient/logs" ) @@ -34,11 +35,17 @@ func NewLastStuckOutbound(nonce uint64, tx *btcutil.Tx, stuckFor time.Duration) } } +// LastTxFinder is a function type for finding the last Bitcoin outbound tx. +type LastTxFinder func(ctx context.Context, ob *Observer) (*btcutil.Tx, uint64, error) + +// StuckTxChecker is a function type for checking if a tx is stuck in the mempool. +type StuckTxChecker func(client interfaces.BTCRPCClient, txHash string, maxWaitBlocks int64) (bool, time.Duration, error) + // WatchMempoolTxs monitors pending outbound txs in the Bitcoin mempool. func (ob *Observer) WatchMempoolTxs(ctx context.Context) error { task := func(ctx context.Context, _ *ticker.Ticker) error { - if err := ob.refreshLastStuckOutbound(ctx); err != nil { - ob.Logger().Chain.Err(err).Msg("refreshLastStuckOutbound error") + if err := ob.RefreshLastStuckOutbound(ctx, GetLastOutbound, rpc.IsTxStuckInMempool); err != nil { + ob.Logger().Chain.Err(err).Msg("RefreshLastStuckOutbound error") } return nil } @@ -52,25 +59,29 @@ func (ob *Observer) WatchMempoolTxs(ctx context.Context) error { ) } -// refreshLastStuckOutbound refreshes the information about the last stuck tx in the Bitcoin mempool. -func (ob *Observer) refreshLastStuckOutbound(ctx context.Context) error { - // log fields - lf := map[string]any{ - logs.FieldMethod: "refreshLastStuckOutbound", - } - +// RefreshLastStuckOutbound refreshes the information about the last stuck tx in the Bitcoin mempool. +func (ob *Observer) RefreshLastStuckOutbound( + ctx context.Context, + txFinder LastTxFinder, + txChecker StuckTxChecker, +) error { // step 1: get last TSS transaction - lastTx, lastNonce, err := ob.GetLastOutbound(ctx) + lastTx, lastNonce, err := txFinder(ctx, ob) if err != nil { - return errors.Wrap(err, "GetLastOutbound failed") + return errors.Wrap(err, "unable to find last outbound") } + + // log fields txHash := lastTx.MsgTx().TxID() - lf[logs.FieldNonce] = lastNonce - lf[logs.FieldTx] = txHash + lf := map[string]any{ + logs.FieldMethod: "RefreshLastStuckOutbound", + logs.FieldNonce: lastNonce, + logs.FieldTx: txHash, + } ob.logger.Outbound.Info().Fields(lf).Msg("checking last TSS outbound") // step 2: is last tx stuck in mempool? - stuck, stuckFor, err := rpc.IsTxStuckInMempool(ob.btcClient, txHash, rpc.PendingTxFeeBumpWaitBlocks) + stuck, stuckFor, err := txChecker(ob.btcClient, txHash, rpc.PendingTxFeeBumpWaitBlocks) if err != nil { return errors.Wrapf(err, "cannot determine if tx %s nonce %d is stuck", txHash, lastNonce) } @@ -107,7 +118,7 @@ func (ob *Observer) refreshLastStuckOutbound(ctx context.Context) error { // 2. txs that had been broadcasted by this observer self. // // Once 2/3+ of the observers reach consensus on last outbound, RBF will start. -func (ob *Observer) GetLastOutbound(ctx context.Context) (*btcutil.Tx, uint64, error) { +func GetLastOutbound(ctx context.Context, ob *Observer) (*btcutil.Tx, uint64, error) { var ( lastNonce uint64 lastHash string @@ -121,13 +132,11 @@ func (ob *Observer) GetLastOutbound(ctx context.Context) (*btcutil.Tx, uint64, e // source 1: // pick highest nonce tx from included txs - lastNonce = pendingNonce - 1 - txResult := ob.GetIncludedTx(lastNonce) - if txResult == nil { - // should NEVER happen by design - return nil, 0, errors.New("last included tx not found") + txResult := ob.GetIncludedTx(pendingNonce - 1) + if txResult != nil { + lastNonce = pendingNonce - 1 + lastHash = txResult.TxID } - lastHash = txResult.TxID // source 2: // pick highest nonce tx from broadcasted txs @@ -145,7 +154,13 @@ func (ob *Observer) GetLastOutbound(ctx context.Context) (*btcutil.Tx, uint64, e } } - // ensure this nonce is the REAL last transaction + // stop if last tx not found, and it is okay + // this individual zetaclient lost track of the last tx for some reason (offline, db reset, etc.) + if lastNonce == 0 { + return nil, 0, errors.New("last tx not found") + } + + // ensure this tx is the REAL last transaction // cross-check the latest UTXO list, the nonce-mark utxo exists ONLY for last nonce if ob.FetchUTXOs(ctx) != nil { return nil, 0, errors.New("FetchUTXOs failed") diff --git a/zetaclient/chains/bitcoin/observer/mempool_test.go b/zetaclient/chains/bitcoin/observer/mempool_test.go new file mode 100644 index 0000000000..621a053a8d --- /dev/null +++ b/zetaclient/chains/bitcoin/observer/mempool_test.go @@ -0,0 +1,348 @@ +package observer_test + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/btcsuite/btcd/btcjson" + "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/wire" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + "github.com/zeta-chain/node/pkg/chains" + crosschaintypes "github.com/zeta-chain/node/x/observer/types" + "github.com/zeta-chain/node/zetaclient/chains/bitcoin/observer" + "github.com/zeta-chain/node/zetaclient/chains/interfaces" + "github.com/zeta-chain/node/zetaclient/testutils" +) + +func Test_NewLastStuckOutbound(t *testing.T) { + nonce := uint64(1) + tx := btcutil.NewTx(wire.NewMsgTx(wire.TxVersion)) + stuckFor := 30 * time.Minute + stuckOutbound := observer.NewLastStuckOutbound(nonce, tx, stuckFor) + + require.Equal(t, nonce, stuckOutbound.Nonce) + require.Equal(t, tx, stuckOutbound.Tx) + require.Equal(t, stuckFor, stuckOutbound.StuckFor) +} + +func Test_FefreshLastStuckOutbound(t *testing.T) { + sampleTx1 := btcutil.NewTx(wire.NewMsgTx(wire.TxVersion)) + sampleTx2 := btcutil.NewTx(wire.NewMsgTx(2)) + + tests := []struct { + name string + txFinder observer.LastTxFinder + txChecker observer.StuckTxChecker + oldStuckTx *observer.LastStuckOutbound + expectedTx *observer.LastStuckOutbound + errMsg string + }{ + { + name: "should set last stuck tx successfully", + txFinder: makeLastTxFinder(sampleTx1, 1, ""), + txChecker: makeStuckTxChecker(true, 30*time.Minute, ""), + oldStuckTx: nil, + expectedTx: observer.NewLastStuckOutbound(1, sampleTx1, 30*time.Minute), + }, + { + name: "should update last stuck tx successfully", + txFinder: makeLastTxFinder(sampleTx2, 2, ""), + txChecker: makeStuckTxChecker(true, 40*time.Minute, ""), + oldStuckTx: observer.NewLastStuckOutbound(1, sampleTx1, 30*time.Minute), + expectedTx: observer.NewLastStuckOutbound(2, sampleTx2, 40*time.Minute), + }, + { + name: "should clear last stuck tx successfully", + txFinder: makeLastTxFinder(sampleTx1, 1, ""), + txChecker: makeStuckTxChecker(false, 1*time.Minute, ""), + oldStuckTx: observer.NewLastStuckOutbound(1, sampleTx1, 30*time.Minute), + expectedTx: nil, + }, + { + name: "should return error if txFinder failed", + txFinder: makeLastTxFinder(nil, 0, "txFinder failed"), + expectedTx: nil, + errMsg: "unable to find last outbound", + }, + { + name: "should return error if txChecker failed", + txFinder: makeLastTxFinder(sampleTx1, 1, ""), + txChecker: makeStuckTxChecker(false, 0, "txChecker failed"), + expectedTx: nil, + errMsg: "cannot determine", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // create observer + ob := newTestSuite(t, chains.BitcoinMainnet, "") + + // setup old stuck tx + if tt.oldStuckTx != nil { + ob.SetLastStuckOutbound(tt.oldStuckTx) + } + + // refresh + ctx := context.Background() + err := ob.RefreshLastStuckOutbound(ctx, tt.txFinder, tt.txChecker) + + if tt.errMsg == "" { + require.NoError(t, err) + } else { + require.ErrorContains(t, err, tt.errMsg) + } + + // check + stuckTx := ob.GetLastStuckOutbound() + require.Equal(t, tt.expectedTx, stuckTx) + }) + } +} + +func Test_GetLastOutbound(t *testing.T) { + sampleTx := btcutil.NewTx(wire.NewMsgTx(wire.TxVersion)) + + tests := []struct { + name string + chain chains.Chain + pendingNonce uint64 + pendingNonces *crosschaintypes.PendingNonces + utxos []btcjson.ListUnspentResult + tx *btcutil.Tx + saveTx bool + includeTx bool + failGetTx bool + expectedTx *btcutil.Tx + expectedNonce uint64 + errMsg string + }{ + { + name: "should return last included outbound", + chain: chains.BitcoinMainnet, + pendingNonce: 10, + pendingNonces: &crosschaintypes.PendingNonces{ + NonceLow: 9, + NonceHigh: 10, + }, + utxos: []btcjson.ListUnspentResult{ + { + TxID: sampleTx.MsgTx().TxID(), + Vout: 0, + Address: testutils.TSSAddressBTCMainnet, + Amount: float64(chains.NonceMarkAmount(9)) / btcutil.SatoshiPerBitcoin, + }, + }, + tx: sampleTx, + saveTx: false, + includeTx: true, + expectedTx: sampleTx, + expectedNonce: 9, + }, + { + name: "should return last broadcasted outbound", + chain: chains.BitcoinMainnet, + pendingNonce: 10, + pendingNonces: &crosschaintypes.PendingNonces{ + NonceLow: 9, + NonceHigh: 10, + }, + utxos: []btcjson.ListUnspentResult{ + { + TxID: sampleTx.MsgTx().TxID(), + Vout: 0, + Address: testutils.TSSAddressBTCMainnet, + Amount: float64(chains.NonceMarkAmount(9)) / btcutil.SatoshiPerBitcoin, + }, + }, + tx: sampleTx, + saveTx: true, + includeTx: false, + expectedTx: sampleTx, + expectedNonce: 9, + }, + { + name: "return error if pending nonce is zero", + chain: chains.BitcoinMainnet, + pendingNonce: 0, + expectedTx: nil, + expectedNonce: 0, + errMsg: "pending nonce is zero", + }, + { + name: "return error if GetPendingNoncesByChain failed", + chain: chains.BitcoinMainnet, + pendingNonce: 10, + saveTx: true, + includeTx: false, + expectedTx: nil, + expectedNonce: 0, + errMsg: "GetPendingNoncesByChain failed", + }, + { + name: "return error if no last tx found", + chain: chains.BitcoinMainnet, + pendingNonce: 10, + pendingNonces: &crosschaintypes.PendingNonces{ + NonceLow: 9, + NonceHigh: 10, + }, + saveTx: false, + includeTx: false, + expectedTx: nil, + expectedNonce: 0, + errMsg: "last tx not found", + }, + { + name: "return error if FetchUTXOs failed", + chain: chains.BitcoinMainnet, + pendingNonce: 10, + pendingNonces: &crosschaintypes.PendingNonces{ + NonceLow: 9, + NonceHigh: 10, + }, + tx: sampleTx, + saveTx: true, + includeTx: false, + expectedTx: nil, + expectedNonce: 0, + errMsg: "FetchUTXOs failed", + }, + { + name: "return error if unable to find nonce-mark UTXO", + chain: chains.BitcoinMainnet, + pendingNonce: 10, + pendingNonces: &crosschaintypes.PendingNonces{ + NonceLow: 9, + NonceHigh: 10, + }, + utxos: []btcjson.ListUnspentResult{ + { + TxID: sampleTx.MsgTx().TxID(), + Vout: 1, // wrong output index + Address: testutils.TSSAddressBTCMainnet, + Amount: float64(chains.NonceMarkAmount(9)) / btcutil.SatoshiPerBitcoin, + }, + }, + tx: sampleTx, + saveTx: true, + includeTx: false, + expectedTx: nil, + expectedNonce: 0, + errMsg: "findNonceMarkUTXO failed", + }, + { + name: "return error if GetRawTxByHash failed", + chain: chains.BitcoinMainnet, + pendingNonce: 10, + pendingNonces: &crosschaintypes.PendingNonces{ + NonceLow: 9, + NonceHigh: 10, + }, + utxos: []btcjson.ListUnspentResult{ + { + TxID: sampleTx.MsgTx().TxID(), + Vout: 0, + Address: testutils.TSSAddressBTCMainnet, + Amount: float64(chains.NonceMarkAmount(9)) / btcutil.SatoshiPerBitcoin, + }, + }, + tx: sampleTx, + saveTx: false, + includeTx: true, + failGetTx: true, + expectedTx: nil, + expectedNonce: 0, + errMsg: "GetRawTxByHash failed", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // create observer + ob := newTestSuite(t, chains.BitcoinMainnet, "") + + // set pending nonce + ob.SetPendingNonce(tt.pendingNonce) + + if tt.tx != nil { + // save tx to simulate broadcasted tx + txNonce := tt.pendingNonce - 1 + if tt.saveTx { + ob.SaveBroadcastedTx(tt.tx.MsgTx().TxID(), txNonce) + } + + // include tx to simulate included tx + if tt.includeTx { + ob.SetIncludedTx(txNonce, &btcjson.GetTransactionResult{ + TxID: tt.tx.MsgTx().TxID(), + }) + } + } + + // mock zetacore client response + if tt.pendingNonces != nil { + ob.zetacore.On("GetPendingNoncesByChain", mock.Anything, mock.Anything). + Maybe(). + Return(*tt.pendingNonces, nil) + } else { + res := crosschaintypes.PendingNonces{} + ob.zetacore.On("GetPendingNoncesByChain", mock.Anything, mock.Anything).Maybe().Return(res, errors.New("failed")) + } + + // mock btc client response + if tt.utxos != nil { + ob.client.On("ListUnspentMinMaxAddresses", mock.Anything, mock.Anything, mock.Anything). + Maybe(). + Return(tt.utxos, nil) + } else { + ob.client.On("ListUnspentMinMaxAddresses", mock.Anything, mock.Anything, mock.Anything).Maybe().Return(nil, errors.New("failed")) + } + if tt.tx != nil && !tt.failGetTx { + ob.client.On("GetRawTransaction", mock.Anything).Maybe().Return(tt.tx, nil) + } else { + ob.client.On("GetRawTransaction", mock.Anything).Maybe().Return(nil, errors.New("failed")) + } + + ctx := context.Background() + lastTx, lastNonce, err := observer.GetLastOutbound(ctx, ob.Observer) + + if tt.errMsg != "" { + require.ErrorContains(t, err, tt.errMsg) + require.Nil(t, lastTx) + require.Zero(t, lastNonce) + return + } + + require.NoError(t, err) + require.Equal(t, tt.expectedTx, lastTx) + require.Equal(t, tt.expectedNonce, lastNonce) + }) + } +} + +// makeLastTxFinder is a helper function to create a mock tx finder +func makeLastTxFinder(tx *btcutil.Tx, nonce uint64, errMsg string) observer.LastTxFinder { + var err error + if errMsg != "" { + err = errors.New(errMsg) + } + return func(_ context.Context, _ *observer.Observer) (*btcutil.Tx, uint64, error) { + return tx, nonce, err + } +} + +// makeStuckTxChecker is a helper function to create a mock stuck tx checker +func makeStuckTxChecker(stuck bool, stuckFor time.Duration, errMsg string) observer.StuckTxChecker { + var err error + if errMsg != "" { + err = errors.New(errMsg) + } + return func(_ interfaces.BTCRPCClient, _ string, _ int64) (bool, time.Duration, error) { + return stuck, stuckFor, err + } +} diff --git a/zetaclient/chains/bitcoin/observer/observer_test.go b/zetaclient/chains/bitcoin/observer/observer_test.go index 88dcfd0b27..001662c421 100644 --- a/zetaclient/chains/bitcoin/observer/observer_test.go +++ b/zetaclient/chains/bitcoin/observer/observer_test.go @@ -335,8 +335,7 @@ func newTestSuite(t *testing.T, chain chains.Chain, dbPath string) *testSuite { tss, database, base.DefaultLogger(), - //base.Logger{Std: log, Compliance: log}, - nil, + &metrics.TelemetryServer{}, ) require.NoError(t, err) From 5c15070b23179fe8054e6edb473c83f3608fa2df Mon Sep 17 00:00:00 2001 From: Charlie Chen Date: Wed, 8 Jan 2025 00:27:01 -0600 Subject: [PATCH 08/20] add keysign tests --- testutil/sample/crypto.go | 13 +- x/crosschain/types/cctx_test.go | 2 +- x/crosschain/types/revert_options_test.go | 2 +- .../chains/bitcoin/observer/event_test.go | 6 +- .../chains/bitcoin/observer/inbound_test.go | 2 +- .../chains/bitcoin/signer/fee_bumper_test.go | 26 +- .../signer/{sign_withdraw.go => sign.go} | 0 .../{sign_withdraw_rbf.go => sign_rbf.go} | 15 +- .../chains/bitcoin/signer/sign_rbf_test.go | 235 ++++++++++++++++++ .../{sign_withdraw_test.go => sign_test.go} | 78 +++++- zetaclient/chains/bitcoin/signer/signer.go | 10 +- .../chains/bitcoin/signer/signer_test.go | 2 +- 12 files changed, 359 insertions(+), 32 deletions(-) rename zetaclient/chains/bitcoin/signer/{sign_withdraw.go => sign.go} (100%) rename zetaclient/chains/bitcoin/signer/{sign_withdraw_rbf.go => sign_rbf.go} (82%) create mode 100644 zetaclient/chains/bitcoin/signer/sign_rbf_test.go rename zetaclient/chains/bitcoin/signer/{sign_withdraw_test.go => sign_test.go} (72%) diff --git a/testutil/sample/crypto.go b/testutil/sample/crypto.go index 9e643fa123..93b7db2d1e 100644 --- a/testutil/sample/crypto.go +++ b/testutil/sample/crypto.go @@ -11,6 +11,7 @@ import ( "github.com/btcsuite/btcd/btcutil" "github.com/btcsuite/btcd/chaincfg" "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/txscript" "github.com/cometbft/cometbft/crypto/secp256k1" "github.com/cosmos/cosmos-sdk/crypto/keys/ed25519" cryptotypes "github.com/cosmos/cosmos-sdk/crypto/types" @@ -65,7 +66,7 @@ func EthAddressFromRand(r *rand.Rand) ethcommon.Address { } // BtcAddressP2WPKH returns a sample btc P2WPKH address -func BtcAddressP2WPKH(t *testing.T, net *chaincfg.Params) string { +func BtcAddressP2WPKH(t *testing.T, net *chaincfg.Params) *btcutil.AddressWitnessPubKeyHash { privateKey, err := btcec.NewPrivateKey() require.NoError(t, err) @@ -73,7 +74,15 @@ func BtcAddressP2WPKH(t *testing.T, net *chaincfg.Params) string { addr, err := btcutil.NewAddressWitnessPubKeyHash(pubKeyHash, net) require.NoError(t, err) - return addr.String() + return addr +} + +// BtcAddressP2WPKH returns a pkscript for a sample btc P2WPKH address +func BtcAddressP2WPKHScript(t *testing.T, net *chaincfg.Params) []byte { + addr := BtcAddressP2WPKH(t, net) + script, err := txscript.PayToAddrScript(addr) + require.NoError(t, err) + return script } // SolanaPrivateKey returns a sample solana private key diff --git a/x/crosschain/types/cctx_test.go b/x/crosschain/types/cctx_test.go index 1369e2dee0..1e2d6c830d 100644 --- a/x/crosschain/types/cctx_test.go +++ b/x/crosschain/types/cctx_test.go @@ -140,7 +140,7 @@ func Test_SetRevertOutboundValues(t *testing.T) { cctx := sample.CrossChainTx(t, "test") cctx.InboundParams.SenderChainId = chains.BitcoinTestnet.ChainId cctx.OutboundParams = cctx.OutboundParams[:1] - cctx.RevertOptions.RevertAddress = sample.BtcAddressP2WPKH(t, &chaincfg.TestNet3Params) + cctx.RevertOptions.RevertAddress = sample.BtcAddressP2WPKH(t, &chaincfg.TestNet3Params).String() err := cctx.AddRevertOutbound(100) require.NoError(t, err) diff --git a/x/crosschain/types/revert_options_test.go b/x/crosschain/types/revert_options_test.go index c91927dd86..3fa6a6e8ba 100644 --- a/x/crosschain/types/revert_options_test.go +++ b/x/crosschain/types/revert_options_test.go @@ -49,7 +49,7 @@ func TestRevertOptions_GetEVMRevertAddress(t *testing.T) { func TestRevertOptions_GetBTCRevertAddress(t *testing.T) { t.Run("valid Bitcoin revert address", func(t *testing.T) { - addr := sample.BtcAddressP2WPKH(t, &chaincfg.TestNet3Params) + addr := sample.BtcAddressP2WPKH(t, &chaincfg.TestNet3Params).String() actualAddr, valid := types.RevertOptions{ RevertAddress: addr, }.GetBTCRevertAddress(chains.BitcoinTestnet.ChainId) diff --git a/zetaclient/chains/bitcoin/observer/event_test.go b/zetaclient/chains/bitcoin/observer/event_test.go index 2264ff21d0..ca8d79e155 100644 --- a/zetaclient/chains/bitcoin/observer/event_test.go +++ b/zetaclient/chains/bitcoin/observer/event_test.go @@ -32,7 +32,7 @@ func createTestBtcEvent( memoStd *memo.InboundMemo, ) observer.BTCInboundEvent { return observer.BTCInboundEvent{ - FromAddress: sample.BtcAddressP2WPKH(t, net), + FromAddress: sample.BtcAddressP2WPKH(t, net).String(), ToAddress: sample.EthAddress().Hex(), MemoBytes: memo, MemoStd: memoStd, @@ -249,7 +249,7 @@ func Test_ValidateStandardMemo(t *testing.T) { }, FieldsV0: memo.FieldsV0{ RevertOptions: crosschaintypes.RevertOptions{ - RevertAddress: sample.BtcAddressP2WPKH(t, &chaincfg.TestNet3Params), + RevertAddress: sample.BtcAddressP2WPKH(t, &chaincfg.TestNet3Params).String(), }, }, }, @@ -400,7 +400,7 @@ func Test_NewInboundVoteFromStdMemo(t *testing.T) { t.Run("should create new inbound vote msg with standard memo", func(t *testing.T) { // create revert options revertOptions := crosschaintypes.NewEmptyRevertOptions() - revertOptions.RevertAddress = sample.BtcAddressP2WPKH(t, &chaincfg.MainNetParams) + revertOptions.RevertAddress = sample.BtcAddressP2WPKH(t, &chaincfg.MainNetParams).String() // create test event receiver := sample.EthAddress() diff --git a/zetaclient/chains/bitcoin/observer/inbound_test.go b/zetaclient/chains/bitcoin/observer/inbound_test.go index 8f6f83e2aa..9e0d629f98 100644 --- a/zetaclient/chains/bitcoin/observer/inbound_test.go +++ b/zetaclient/chains/bitcoin/observer/inbound_test.go @@ -167,7 +167,7 @@ func Test_GetInboundVoteFromBtcEvent(t *testing.T) { { name: "should return vote for standard memo", event: &observer.BTCInboundEvent{ - FromAddress: sample.BtcAddressP2WPKH(t, &chaincfg.MainNetParams), + FromAddress: sample.BtcAddressP2WPKH(t, &chaincfg.MainNetParams).String(), // a deposit and call MemoBytes: testutil.HexToBytes( t, diff --git a/zetaclient/chains/bitcoin/signer/fee_bumper_test.go b/zetaclient/chains/bitcoin/signer/fee_bumper_test.go index ea509a44e4..3e455fd4f9 100644 --- a/zetaclient/chains/bitcoin/signer/fee_bumper_test.go +++ b/zetaclient/chains/bitcoin/signer/fee_bumper_test.go @@ -103,16 +103,7 @@ func Test_BumpTxFee(t *testing.T) { // https://mempool.space/tx/030cd813443f7b70cc6d8a544d320c6d8465e4528fc0f3410b599dc0b26753a0 chain := chains.BitcoinMainnet txid := "030cd813443f7b70cc6d8a544d320c6d8465e4528fc0f3410b599dc0b26753a0" - msgTx := testutils.LoadBTCMsgTx(t, TestDataDir, chain.ChainId, txid).Copy() - - // cleanMsgTx is a helper function to clean witness data - cleanMsgTx := func(tx *wire.MsgTx) *wire.MsgTx { - newTx := tx.Copy() - for idx := range newTx.TxIn { - newTx.TxIn[idx].Witness = wire.TxWitness{} - } - return newTx - } + msgTx := testutils.LoadBTCMsgTx(t, TestDataDir, chain.ChainId, txid) tests := []struct { name string @@ -135,7 +126,7 @@ func Test_BumpTxFee(t *testing.T) { additionalFees: 5790, expectedTx: func() *wire.MsgTx { // deduct additional fees - newTx := cleanMsgTx(msgTx) + newTx := copyMsgTx(msgTx) newTx.TxOut[2].Value -= 5790 return newTx }(), @@ -154,7 +145,7 @@ func Test_BumpTxFee(t *testing.T) { additionalFees: 1158, expectedTx: func() *wire.MsgTx { // deduct additional fees - newTx := cleanMsgTx(msgTx) + newTx := copyMsgTx(msgTx) newTx.TxOut[2].Value -= 1158 return newTx }(), @@ -178,7 +169,7 @@ func Test_BumpTxFee(t *testing.T) { additionalFees: 5790 + constant.BTCWithdrawalDustAmount - 1, // 6789 expectedTx: func() *wire.MsgTx { // give up all reserved bump fees - newTx := cleanMsgTx(msgTx) + newTx := copyMsgTx(msgTx) newTx.TxOut = newTx.TxOut[:2] return newTx }(), @@ -328,3 +319,12 @@ func makeMempoolTxsInfoFetcher( return totalTxs, totalFees, totalVSize, avgFeeRate, err } } + +// copyMsgTx is a helper function to copy a MsgTx and clean witness data +func copyMsgTx(tx *wire.MsgTx) *wire.MsgTx { + newTx := tx.Copy() + for idx := range newTx.TxIn { + newTx.TxIn[idx].Witness = wire.TxWitness{} + } + return newTx +} diff --git a/zetaclient/chains/bitcoin/signer/sign_withdraw.go b/zetaclient/chains/bitcoin/signer/sign.go similarity index 100% rename from zetaclient/chains/bitcoin/signer/sign_withdraw.go rename to zetaclient/chains/bitcoin/signer/sign.go diff --git a/zetaclient/chains/bitcoin/signer/sign_withdraw_rbf.go b/zetaclient/chains/bitcoin/signer/sign_rbf.go similarity index 82% rename from zetaclient/chains/bitcoin/signer/sign_withdraw_rbf.go rename to zetaclient/chains/bitcoin/signer/sign_rbf.go index d2c5717021..8794c3e973 100644 --- a/zetaclient/chains/bitcoin/signer/sign_withdraw_rbf.go +++ b/zetaclient/chains/bitcoin/signer/sign_rbf.go @@ -10,7 +10,6 @@ import ( "github.com/pkg/errors" "github.com/zeta-chain/node/x/crosschain/types" - "github.com/zeta-chain/node/zetaclient/chains/bitcoin/rpc" "github.com/zeta-chain/node/zetaclient/logs" ) @@ -18,7 +17,7 @@ const ( // rbfTxInSequenceNum is the sequence number used to signal an opt-in full-RBF (Replace-By-Fee) transaction // Setting sequenceNum to "1" effectively makes the transaction timelocks irrelevant. // See: https://github.com/bitcoin/bips/blob/master/bip-0125.mediawiki - // Also see: https://github.com/BlockchainCommons/Learning-Bitcoin-from-the-Command-Line/blob/master/05_2_Resending_a_Transaction_with_RBF.md + // See: https://github.com/BlockchainCommons/Learning-Bitcoin-from-the-Command-Line/blob/master/05_2_Resending_a_Transaction_with_RBF.md rbfTxInSequenceNum uint32 = 1 ) @@ -30,8 +29,10 @@ const ( func (signer *Signer) SignRBFTx( ctx context.Context, cctx *types.CrossChainTx, + height uint64, lastTx *btcutil.Tx, minRelayFee float64, + memplTxsInfoFetcher MempoolTxsInfoFetcher, ) (*wire.MsgTx, error) { var ( params = cctx.GetCurrentOutboundParam() @@ -44,15 +45,15 @@ func (signer *Signer) SignRBFTx( ) // parse recent fee rate from CCTX - cctxRate, err := strconv.ParseInt(params.GasPrice, 10, 64) + cctxRate, err := strconv.ParseInt(params.GasPriorityFee, 10, 64) if err != nil || cctxRate <= 0 { - return nil, fmt.Errorf("cannot convert fee rate %s", params.GasPrice) + return nil, fmt.Errorf("invalid fee rate %s", params.GasPrice) } // create fee bumper fb, err := NewCPFPFeeBumper( signer.client, - rpc.GetTotalMempoolParentsSizeNFees, + memplTxsInfoFetcher, lastTx, cctxRate, minRelayFee, @@ -69,7 +70,7 @@ func (signer *Signer) SignRBFTx( } logger.Info().Msgf("BumpTxFee success, additional fees: %d satoshis", additionalFees) - // collect input amounts for later signing + // collect input amounts for signing inAmounts := make([]int64, len(newTx.TxIn)) for i, input := range newTx.TxIn { preOut := input.PreviousOutPoint @@ -81,7 +82,7 @@ func (signer *Signer) SignRBFTx( } // sign the RBF tx - err = signer.SignTx(ctx, newTx, inAmounts, 0, params.TssNonce) + err = signer.SignTx(ctx, newTx, inAmounts, height, params.TssNonce) if err != nil { return nil, errors.Wrap(err, "SignTx failed") } diff --git a/zetaclient/chains/bitcoin/signer/sign_rbf_test.go b/zetaclient/chains/bitcoin/signer/sign_rbf_test.go new file mode 100644 index 0000000000..494df94e34 --- /dev/null +++ b/zetaclient/chains/bitcoin/signer/sign_rbf_test.go @@ -0,0 +1,235 @@ +package signer_test + +import ( + "context" + "testing" + + "github.com/btcsuite/btcd/btcjson" + "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/wire" + "github.com/pkg/errors" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + crosschaintypes "github.com/zeta-chain/node/x/crosschain/types" + "github.com/zeta-chain/node/zetaclient/chains/bitcoin/signer" + "github.com/zeta-chain/node/zetaclient/testutils" + + "github.com/zeta-chain/node/pkg/chains" +) + +func Test_SignRBFTx(t *testing.T) { + // https://mempool.space/tx/030cd813443f7b70cc6d8a544d320c6d8465e4528fc0f3410b599dc0b26753a0 + chain := chains.BitcoinMainnet + nonce := uint64(148) + cctx := testutils.LoadCctxByNonce(t, chain.ChainId, nonce) + txid := "030cd813443f7b70cc6d8a544d320c6d8465e4528fc0f3410b599dc0b26753a0" + msgTx := testutils.LoadBTCMsgTx(t, TestDataDir, chain.ChainId, txid) + + // inputs + type prevTx struct { + hash *chainhash.Hash + vout uint32 + amount int64 + } + preTxs := []prevTx{ + { + hash: hashFromTXID( + t, + "efca302a18bd8cebb3b8afef13e98ecaac47157755a62ab241ef3848140cfe92", + ), vout: 0, amount: 2147, + }, + { + hash: hashFromTXID( + t, + "efca302a18bd8cebb3b8afef13e98ecaac47157755a62ab241ef3848140cfe92", + ), vout: 2, amount: 28240703, + }, + { + hash: hashFromTXID( + t, + "3dc005eb0c1d393e717070ea84aa13e334a458a4fb7c7f9f98dbf8b231b5ceef", + ), vout: 0, amount: 10000, + }, + { + hash: hashFromTXID( + t, + "74c3aca825f3b21b82ee344d939c40d4c1e836a89c18abbd521bfa69f5f6e5d7", + ), vout: 0, amount: 10000, + }, + { + hash: hashFromTXID( + t, + "87264cef0e581f4aab3c99c53221bec3219686b48088d651a8cf8a98e4c2c5bf", + ), vout: 0, amount: 10000, + }, + { + hash: hashFromTXID( + t, + "5af24933973df03d96624ae1341d79a860e8dbc2ffc841420aa6710f3abc0074", + ), vout: 0, amount: 1200000, + }, + + { + hash: hashFromTXID( + t, + "b85755938ac026b2d13e5fbacf015288f453712b4eb4a02d7e4c98ee76ada530", + ), vout: 0, amount: 9610000, + }, + } + + // test cases + tests := []struct { + name string + chain chains.Chain + cctx *crosschaintypes.CrossChainTx + lastTx *btcutil.Tx + preTxs []prevTx + minRelayFee float64 + cctxRate string + liveRate float64 + memplTxsInfoFetcher signer.MempoolTxsInfoFetcher + errMsg string + expectedTx *wire.MsgTx + }{ + { + name: "should sign RBF tx successfully", + chain: chains.BitcoinMainnet, + cctx: cctx, + lastTx: btcutil.NewTx(msgTx.Copy()), + preTxs: preTxs, + minRelayFee: 0.00001, + cctxRate: "57", + liveRate: 0.00059, // 59 sat/vB + memplTxsInfoFetcher: makeMempoolTxsInfoFetcher( + 1, // 1 stuck tx + 0.00027213, // fees: 0.00027213 BTC + 579, // size: 579 vByte + 47, // rate: 47 sat/vB + "", // no error + ), + expectedTx: func() *wire.MsgTx { + // deduct additional fees + newTx := copyMsgTx(msgTx) + newTx.TxOut[2].Value -= 5790 + return newTx + }(), + }, + { + name: "should return error if latest fee rate is not available", + chain: chains.BitcoinMainnet, + cctx: cctx, + lastTx: btcutil.NewTx(msgTx.Copy()), + minRelayFee: 0.00001, + cctxRate: "", + errMsg: "invalid fee rate", + }, + { + name: "should return error if unable to create fee bumper", + chain: chains.BitcoinMainnet, + cctx: cctx, + lastTx: btcutil.NewTx(msgTx.Copy()), + minRelayFee: 0.00001, + cctxRate: "57", + memplTxsInfoFetcher: makeMempoolTxsInfoFetcher(0, 0, 0, 0, "error"), + errMsg: "NewCPFPFeeBumper failed", + }, + { + name: "should return error if live rate is too high", + chain: chains.BitcoinMainnet, + cctx: cctx, + lastTx: btcutil.NewTx(msgTx.Copy()), + minRelayFee: 0.00001, + cctxRate: "57", + liveRate: 0.00099, // 99 sat/vB is much higher than ccxt rate + memplTxsInfoFetcher: makeMempoolTxsInfoFetcher( + 1, // 1 stuck tx + 0.00027213, // fees: 0.00027213 BTC + 579, // size: 579 vByte + 47, // rate: 47 sat/vB + "", // no error + ), + errMsg: "BumpTxFee failed", + }, + { + name: "should return error if live rate is too high", + chain: chains.BitcoinMainnet, + cctx: cctx, + lastTx: btcutil.NewTx(msgTx.Copy()), + minRelayFee: 0.00001, + cctxRate: "57", + liveRate: 0.00059, // 59 sat/vB + memplTxsInfoFetcher: makeMempoolTxsInfoFetcher( + 1, // 1 stuck tx + 0.00027213, // fees: 0.00027213 BTC + 579, // size: 579 vByte + 47, // rate: 47 sat/vB + "", // no error + ), + errMsg: "unable to get previous tx", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // setup signer + s := newTestSuite(t, tt.chain) + + // mock cctx rate + tt.cctx.GetCurrentOutboundParam().GasPriorityFee = tt.cctxRate + + // mock RPC live fee rate + if tt.liveRate > 0 { + s.client.On("EstimateSmartFee", mock.Anything, mock.Anything). + Maybe(). + Return(&btcjson.EstimateSmartFeeResult{ + FeeRate: &tt.liveRate, + }, nil) + } else { + s.client.On("EstimateSmartFee", mock.Anything, mock.Anything).Maybe().Return(nil, errors.New("rpc error")) + } + + // mock RPC transactions + if tt.preTxs != nil { + // mock first two inputs they belong to same tx + mockMsg := wire.NewMsgTx(wire.TxVersion) + mockMsg.TxOut = make([]*wire.TxOut, 3) + for _, preTx := range tt.preTxs[:2] { + mockMsg.TxOut[preTx.vout] = wire.NewTxOut(preTx.amount, []byte{}) + } + s.client.On("GetRawTransaction", tt.preTxs[0].hash).Maybe().Return(btcutil.NewTx(mockMsg), nil) + + // mock other inputs + for _, preTx := range tt.preTxs[2:] { + mockMsg := wire.NewMsgTx(wire.TxVersion) + mockMsg.TxOut = make([]*wire.TxOut, 3) + mockMsg.TxOut[preTx.vout] = wire.NewTxOut(preTx.amount, []byte{}) + + s.client.On("GetRawTransaction", preTx.hash).Maybe().Return(btcutil.NewTx(mockMsg), nil) + } + } else { + s.client.On("GetRawTransaction", mock.Anything).Maybe().Return(nil, errors.New("rpc error")) + } + + // sign tx + ctx := context.Background() + newTx, err := s.SignRBFTx(ctx, tt.cctx, 1, tt.lastTx, tt.minRelayFee, tt.memplTxsInfoFetcher) + if tt.errMsg != "" { + require.ErrorContains(t, err, tt.errMsg) + return + } + require.NoError(t, err) + + // check tx signature + for i := range newTx.TxIn { + require.Len(t, newTx.TxIn[i].Witness, 2) + } + }) + } +} + +func hashFromTXID(t *testing.T, txid string) *chainhash.Hash { + h, err := chainhash.NewHashFromStr(txid) + require.NoError(t, err) + return h +} diff --git a/zetaclient/chains/bitcoin/signer/sign_withdraw_test.go b/zetaclient/chains/bitcoin/signer/sign_test.go similarity index 72% rename from zetaclient/chains/bitcoin/signer/sign_withdraw_test.go rename to zetaclient/chains/bitcoin/signer/sign_test.go index 981ab4adac..bf2138e8a9 100644 --- a/zetaclient/chains/bitcoin/signer/sign_withdraw_test.go +++ b/zetaclient/chains/bitcoin/signer/sign_test.go @@ -1,24 +1,29 @@ -package signer +package signer_test import ( + "context" "fmt" "reflect" "testing" + "github.com/btcsuite/btcd/btcjson" "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/chaincfg" "github.com/btcsuite/btcd/txscript" "github.com/btcsuite/btcd/wire" "github.com/stretchr/testify/require" + "github.com/zeta-chain/node/testutil/sample" "github.com/zeta-chain/node/zetaclient/testutils" "github.com/zeta-chain/node/pkg/chains" "github.com/zeta-chain/node/zetaclient/chains/base" + "github.com/zeta-chain/node/zetaclient/chains/bitcoin/signer" "github.com/zeta-chain/node/zetaclient/testutils/mocks" ) func TestAddWithdrawTxOutputs(t *testing.T) { // Create test signer and receiver address - signer := NewSigner( + signer := signer.NewSigner( chains.BitcoinMainnet, mocks.NewBTCRPCClient(t), mocks.NewTSS(t).FakePubKey(testutils.TSSPubKeyMainnet), @@ -181,3 +186,72 @@ func TestAddWithdrawTxOutputs(t *testing.T) { }) } } + +func Test_SignTx(t *testing.T) { + tests := []struct { + name string + chain chains.Chain + net *chaincfg.Params + inputs []float64 + outputs []int64 + height uint64 + nonce uint64 + }{ + { + name: "should sign tx successfully", + chain: chains.BitcoinMainnet, + net: &chaincfg.MainNetParams, + inputs: []float64{ + 0.0001, + 0.0002, + }, + outputs: []int64{ + 5000, + 20000, + }, + nonce: 100, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // setup signer + s := newTestSuite(t, tt.chain) + address, err := s.TSS().PubKey().AddressBTC(tt.chain.ChainId) + require.NoError(t, err) + + // create tx msg + tx := wire.NewMsgTx(wire.TxVersion) + + // add inputs + utxos := []btcjson.ListUnspentResult{} + for i, amount := range tt.inputs { + utxos = append(utxos, btcjson.ListUnspentResult{ + TxID: sample.BtcHash().String(), + Vout: uint32(i), + Address: address.EncodeAddress(), + Amount: amount, + }) + } + inAmounts, err := s.AddTxInputs(tx, utxos) + require.NoError(t, err) + require.Len(t, inAmounts, len(tt.inputs)) + + // add outputs + for _, amount := range tt.outputs { + pkScript := sample.BtcAddressP2WPKHScript(t, tt.net) + tx.AddTxOut(wire.NewTxOut(amount, pkScript)) + } + + // sign tx + ctx := context.Background() + err = s.SignTx(ctx, tx, inAmounts, tt.height, tt.nonce) + require.NoError(t, err) + + // check tx signature + for i := range tx.TxIn { + require.Len(t, tx.TxIn[i].Witness, 2) + } + }) + } +} diff --git a/zetaclient/chains/bitcoin/signer/signer.go b/zetaclient/chains/bitcoin/signer/signer.go index 4004e3493d..2e8ed7508c 100644 --- a/zetaclient/chains/bitcoin/signer/signer.go +++ b/zetaclient/chains/bitcoin/signer/signer.go @@ -15,6 +15,7 @@ import ( "github.com/zeta-chain/node/x/crosschain/types" "github.com/zeta-chain/node/zetaclient/chains/base" "github.com/zeta-chain/node/zetaclient/chains/bitcoin/observer" + "github.com/zeta-chain/node/zetaclient/chains/bitcoin/rpc" "github.com/zeta-chain/node/zetaclient/chains/interfaces" "github.com/zeta-chain/node/zetaclient/logs" "github.com/zeta-chain/node/zetaclient/outboundprocessor" @@ -166,7 +167,14 @@ func (signer *Signer) TryProcessOutbound( // sign outbound if stuckTx != nil && params.TssNonce == stuckTx.Nonce { - signedTx, err = signer.SignRBFTx(ctx, cctx, stuckTx.Tx, minRelayFee) + signedTx, err = signer.SignRBFTx( + ctx, + cctx, + height, + stuckTx.Tx, + minRelayFee, + rpc.GetTotalMempoolParentsSizeNFees, + ) if err != nil { logger.Error().Err(err).Msg("SignRBFTx failed") return diff --git a/zetaclient/chains/bitcoin/signer/signer_test.go b/zetaclient/chains/bitcoin/signer/signer_test.go index 61e5c1946e..2403daf2d5 100644 --- a/zetaclient/chains/bitcoin/signer/signer_test.go +++ b/zetaclient/chains/bitcoin/signer/signer_test.go @@ -112,7 +112,7 @@ func Test_BroadcastOutbound(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - // setup s and observer + // setup signer and observer s := newTestSuite(t, tt.chain) observer := s.getNewObserver(t) From 847e2f1a6f77776f8d084e7378c74e80889913cf Mon Sep 17 00:00:00 2001 From: Charlie Chen Date: Wed, 8 Jan 2025 09:43:10 -0600 Subject: [PATCH 09/20] use latest bitcoin-core-docker to be able to call mempool related RPCs --- contrib/localnet/docker-compose.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/contrib/localnet/docker-compose.yml b/contrib/localnet/docker-compose.yml index daa5a16440..3f8ae51cf3 100644 --- a/contrib/localnet/docker-compose.yml +++ b/contrib/localnet/docker-compose.yml @@ -199,7 +199,7 @@ services: ipv4_address: 172.20.0.102 bitcoin: - image: ghcr.io/zeta-chain/bitcoin-core-docker:a94b52f + image: ghcr.io/zeta-chain/bitcoin-core-docker:28.0-zeta6 container_name: bitcoin hostname: bitcoin networks: From c50768735454ce07c3cfc742a66ad32e1fecf831 Mon Sep 17 00:00:00 2001 From: Charlie Chen Date: Wed, 8 Jan 2025 15:26:31 -0600 Subject: [PATCH 10/20] always use latest gas rate for Bitcoin outbound; enforce gas rate cap --- pkg/math/integer.go | 23 ++++++++ pkg/math/integer_test.go | 30 ++++++++++ .../chains/bitcoin/signer/fee_bumper.go | 35 +++++++---- .../chains/bitcoin/signer/fee_bumper_test.go | 59 +++++++++++++------ .../chains/bitcoin/signer/outbound_data.go | 9 ++- .../bitcoin/signer/outbound_data_test.go | 28 +++++++++ zetaclient/chains/bitcoin/signer/sign.go | 17 ++++-- zetaclient/chains/bitcoin/signer/sign_rbf.go | 4 +- zetaclient/chains/bitcoin/signer/signer.go | 5 ++ 9 files changed, 174 insertions(+), 36 deletions(-) create mode 100644 pkg/math/integer.go create mode 100644 pkg/math/integer_test.go diff --git a/pkg/math/integer.go b/pkg/math/integer.go new file mode 100644 index 0000000000..46914c9b52 --- /dev/null +++ b/pkg/math/integer.go @@ -0,0 +1,23 @@ +package math + +import "math" + +// IncreaseIntByPercent is a function that increases integer by a percentage. +// Example1: IncreaseIntByPercent(10, 15, true) = 10 * 1.15 = 12 +// Example2: IncreaseIntByPercent(10, 15, false) = 10 + 10 * 0.15 = 11 +func IncreaseIntByPercent(value int64, percent uint64, round bool) int64 { + switch { + case percent == 0: + return value + case percent%100 == 0: + // optimization: a simple multiplication + increase := value * int64(percent/100) + return value + increase + default: + increase := float64(value) * float64(percent) / 100 + if round { + return value + int64(math.Round(increase)) + } + return value + int64(increase) + } +} diff --git a/pkg/math/integer_test.go b/pkg/math/integer_test.go new file mode 100644 index 0000000000..5346f86cf9 --- /dev/null +++ b/pkg/math/integer_test.go @@ -0,0 +1,30 @@ +package math + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/assert" +) + +func Test_IncreaseIntByPercent(t *testing.T) { + for i, tt := range []struct { + value int64 + percent uint64 + round bool + expected int64 + }{ + {value: 10, percent: 0, round: false, expected: 10}, + {value: 10, percent: 15, round: false, expected: 11}, + {value: 10, percent: 15, round: true, expected: 12}, + {value: 10, percent: 14, round: false, expected: 11}, + {value: 10, percent: 14, round: true, expected: 11}, + {value: 10, percent: 200, round: false, expected: 30}, + {value: 10, percent: 200, round: true, expected: 30}, + } { + t.Run(fmt.Sprintf("%d", i), func(t *testing.T) { + result := IncreaseIntByPercent(tt.value, tt.percent, tt.round) + assert.Equal(t, tt.expected, result) + }) + } +} diff --git a/zetaclient/chains/bitcoin/signer/fee_bumper.go b/zetaclient/chains/bitcoin/signer/fee_bumper.go index f5b203b521..9c4fc0f3bc 100644 --- a/zetaclient/chains/bitcoin/signer/fee_bumper.go +++ b/zetaclient/chains/bitcoin/signer/fee_bumper.go @@ -11,16 +11,22 @@ import ( "github.com/rs/zerolog" "github.com/zeta-chain/node/pkg/constant" + mathpkg "github.com/zeta-chain/node/pkg/math" "github.com/zeta-chain/node/zetaclient/chains/bitcoin" "github.com/zeta-chain/node/zetaclient/chains/bitcoin/rpc" "github.com/zeta-chain/node/zetaclient/chains/interfaces" ) const ( - // minCPFPFeeBumpFactor is the minimum factor by which the CPFP average fee rate should be bumped. + // gasRateCap is the maximum average gas rate for CPFP fee bumping + // 100 sat/vB is a typical heuristic based on Bitcoin mempool statistics + // see: https://mempool.space/graphs/mempool#3y + gasRateCap = 100 + + // minCPFPFeeBumpPercent is the minimum percentage by which the CPFP average fee rate should be bumped. // This value 20% is a heuristic, not mandated by the Bitcoin protocol, designed to balance effectiveness // in replacing stuck transactions while avoiding excessive sensitivity to fee market fluctuations. - minCPFPFeeBumpFactor = 1.2 + minCPFPFeeBumpPercent = 20 ) // MempoolTxsInfoFetcher is a function type to fetch mempool txs information @@ -80,7 +86,7 @@ func NewCPFPFeeBumper( } // BumpTxFee bumps the fee of the stuck transaction using reserved bump fees -func (b *CPFPFeeBumper) BumpTxFee() (*wire.MsgTx, int64, error) { +func (b *CPFPFeeBumper) BumpTxFee() (*wire.MsgTx, int64, int64, error) { // reuse old tx body and clear witness data (e.g., signatures) newTx := b.Tx.MsgTx().Copy() for idx := range newTx.TxIn { @@ -89,14 +95,14 @@ func (b *CPFPFeeBumper) BumpTxFee() (*wire.MsgTx, int64, error) { // check reserved bump fees amount in the original tx if len(newTx.TxOut) < 3 { - return nil, 0, errors.New("original tx has no reserved bump fees") + return nil, 0, 0, errors.New("original tx has no reserved bump fees") } // tx replacement is triggered only when market fee rate goes 20% higher than current paid fee rate. // zetacore updates the cctx fee rate evey 10 minutes, we could hold on and retry later. - minBumpRate := int64(math.Ceil(float64(b.AvgFeeRate) * minCPFPFeeBumpFactor)) + minBumpRate := mathpkg.IncreaseIntByPercent(b.AvgFeeRate, minCPFPFeeBumpPercent, true) if b.CCTXRate < minBumpRate { - return nil, 0, fmt.Errorf( + return nil, 0, 0, fmt.Errorf( "hold on RBF: cctx rate %d is lower than the min bumped rate %d", b.CCTXRate, minBumpRate, @@ -106,15 +112,21 @@ func (b *CPFPFeeBumper) BumpTxFee() (*wire.MsgTx, int64, error) { // the live rate may continue increasing during network congestion, we should wait until it stabilizes a bit. // this is to ensure the live rate is not 20%+ higher than the cctx rate, otherwise, the replacement tx may // also get stuck and need another replacement. - bumpedRate := int64(math.Ceil(float64(b.CCTXRate) * minCPFPFeeBumpFactor)) + bumpedRate := mathpkg.IncreaseIntByPercent(b.CCTXRate, minCPFPFeeBumpPercent, true) if b.LiveRate > bumpedRate { - return nil, 0, fmt.Errorf( + return nil, 0, 0, fmt.Errorf( "hold on RBF: live rate %d is much higher than the cctx rate %d", b.LiveRate, b.CCTXRate, ) } + // cap the gas rate to avoid excessive fees + gasRateNew := b.CCTXRate + if b.CCTXRate > gasRateCap { + gasRateNew = gasRateCap + } + // calculate minmimum relay fees of the new replacement tx // the new tx will have almost same size as the old one because the tx body stays the same txVSize := mempool.GetTxVirtualSize(b.Tx) @@ -127,7 +139,7 @@ func (b *CPFPFeeBumper) BumpTxFee() (*wire.MsgTx, int64, error) { // 2. additionalFees >= minRelayTxFees // // see: https://github.com/bitcoin/bitcoin/blob/master/src/policy/rbf.cpp#L166-L183 - additionalFees := b.TotalVSize*b.CCTXRate - b.TotalFees + additionalFees := b.TotalVSize*gasRateNew - b.TotalFees if additionalFees < minRelayTxFees { additionalFees = minRelayTxFees } @@ -142,7 +154,10 @@ func (b *CPFPFeeBumper) BumpTxFee() (*wire.MsgTx, int64, error) { newTx.TxOut = newTx.TxOut[:2] } - return newTx, additionalFees, nil + // effective gas rate + gasRateNew = int64(math.Ceil(float64(b.TotalFees+additionalFees) / float64(b.TotalVSize))) + + return newTx, additionalFees, gasRateNew, nil } // fetchFeeBumpInfo fetches all necessary information needed to bump the stuck tx diff --git a/zetaclient/chains/bitcoin/signer/fee_bumper_test.go b/zetaclient/chains/bitcoin/signer/fee_bumper_test.go index 3e455fd4f9..27676084c5 100644 --- a/zetaclient/chains/bitcoin/signer/fee_bumper_test.go +++ b/zetaclient/chains/bitcoin/signer/fee_bumper_test.go @@ -41,7 +41,7 @@ func Test_NewCPFPFeeBumper(t *testing.T) { 2, // 2 stuck TSS txs 0.0001, // total fees 0.0001 BTC 1000, // total vsize 1000 - 10, // average fee rate 10 sat/vbyte + 10, // average fee rate 10 sat/vB "", // no error ), expected: &signer.CPFPFeeBumper{ @@ -106,11 +106,12 @@ func Test_BumpTxFee(t *testing.T) { msgTx := testutils.LoadBTCMsgTx(t, TestDataDir, chain.ChainId, txid) tests := []struct { - name string - feeBumper *signer.CPFPFeeBumper - errMsg string - additionalFees int64 - expectedTx *wire.MsgTx + name string + feeBumper *signer.CPFPFeeBumper + additionalFees int64 + expectedNewRate int64 + expectedNewTx *wire.MsgTx + errMsg string }{ { name: "should bump tx fee successfully", @@ -123,8 +124,9 @@ func Test_BumpTxFee(t *testing.T) { TotalVSize: 579, AvgFeeRate: 47, }, - additionalFees: 5790, - expectedTx: func() *wire.MsgTx { + additionalFees: 5790, + expectedNewRate: 57, + expectedNewTx: func() *wire.MsgTx { // deduct additional fees newTx := copyMsgTx(msgTx) newTx.TxOut[2].Value -= 5790 @@ -137,13 +139,14 @@ func Test_BumpTxFee(t *testing.T) { Tx: btcutil.NewTx(msgTx), MinRelayFee: 0.00002, // min relay fee will be 579vB * 2 = 1158 sats CCTXRate: 6, - LiveRate: 8, + LiveRate: 7, TotalFees: 2895, TotalVSize: 579, AvgFeeRate: 5, }, - additionalFees: 1158, - expectedTx: func() *wire.MsgTx { + additionalFees: 1158, + expectedNewRate: 7, // (2895 + 1158) / 579 = 7 + expectedNewTx: func() *wire.MsgTx { // deduct additional fees newTx := copyMsgTx(msgTx) newTx.TxOut[2].Value -= 1158 @@ -166,14 +169,35 @@ func Test_BumpTxFee(t *testing.T) { TotalVSize: 579, AvgFeeRate: 47, }, - additionalFees: 5790 + constant.BTCWithdrawalDustAmount - 1, // 6789 - expectedTx: func() *wire.MsgTx { + additionalFees: 5790 + constant.BTCWithdrawalDustAmount - 1, // 6789 + expectedNewRate: 59, // (27213 + 6789) / 579 ≈ 59 + expectedNewTx: func() *wire.MsgTx { // give up all reserved bump fees newTx := copyMsgTx(msgTx) newTx.TxOut = newTx.TxOut[:2] return newTx }(), }, + { + name: "should cap new gas rate to 'gasRateCap'", + feeBumper: &signer.CPFPFeeBumper{ + Tx: btcutil.NewTx(msgTx), + MinRelayFee: 0.00001, + CCTXRate: 101, // > 100 + LiveRate: 120, + TotalFees: 27213, + TotalVSize: 579, + AvgFeeRate: 47, + }, + additionalFees: 30687, // (100-47)*579 + expectedNewRate: 100, + expectedNewTx: func() *wire.MsgTx { + // deduct additional fees + newTx := copyMsgTx(msgTx) + newTx.TxOut[2].Value -= 30687 + return newTx + }(), + }, { name: "should fail if original tx has no reserved bump fees", feeBumper: &signer.CPFPFeeBumper{ @@ -190,7 +214,7 @@ func Test_BumpTxFee(t *testing.T) { name: "should hold on RBF if CCTX rate is lower than minimum bumpeed rate", feeBumper: &signer.CPFPFeeBumper{ Tx: btcutil.NewTx(msgTx), - CCTXRate: 56, // 56 < 47 * 120% + CCTXRate: 55, // 56 < 47 * 120% AvgFeeRate: 47, }, errMsg: "lower than the min bumped rate", @@ -209,15 +233,16 @@ func Test_BumpTxFee(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - newTx, additionalFees, err := tt.feeBumper.BumpTxFee() + newTx, additionalFees, newRate, err := tt.feeBumper.BumpTxFee() if tt.errMsg != "" { require.Nil(t, newTx) require.Zero(t, additionalFees) require.ErrorContains(t, err, tt.errMsg) } else { require.NoError(t, err) - require.Equal(t, tt.expectedTx, newTx) + require.Equal(t, tt.expectedNewTx, newTx) require.Equal(t, tt.additionalFees, additionalFees) + require.Equal(t, tt.expectedNewRate, newRate) } }) } @@ -246,7 +271,7 @@ func Test_FetchFeeBumpInfo(t *testing.T) { 2, // 2 stuck TSS txs 0.0001, // total fees 0.0001 BTC 1000, // total vsize 1000 - 10, // average fee rate 10 sat/vbyte + 10, // average fee rate 10 sat/vB "", // no error ), expected: &signer.CPFPFeeBumper{ diff --git a/zetaclient/chains/bitcoin/signer/outbound_data.go b/zetaclient/chains/bitcoin/signer/outbound_data.go index f33da64604..5b2660275c 100644 --- a/zetaclient/chains/bitcoin/signer/outbound_data.go +++ b/zetaclient/chains/bitcoin/signer/outbound_data.go @@ -62,12 +62,19 @@ func NewOutboundData( return nil, errors.New("can only send gas token to a Bitcoin network") } - // fee rate + // initial fee rate feeRate, err := strconv.ParseInt(params.GasPrice, 10, 64) if err != nil || feeRate < 0 { return nil, fmt.Errorf("cannot convert gas price %s", params.GasPrice) } + // use current gas rate if fed by zetacore + newRate, err := strconv.ParseInt(params.GasPriorityFee, 10, 64) + if err == nil && newRate > 0 && newRate != feeRate { + logger.Info().Msgf("use new gas rate %d sat/vB instead of %d sat/vB", newRate, feeRate) + feeRate = newRate + } + // check receiver address to, err := chains.DecodeBtcAddress(params.Receiver, params.ReceiverChainId) if err != nil { diff --git a/zetaclient/chains/bitcoin/signer/outbound_data_test.go b/zetaclient/chains/bitcoin/signer/outbound_data_test.go index 6c7aea3100..4a619d1302 100644 --- a/zetaclient/chains/bitcoin/signer/outbound_data_test.go +++ b/zetaclient/chains/bitcoin/signer/outbound_data_test.go @@ -64,6 +64,34 @@ func Test_NewOutboundData(t *testing.T) { }, errMsg: "", }, + { + name: "create new outbound data using current gas rate instead of old rate", + cctx: sample.CrossChainTx(t, "0x123"), + cctxModifier: func(cctx *crosschaintypes.CrossChainTx) { + cctx.InboundParams.CoinType = coin.CoinType_Gas + cctx.GetCurrentOutboundParam().Receiver = receiver.String() + cctx.GetCurrentOutboundParam().ReceiverChainId = chain.ChainId + cctx.GetCurrentOutboundParam().Amount = sdk.NewUint(1e7) // 0.1 BTC + cctx.GetCurrentOutboundParam().CallOptions.GasLimit = 254 // 254 bytes + cctx.GetCurrentOutboundParam().GasPrice = "10" // 10 sats/vByte + cctx.GetCurrentOutboundParam().GasPriorityFee = "15" // 15 sats/vByte + cctx.GetCurrentOutboundParam().TssNonce = 1 + }, + chainID: chain.ChainId, + height: 101, + minRelayFee: 0.00001, // 1000 sat/KB + expected: &OutboundData{ + chainID: chain.ChainId, + to: receiver, + amount: 0.1, + feeRate: 16, // 15 + 1 (minRelayFee) + txSize: 254, + nonce: 1, + height: 101, + cancelTx: false, + }, + errMsg: "", + }, { name: "cctx is nil", cctx: nil, diff --git a/zetaclient/chains/bitcoin/signer/sign.go b/zetaclient/chains/bitcoin/signer/sign.go index 7b2a6e9c85..13f3271fdf 100644 --- a/zetaclient/chains/bitcoin/signer/sign.go +++ b/zetaclient/chains/bitcoin/signer/sign.go @@ -25,6 +25,11 @@ const ( // the rank below (or equal to) which we consolidate UTXOs consolidationRank = 10 + + // reservedRBFFees is the amount of BTC reserved for RBF fee bumping. + // the TSS keysign stops automatically when transactions get stuck in the mempool + // 0.01 BTC can bump 10 transactions (1KB each) by 100 sat/vB + reservedRBFFees = 0.01 ) // SignWithdrawTx signs a BTC withdrawal tx and returns the signed tx @@ -35,15 +40,15 @@ func (signer *Signer) SignWithdrawTx( ) (*wire.MsgTx, error) { nonceMark := chains.NonceMarkAmount(txData.nonce) estimateFee := float64(txData.feeRate*bitcoin.OutboundBytesMax) / 1e8 + totalAmount := txData.amount + estimateFee + reservedRBFFees + float64(nonceMark)*1e-8 // refreshing UTXO list before TSS keysign is important: // 1. all TSS outbounds have opted-in for RBF to be replaceable - // 2. using old UTXOs may lead to accidental double-spending - // 3. double-spending may trigger unexpected tx replacement (RBF) + // 2. using old UTXOs may lead to accidental double-spending, which may trigger unwanted RBF // - // Note: unwanted RBF will rarely happen for two reasons: + // Note: unwanted RBF is very unlikely to happen for two reasons: // 1. it requires 2/3 TSS signers to accidentally sign the same tx using same outdated UTXOs. - // 2. RBF requires a higher fee rate than the original tx. + // 2. RBF requires a higher fee rate than the original tx, otherwise it will fail. err := ob.FetchUTXOs(ctx) if err != nil { return nil, errors.Wrap(err, "FetchUTXOs failed") @@ -52,7 +57,7 @@ func (signer *Signer) SignWithdrawTx( // select N UTXOs to cover the total expense prevOuts, total, consolidatedUtxo, consolidatedValue, err := ob.SelectUTXOs( ctx, - txData.amount+estimateFee+float64(nonceMark)*1e-8, + totalAmount, MaxNoOfInputsPerTx, txData.nonce, consolidationRank, @@ -79,7 +84,7 @@ func (signer *Signer) SignWithdrawTx( signer.Logger().Std.Info(). Msgf("txSize %d is less than BtcOutboundBytesWithdrawer %d for nonce %d", txData.txSize, txSize, txData.nonce) } - if txSize < bitcoin.OutboundBytesMin { // outbound shouldn't be blocked a low sizeLimit + if txSize < bitcoin.OutboundBytesMin { // outbound shouldn't be blocked by low sizeLimit signer.Logger().Std.Warn(). Msgf("txSize %d is less than outboundBytesMin %d; use outboundBytesMin", txSize, bitcoin.OutboundBytesMin) txSize = bitcoin.OutboundBytesMin diff --git a/zetaclient/chains/bitcoin/signer/sign_rbf.go b/zetaclient/chains/bitcoin/signer/sign_rbf.go index 8794c3e973..27fd956093 100644 --- a/zetaclient/chains/bitcoin/signer/sign_rbf.go +++ b/zetaclient/chains/bitcoin/signer/sign_rbf.go @@ -64,11 +64,11 @@ func (signer *Signer) SignRBFTx( } // bump tx fees - newTx, additionalFees, err := fb.BumpTxFee() + newTx, additionalFees, newRate, err := fb.BumpTxFee() if err != nil { return nil, errors.Wrap(err, "BumpTxFee failed") } - logger.Info().Msgf("BumpTxFee success, additional fees: %d satoshis", additionalFees) + logger.Info().Msgf("BumpTxFee succeed, additional fees: %d satoshis, new rate: %d sat/vB", additionalFees, newRate) // collect input amounts for signing inAmounts := make([]int64, len(newTx.TxIn)) diff --git a/zetaclient/chains/bitcoin/signer/signer.go b/zetaclient/chains/bitcoin/signer/signer.go index 2e8ed7508c..fa63ebde37 100644 --- a/zetaclient/chains/bitcoin/signer/signer.go +++ b/zetaclient/chains/bitcoin/signer/signer.go @@ -159,6 +159,10 @@ func (signer *Signer) TryProcessOutbound( return } minRelayFee := networkInfo.RelayFee + if minRelayFee <= 0 { + logger.Error().Msgf("invalid minimum relay fee: %f", minRelayFee) + return + } var ( signedTx *wire.MsgTx @@ -167,6 +171,7 @@ func (signer *Signer) TryProcessOutbound( // sign outbound if stuckTx != nil && params.TssNonce == stuckTx.Nonce { + // sign RBF tx signedTx, err = signer.SignRBFTx( ctx, cctx, From cc6c2583277abe6355554d1701130d4127ccc793 Mon Sep 17 00:00:00 2001 From: Charlie Chen Date: Wed, 8 Jan 2025 16:33:11 -0600 Subject: [PATCH 11/20] fix gosec and unit tests --- pkg/math/integer.go | 2 +- pkg/math/integer_test.go | 2 +- zetaclient/chains/bitcoin/fee.go | 3 +- zetaclient/chains/bitcoin/fee_test.go | 50 +++++++++---------- zetaclient/chains/bitcoin/observer/mempool.go | 1 + .../chains/bitcoin/observer/observer_test.go | 8 ++- zetaclient/chains/bitcoin/observer/utxos.go | 12 +---- .../chains/bitcoin/signer/outbound_data.go | 9 ++-- .../chains/bitcoin/signer/signer_test.go | 7 ++- 9 files changed, 48 insertions(+), 46 deletions(-) diff --git a/pkg/math/integer.go b/pkg/math/integer.go index 46914c9b52..18370aed0c 100644 --- a/pkg/math/integer.go +++ b/pkg/math/integer.go @@ -5,7 +5,7 @@ import "math" // IncreaseIntByPercent is a function that increases integer by a percentage. // Example1: IncreaseIntByPercent(10, 15, true) = 10 * 1.15 = 12 // Example2: IncreaseIntByPercent(10, 15, false) = 10 + 10 * 0.15 = 11 -func IncreaseIntByPercent(value int64, percent uint64, round bool) int64 { +func IncreaseIntByPercent(value int64, percent uint32, round bool) int64 { switch { case percent == 0: return value diff --git a/pkg/math/integer_test.go b/pkg/math/integer_test.go index 5346f86cf9..9bcc63e8d5 100644 --- a/pkg/math/integer_test.go +++ b/pkg/math/integer_test.go @@ -10,7 +10,7 @@ import ( func Test_IncreaseIntByPercent(t *testing.T) { for i, tt := range []struct { value int64 - percent uint64 + percent uint32 round bool expected int64 }{ diff --git a/zetaclient/chains/bitcoin/fee.go b/zetaclient/chains/bitcoin/fee.go index 47f93280cf..39605f91d6 100644 --- a/zetaclient/chains/bitcoin/fee.go +++ b/zetaclient/chains/bitcoin/fee.go @@ -67,11 +67,12 @@ func WiredTxSize(numInputs uint64, numOutputs uint64) int64 { // EstimateOutboundSize estimates the size of an outbound in vBytes func EstimateOutboundSize(numInputs int64, payees []btcutil.Address) (int64, error) { - if numInputs == 0 { + if numInputs <= 0 { return 0, nil } // #nosec G115 always positive numOutputs := 2 + uint64(len(payees)) + // #nosec G115 checked positive bytesWiredTx := WiredTxSize(uint64(numInputs), numOutputs) bytesInput := numInputs * bytesPerInput bytesOutput := int64(2) * bytesPerOutputP2WPKH // new nonce mark, change diff --git a/zetaclient/chains/bitcoin/fee_test.go b/zetaclient/chains/bitcoin/fee_test.go index e12c427733..2efe3b45c2 100644 --- a/zetaclient/chains/bitcoin/fee_test.go +++ b/zetaclient/chains/bitcoin/fee_test.go @@ -184,6 +184,11 @@ func TestOutboundSize2In3Out(t *testing.T) { privateKey, _, payerScript := generateKeyPair(t, &chaincfg.TestNet3Params) _, payee, payeeScript := generateKeyPair(t, &chaincfg.TestNet3Params) + // return 0 vByte if no UTXO + vBytesEstimated, err := EstimateOutboundSize(0, []btcutil.Address{payee}) + require.NoError(t, err) + require.Zero(t, vBytesEstimated) + // 2 example UTXO txids to use in the test. utxosTxids := exampleTxids[:2] @@ -194,10 +199,9 @@ func TestOutboundSize2In3Out(t *testing.T) { addTxInputsOutputsAndSignTx(t, tx, privateKey, payerScript, utxosTxids, [][]byte{payeeScript}) // Estimate the tx size in vByte - // #nosec G115 always positive vError := int64(1) // 1 vByte error tolerance vBytes := blockchain.GetTransactionWeight(btcutil.NewTx(tx)) / blockchain.WitnessScaleFactor - vBytesEstimated, err := EstimateOutboundSize(int64(len(utxosTxids)), []btcutil.Address{payee}) + vBytesEstimated, err = EstimateOutboundSize(int64(len(utxosTxids)), []btcutil.Address{payee}) require.NoError(t, err) if vBytes > vBytesEstimated { require.True(t, vBytes-vBytesEstimated <= vError) @@ -264,62 +268,62 @@ func TestGetOutputSizeByAddress(t *testing.T) { nilP2TR := (*btcutil.AddressTaproot)(nil) sizeNilP2TR, err := GetOutputSizeByAddress(nilP2TR) require.NoError(t, err) - require.Equal(t, uint64(0), sizeNilP2TR) + require.Zero(t, sizeNilP2TR) addrP2TR := getTestAddrScript(t, ScriptTypeP2TR) sizeP2TR, err := GetOutputSizeByAddress(addrP2TR) require.NoError(t, err) - require.Equal(t, uint64(bytesPerOutputP2TR), sizeP2TR) + require.Equal(t, int64(bytesPerOutputP2TR), sizeP2TR) // test nil P2WSH address and non-nil P2WSH address nilP2WSH := (*btcutil.AddressWitnessScriptHash)(nil) sizeNilP2WSH, err := GetOutputSizeByAddress(nilP2WSH) require.NoError(t, err) - require.Equal(t, uint64(0), sizeNilP2WSH) + require.Zero(t, sizeNilP2WSH) addrP2WSH := getTestAddrScript(t, ScriptTypeP2WSH) sizeP2WSH, err := GetOutputSizeByAddress(addrP2WSH) require.NoError(t, err) - require.Equal(t, uint64(bytesPerOutputP2WSH), sizeP2WSH) + require.Equal(t, int64(bytesPerOutputP2WSH), sizeP2WSH) // test nil P2WPKH address and non-nil P2WPKH address nilP2WPKH := (*btcutil.AddressWitnessPubKeyHash)(nil) sizeNilP2WPKH, err := GetOutputSizeByAddress(nilP2WPKH) require.NoError(t, err) - require.Equal(t, uint64(0), sizeNilP2WPKH) + require.Zero(t, sizeNilP2WPKH) addrP2WPKH := getTestAddrScript(t, ScriptTypeP2WPKH) sizeP2WPKH, err := GetOutputSizeByAddress(addrP2WPKH) require.NoError(t, err) - require.Equal(t, uint64(bytesPerOutputP2WPKH), sizeP2WPKH) + require.Equal(t, int64(bytesPerOutputP2WPKH), sizeP2WPKH) // test nil P2SH address and non-nil P2SH address nilP2SH := (*btcutil.AddressScriptHash)(nil) sizeNilP2SH, err := GetOutputSizeByAddress(nilP2SH) require.NoError(t, err) - require.Equal(t, uint64(0), sizeNilP2SH) + require.Zero(t, sizeNilP2SH) addrP2SH := getTestAddrScript(t, ScriptTypeP2SH) sizeP2SH, err := GetOutputSizeByAddress(addrP2SH) require.NoError(t, err) - require.Equal(t, uint64(bytesPerOutputP2SH), sizeP2SH) + require.Equal(t, int64(bytesPerOutputP2SH), sizeP2SH) // test nil P2PKH address and non-nil P2PKH address nilP2PKH := (*btcutil.AddressPubKeyHash)(nil) sizeNilP2PKH, err := GetOutputSizeByAddress(nilP2PKH) require.NoError(t, err) - require.Equal(t, uint64(0), sizeNilP2PKH) + require.Zero(t, sizeNilP2PKH) addrP2PKH := getTestAddrScript(t, ScriptTypeP2PKH) sizeP2PKH, err := GetOutputSizeByAddress(addrP2PKH) require.NoError(t, err) - require.Equal(t, uint64(bytesPerOutputP2PKH), sizeP2PKH) + require.Equal(t, int64(bytesPerOutputP2PKH), sizeP2PKH) // test unsupported address type nilP2PK := (*btcutil.AddressPubKey)(nil) sizeP2PK, err := GetOutputSizeByAddress(nilP2PK) require.ErrorContains(t, err, "cannot get output size for address type") - require.Equal(t, uint64(0), sizeP2PK) + require.Zero(t, sizeP2PK) } func TestOutputSizeP2TR(t *testing.T) { @@ -335,8 +339,7 @@ func TestOutputSizeP2TR(t *testing.T) { addTxInputsOutputsAndSignTx(t, tx, privateKey, payerScript, exampleTxids[:2], payeeScripts) // Estimate the tx size in vByte - // #nosec G115 always positive - vBytes := uint64(blockchain.GetTransactionWeight(btcutil.NewTx(tx)) / blockchain.WitnessScaleFactor) + vBytes := blockchain.GetTransactionWeight(btcutil.NewTx(tx)) / blockchain.WitnessScaleFactor vBytesEstimated, err := EstimateOutboundSize(2, payees) require.NoError(t, err) require.Equal(t, vBytes, vBytesEstimated) @@ -355,8 +358,7 @@ func TestOutputSizeP2WSH(t *testing.T) { addTxInputsOutputsAndSignTx(t, tx, privateKey, payerScript, exampleTxids[:2], payeeScripts) // Estimate the tx size in vByte - // #nosec G115 always positive - vBytes := uint64(blockchain.GetTransactionWeight(btcutil.NewTx(tx)) / blockchain.WitnessScaleFactor) + vBytes := blockchain.GetTransactionWeight(btcutil.NewTx(tx)) / blockchain.WitnessScaleFactor vBytesEstimated, err := EstimateOutboundSize(2, payees) require.NoError(t, err) require.Equal(t, vBytes, vBytesEstimated) @@ -375,8 +377,7 @@ func TestOutputSizeP2SH(t *testing.T) { addTxInputsOutputsAndSignTx(t, tx, privateKey, payerScript, exampleTxids[:2], payeeScripts) // Estimate the tx size in vByte - // #nosec G115 always positive - vBytes := uint64(blockchain.GetTransactionWeight(btcutil.NewTx(tx)) / blockchain.WitnessScaleFactor) + vBytes := blockchain.GetTransactionWeight(btcutil.NewTx(tx)) / blockchain.WitnessScaleFactor vBytesEstimated, err := EstimateOutboundSize(2, payees) require.NoError(t, err) require.Equal(t, vBytes, vBytesEstimated) @@ -395,8 +396,7 @@ func TestOutputSizeP2PKH(t *testing.T) { addTxInputsOutputsAndSignTx(t, tx, privateKey, payerScript, exampleTxids[:2], payeeScripts) // Estimate the tx size in vByte - // #nosec G115 always positive - vBytes := uint64(blockchain.GetTransactionWeight(btcutil.NewTx(tx)) / blockchain.WitnessScaleFactor) + vBytes := blockchain.GetTransactionWeight(btcutil.NewTx(tx)) / blockchain.WitnessScaleFactor vBytesEstimated, err := EstimateOutboundSize(2, payees) require.NoError(t, err) require.Equal(t, vBytes, vBytesEstimated) @@ -422,15 +422,15 @@ func TestOutboundSizeBreakdown(t *testing.T) { // calculate the average outbound size (245 vByte) // #nosec G115 always in range - txSizeAverage := uint64((float64(txSizeTotal))/float64(len(payees)) + 0.5) + txSizeAverage := int64((float64(txSizeTotal))/float64(len(payees)) + 0.5) // get deposit fee txSizeDepositor := OutboundSizeDepositor() - require.Equal(t, uint64(68), txSizeDepositor) + require.Equal(t, int64(68), txSizeDepositor) // get withdrawer fee txSizeWithdrawer := OutboundSizeWithdrawer() - require.Equal(t, uint64(177), txSizeWithdrawer) + require.Equal(t, int64(177), txSizeWithdrawer) // total outbound size == (deposit fee + withdrawer fee), 245 = 68 + 177 require.Equal(t, txSizeAverage, txSizeDepositor+txSizeWithdrawer) @@ -459,5 +459,5 @@ func TestOutboundSizeMinMaxError(t *testing.T) { nilP2PK := (*btcutil.AddressPubKey)(nil) size, err := EstimateOutboundSize(1, []btcutil.Address{nilP2PK}) require.Error(t, err) - require.Equal(t, uint64(0), size) + require.Zero(t, size) } diff --git a/zetaclient/chains/bitcoin/observer/mempool.go b/zetaclient/chains/bitcoin/observer/mempool.go index a6287ef805..34e57a0932 100644 --- a/zetaclient/chains/bitcoin/observer/mempool.go +++ b/zetaclient/chains/bitcoin/observer/mempool.go @@ -144,6 +144,7 @@ func GetLastOutbound(ctx context.Context, ob *Observer) (*btcutil.Tx, uint64, er if err != nil { return nil, 0, errors.Wrap(err, "GetPendingNoncesByChain failed") } + // #nosec G115 always in range for nonce := uint64(p.NonceLow); nonce < uint64(p.NonceHigh); nonce++ { if nonce > lastNonce { txID, found := ob.GetBroadcastedTx(nonce) diff --git a/zetaclient/chains/bitcoin/observer/observer_test.go b/zetaclient/chains/bitcoin/observer/observer_test.go index 001662c421..d7e3326e3d 100644 --- a/zetaclient/chains/bitcoin/observer/observer_test.go +++ b/zetaclient/chains/bitcoin/observer/observer_test.go @@ -10,6 +10,7 @@ import ( "github.com/btcsuite/btcd/btcjson" "github.com/btcsuite/btcd/btcutil" "github.com/btcsuite/btcd/wire" + "github.com/rs/zerolog" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" "github.com/zeta-chain/node/zetaclient/db" @@ -325,8 +326,11 @@ func newTestSuite(t *testing.T, chain chains.Chain, dbPath string) *testSuite { } require.NoError(t, err) + // create logger + testLogger := zerolog.New(zerolog.NewTestWriter(t)) + logger := base.Logger{Std: testLogger, Compliance: testLogger} + // create observer - //log := zerolog.New(zerolog.NewTestWriter(t)) ob, err := observer.NewObserver( chain, client, @@ -334,7 +338,7 @@ func newTestSuite(t *testing.T, chain chains.Chain, dbPath string) *testSuite { zetacore, tss, database, - base.DefaultLogger(), + logger, &metrics.TelemetryServer{}, ) require.NoError(t, err) diff --git a/zetaclient/chains/bitcoin/observer/utxos.go b/zetaclient/chains/bitcoin/observer/utxos.go index 5b0275101f..af0e2a16f8 100644 --- a/zetaclient/chains/bitcoin/observer/utxos.go +++ b/zetaclient/chains/bitcoin/observer/utxos.go @@ -60,22 +60,12 @@ func (ob *Observer) FetchUTXOs(ctx context.Context) error { // this is useful when a zetaclient's pending nonce lagged behind for whatever reason. ob.refreshPendingNonce(ctx) - // refresh the last block height. - lastBlock, err := ob.btcClient.GetBlockCount() - if err != nil { - return fmt.Errorf("btc: error getting block height : %v", err) - } - if ob.LastBlock() < uint64(lastBlock) { - ob.WithLastBlock(uint64(lastBlock)) - } - // list all unspent UTXOs (160ms) - maxConfirmations := int(lastBlock) tssAddr, err := ob.TSS().PubKey().AddressBTC(ob.Chain().ChainId) if err != nil { return fmt.Errorf("error getting bitcoin tss address") } - utxos, err := ob.btcClient.ListUnspentMinMaxAddresses(0, maxConfirmations, []btcutil.Address{tssAddr}) + utxos, err := ob.btcClient.ListUnspentMinMaxAddresses(0, 9999999, []btcutil.Address{tssAddr}) if err != nil { return err } diff --git a/zetaclient/chains/bitcoin/signer/outbound_data.go b/zetaclient/chains/bitcoin/signer/outbound_data.go index 5b2660275c..115b13b28a 100644 --- a/zetaclient/chains/bitcoin/signer/outbound_data.go +++ b/zetaclient/chains/bitcoin/signer/outbound_data.go @@ -110,10 +110,11 @@ func NewOutboundData( } return &OutboundData{ - chainID: chainID, - to: to, - amount: amount, - feeRate: feeRate, + chainID: chainID, + to: to, + amount: amount, + feeRate: feeRate, + // #nosec G115 always in range txSize: int64(params.CallOptions.GasLimit), nonce: params.TssNonce, height: height, diff --git a/zetaclient/chains/bitcoin/signer/signer_test.go b/zetaclient/chains/bitcoin/signer/signer_test.go index 2403daf2d5..2f32d0a05b 100644 --- a/zetaclient/chains/bitcoin/signer/signer_test.go +++ b/zetaclient/chains/bitcoin/signer/signer_test.go @@ -62,11 +62,16 @@ func newTestSuite(t *testing.T, chain chains.Chain) *testSuite { WithKeys(&keys.Keys{}). WithZetaChain() + // create logger + testLogger := zerolog.New(zerolog.NewTestWriter(t)) + logger := base.Logger{Std: testLogger, Compliance: testLogger} + + // create signer signer := signer.NewSigner( chain, rpcClient, tss, - base.DefaultLogger(), + logger, ) return &testSuite{ From 6fd4335138789c89ed453e07fb2739c38c729ef2 Mon Sep 17 00:00:00 2001 From: Charlie Chen Date: Wed, 8 Jan 2025 16:37:53 -0600 Subject: [PATCH 12/20] add changelog entry --- changelog.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/changelog.md b/changelog.md index 4442a0cbba..0cfa806354 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,17 @@ # CHANGELOG +## Unreleased + +### Features + +* [3306](https://github.com/zeta-chain/node/pull/3306) - add support for Bitcoin RBF (Replace-By-Fee) + +### Tests + +## Refactor + +### Fixes + ## v25.0.0 ### Features From dfb9f65cd1e3bee010662aae1fc965c65292d651 Mon Sep 17 00:00:00 2001 From: Charlie Chen Date: Thu, 9 Jan 2025 20:59:45 -0600 Subject: [PATCH 13/20] add Bitcoin RBF transaction E2E test --- cmd/zetae2e/local/local.go | 1 + e2e/e2etests/e2etests.go | 10 ++ e2e/e2etests/helpers.go | 45 ++++--- ..._bitcoin_deposit_and_withdraw_with_dust.go | 16 ++- e2e/e2etests/test_bitcoin_withdraw_rbf.go | 77 ++++++++++++ e2e/utils/bitcoin.go | 48 +++++++ e2e/utils/zetacore.go | 69 ++++++++++ .../chains/bitcoin/observer/gas_price.go | 6 +- zetaclient/chains/bitcoin/observer/mempool.go | 65 +++++++--- .../chains/bitcoin/observer/mempool_test.go | 119 ++++++++++++++++-- .../chains/bitcoin/observer/outbound.go | 21 ++-- zetaclient/chains/bitcoin/rpc/rpc.go | 68 +++++++--- zetaclient/chains/bitcoin/rpc/rpc_test.go | 92 ++++++++++++++ .../chains/bitcoin/signer/fee_bumper.go | 31 ++++- .../chains/bitcoin/signer/fee_bumper_test.go | 71 ++++++----- zetaclient/chains/bitcoin/signer/sign_rbf.go | 21 +++- .../chains/bitcoin/signer/sign_rbf_test.go | 2 +- zetaclient/chains/bitcoin/signer/signer.go | 14 +-- zetaclient/common/constant.go | 2 +- 19 files changed, 651 insertions(+), 127 deletions(-) create mode 100644 e2e/e2etests/test_bitcoin_withdraw_rbf.go create mode 100644 e2e/utils/bitcoin.go create mode 100644 zetaclient/chains/bitcoin/rpc/rpc_test.go diff --git a/cmd/zetae2e/local/local.go b/cmd/zetae2e/local/local.go index e32bfc0d77..dd2f14a40b 100644 --- a/cmd/zetae2e/local/local.go +++ b/cmd/zetae2e/local/local.go @@ -317,6 +317,7 @@ func localE2ETest(cmd *cobra.Command, _ []string) { e2etests.TestBitcoinWithdrawP2WSHName, e2etests.TestBitcoinWithdrawMultipleName, e2etests.TestBitcoinWithdrawRestrictedName, + //e2etests.TestBitcoinWithdrawRBFName, // leave it as the last BTC test } if !light { diff --git a/e2e/e2etests/e2etests.go b/e2e/e2etests/e2etests.go index 884573d2fa..0163efd402 100644 --- a/e2e/e2etests/e2etests.go +++ b/e2e/e2etests/e2etests.go @@ -90,6 +90,7 @@ const ( TestBitcoinWithdrawP2SHName = "bitcoin_withdraw_p2sh" TestBitcoinWithdrawInvalidAddressName = "bitcoin_withdraw_invalid" TestBitcoinWithdrawRestrictedName = "bitcoin_withdraw_restricted" + TestBitcoinWithdrawRBFName = "bitcoin_withdraw_rbf" /* Application tests @@ -717,6 +718,15 @@ var AllE2ETests = []runner.E2ETest{ }, TestBitcoinWithdrawRestricted, ), + 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, + ), /* Application tests */ diff --git a/e2e/e2etests/helpers.go b/e2e/e2etests/helpers.go index a06149139e..b0dbe80972 100644 --- a/e2e/e2etests/helpers.go +++ b/e2e/e2etests/helpers.go @@ -8,6 +8,7 @@ import ( "github.com/btcsuite/btcd/btcjson" "github.com/btcsuite/btcd/btcutil" "github.com/btcsuite/btcd/chaincfg/chainhash" + ethtypes "github.com/ethereum/go-ethereum/core/types" "github.com/stretchr/testify/require" "github.com/zeta-chain/node/e2e/runner" @@ -25,31 +26,13 @@ func randomPayload(r *runner.E2ERunner) string { } func withdrawBTCZRC20(r *runner.E2ERunner, to btcutil.Address, amount *big.Int) *btcjson.TxRawResult { - 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) + // call approve and withdraw on ZRC20 contract + receipt := approveAndWithdrawBTCZRC20(r, to, amount) // 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) - - // mine 10 blocks to confirm the withdrawal tx - _, err = r.GenerateToAddressIfLocalBitcoin(10, to) - require.NoError(r, err) - // 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) @@ -79,6 +62,28 @@ func withdrawBTCZRC20(r *runner.E2ERunner, to btcutil.Address, amount *big.Int) return rawTx } +// approveAndWithdrawBTCZRC20 is a helper function to call 'approve' and 'withdraw' on BTCZRC20 contract +func approveAndWithdrawBTCZRC20(r *runner.E2ERunner, to btcutil.Address, amount *big.Int) *ethtypes.Receipt { + 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) + + // 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 +} + // bigAdd is shorthand for new(big.Int).Add(x, y) func bigAdd(x *big.Int, y *big.Int) *big.Int { return new(big.Int).Add(x, y) 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 8b108f2103..439bfbddc4 100644 --- a/e2e/e2etests/test_bitcoin_deposit_and_withdraw_with_dust.go +++ b/e2e/e2etests/test_bitcoin_deposit_and_withdraw_with_dust.go @@ -2,6 +2,7 @@ package e2etests import ( "math/big" + "sync" "github.com/ethereum/go-ethereum/accounts/abi/bind" "github.com/stretchr/testify/require" @@ -12,17 +13,28 @@ import ( crosschaintypes "github.com/zeta-chain/node/x/crosschain/types" ) +// wgDeposit is a wait group for deposit runner to finish +var wgDepositRunner sync.WaitGroup + +func init() { + // there is one single deposit runner for Bitcoin E2E tests + wgDepositRunner.Add(1) +} + // TestBitcoinDepositAndWithdrawWithDust deposits Bitcoin and call a smart contract that withdraw dust amount // It tests the edge case where during a cross-chain call, a invaild withdraw is initiated (processLogs fails) func TestBitcoinDepositAndWithdrawWithDust(r *runner.E2ERunner, args []string) { // Given "Live" BTC network stop := r.MineBlocksIfLocalBitcoin() - defer stop() + defer func() { + stop() + // signal the deposit runner is done after this last test + wgDepositRunner.Done() + }() 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..e9546219db --- /dev/null +++ b/e2e/e2etests/test_bitcoin_withdraw_rbf.go @@ -0,0 +1,77 @@ +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" + "github.com/zeta-chain/node/zetaclient/chains/bitcoin/rpc" +) + +// 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. +// Chainging the 'minTxConfirmations' to 1 to not include Bitcoin a pending tx. +// https://github.com/zeta-chain/node/blob/feat-bitcoin-Replace-By-Fee/zetaclient/chains/bitcoin/observer/outbound.go#L30 +func TestBitcoinWithdrawRBF(r *runner.E2ERunner, args []string) { + require.Len(r, args, 2) + + // wait for block mining to stop + wgDepositRunner.Wait() + r.Logger.Print("Bitcoin mining stopped, starting RBF test") + + // parse arguments + defaultReceiver := r.BTCDeployerAddress.EncodeAddress() + to, amount := utils.ParseBitcoinWithdrawArgs(r, args, defaultReceiver, r.GetBitcoinChainID()) + + // initiate a withdraw CCTX + receipt := approveAndWithdrawBTCZRC20(r, to, amount) + cctx := utils.GetCctxByInboundHash(r.Ctx, receipt.TxHash.Hex(), r.CctxClient) + + // 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(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.MustHaveDroppedTx(r.Ctx, r.BtcRPCClient, txHash) + + // ensure the RBF tx is mined + rawResult := utils.MustHaveMinedTx(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 := rpc.GetTransactionFeeAndRate(r.BtcRPCClient, rawResult) + require.NoError(r, err) + require.Greater(r, newRate, oldRate, "RBF fee rate should be higher than the original tx") +} diff --git a/e2e/utils/bitcoin.go b/e2e/utils/bitcoin.go new file mode 100644 index 0000000000..f1059fe279 --- /dev/null +++ b/e2e/utils/bitcoin.go @@ -0,0 +1,48 @@ +package utils + +import ( + "context" + + "github.com/btcsuite/btcd/btcjson" + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/rpcclient" + "github.com/stretchr/testify/require" +) + +// MustHaveDroppedTx ensures the given tx has been dropped +func MustHaveDroppedTx(ctx context.Context, client *rpcclient.Client, txHash *chainhash.Hash) { + t := TestingFromContext(ctx) + + // dropped tx has negative confirmations + txResult, err := client.GetTransaction(txHash) + if err == nil { + require.Negative(t, txResult.Confirmations) + } + + // dropped tx should be removed from mempool + entry, err := client.GetMempoolEntry(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(txHash) + require.Error(t, err) + require.Nil(t, rawTx) +} + +// MustHaveMinedTx ensures the given tx has been mined +func MustHaveMinedTx(ctx context.Context, client *rpcclient.Client, txHash *chainhash.Hash) *btcjson.TxRawResult { + t := TestingFromContext(ctx) + + // positive confirmations + txResult, err := client.GetTransaction(txHash) + require.NoError(t, err) + require.Positive(t, txResult.Confirmations) + + // tx exists in blockchain + rawResult, err := client.GetRawTransactionVerbose(txHash) + require.NoError(t, err) + + return rawResult +} diff --git a/e2e/utils/zetacore.go b/e2e/utils/zetacore.go index 681e3bca7d..41756b6732 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" @@ -28,6 +29,24 @@ const ( DefaultCctxTimeout = 8 * time.Minute ) +// GetCctxByInboundHash gets cctx by inbound hash +func GetCctxByInboundHash( + ctx context.Context, + inboundHash string, + client crosschaintypes.QueryClient, +) *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, @@ -187,6 +206,56 @@ 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), + ) + time.Sleep(5 * time.Second) + + 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. diff --git a/zetaclient/chains/bitcoin/observer/gas_price.go b/zetaclient/chains/bitcoin/observer/gas_price.go index aff3b1b5f5..8a246c3754 100644 --- a/zetaclient/chains/bitcoin/observer/gas_price.go +++ b/zetaclient/chains/bitcoin/observer/gas_price.go @@ -63,7 +63,8 @@ func (ob *Observer) PostGasPrice(ctx context.Context) error { return errors.Wrap(err, "unable to execute specialHandleFeeRate") } } else { - feeRateEstimated, err = rpc.GetEstimatedFeeRate(ob.btcClient, 1) + isRegnet := chains.IsBitcoinRegnet(ob.Chain().ChainId) + feeRateEstimated, err = rpc.GetEstimatedFeeRate(ob.btcClient, 1, isRegnet) if err != nil { return errors.Wrap(err, "unable to get estimated fee rate") } @@ -92,8 +93,7 @@ func (ob *Observer) PostGasPrice(ctx context.Context) error { func (ob *Observer) specialHandleFeeRate() (int64, error) { switch ob.Chain().NetworkType { case chains.NetworkType_privnet: - // hardcode gas price for regnet - return 1, nil + return rpc.FeeRateRegnet, nil case chains.NetworkType_testnet: feeRateEstimated, err := bitcoin.GetRecentFeeRate(ob.btcClient, ob.netParams) if err != nil { diff --git a/zetaclient/chains/bitcoin/observer/mempool.go b/zetaclient/chains/bitcoin/observer/mempool.go index 34e57a0932..d483b20dbc 100644 --- a/zetaclient/chains/bitcoin/observer/mempool.go +++ b/zetaclient/chains/bitcoin/observer/mempool.go @@ -7,6 +7,7 @@ import ( "github.com/btcsuite/btcd/btcutil" "github.com/pkg/errors" + "github.com/zeta-chain/node/pkg/chains" "github.com/zeta-chain/node/pkg/ticker" "github.com/zeta-chain/node/zetaclient/chains/bitcoin/rpc" "github.com/zeta-chain/node/zetaclient/chains/interfaces" @@ -14,6 +15,15 @@ import ( "github.com/zeta-chain/node/zetaclient/logs" ) +const ( + // PendingTxFeeBumpWaitBlocks is the number of blocks to await before considering a tx stuck in mempool + PendingTxFeeBumpWaitBlocks = 3 + + // PendingTxFeeBumpWaitBlocksRegnet is the number of blocks to await before considering a tx stuck in mempool in regnet + // Note: this is used for E2E test only + PendingTxFeeBumpWaitBlocksRegnet = 30 +) + // LastStuckOutbound contains the last stuck outbound tx information. type LastStuckOutbound struct { // Nonce is the nonce of the outbound. @@ -35,16 +45,17 @@ func NewLastStuckOutbound(nonce uint64, tx *btcutil.Tx, stuckFor time.Duration) } } -// LastTxFinder is a function type for finding the last Bitcoin outbound tx. -type LastTxFinder func(ctx context.Context, ob *Observer) (*btcutil.Tx, uint64, error) +// PendingTxFinder is a function type for finding the last Bitcoin pending tx. +type PendingTxFinder func(ctx context.Context, ob *Observer) (*btcutil.Tx, uint64, error) // StuckTxChecker is a function type for checking if a tx is stuck in the mempool. type StuckTxChecker func(client interfaces.BTCRPCClient, txHash string, maxWaitBlocks int64) (bool, time.Duration, error) // WatchMempoolTxs monitors pending outbound txs in the Bitcoin mempool. func (ob *Observer) WatchMempoolTxs(ctx context.Context) error { + txChecker := GetStuckTxChecker(ob.Chain().ChainId) task := func(ctx context.Context, _ *ticker.Ticker) error { - if err := ob.RefreshLastStuckOutbound(ctx, GetLastOutbound, rpc.IsTxStuckInMempool); err != nil { + if err := ob.RefreshLastStuckOutbound(ctx, GetLastPendingOutbound, txChecker); err != nil { ob.Logger().Chain.Err(err).Msg("RefreshLastStuckOutbound error") } return nil @@ -60,28 +71,32 @@ func (ob *Observer) WatchMempoolTxs(ctx context.Context) error { } // RefreshLastStuckOutbound refreshes the information about the last stuck tx in the Bitcoin mempool. +// Once 2/3+ of the observers reach consensus on last stuck outbound, RBF will start. func (ob *Observer) RefreshLastStuckOutbound( ctx context.Context, - txFinder LastTxFinder, + txFinder PendingTxFinder, txChecker StuckTxChecker, ) error { + lf := map[string]any{ + logs.FieldMethod: "RefreshLastStuckOutbound", + } + // step 1: get last TSS transaction lastTx, lastNonce, err := txFinder(ctx, ob) if err != nil { - return errors.Wrap(err, "unable to find last outbound") + ob.logger.Outbound.Info().Msgf("last pending outbound not found: %s", err.Error()) + return nil } // log fields txHash := lastTx.MsgTx().TxID() - lf := map[string]any{ - logs.FieldMethod: "RefreshLastStuckOutbound", - logs.FieldNonce: lastNonce, - logs.FieldTx: txHash, - } + lf[logs.FieldNonce] = lastNonce + lf[logs.FieldTx] = txHash ob.logger.Outbound.Info().Fields(lf).Msg("checking last TSS outbound") // step 2: is last tx stuck in mempool? - stuck, stuckFor, err := txChecker(ob.btcClient, txHash, rpc.PendingTxFeeBumpWaitBlocks) + feeBumpWaitBlocks := GetFeeBumpWaitBlocks(ob.Chain().ChainId) + stuck, stuckFor, err := txChecker(ob.btcClient, txHash, feeBumpWaitBlocks) if err != nil { return errors.Wrapf(err, "cannot determine if tx %s nonce %d is stuck", txHash, lastNonce) } @@ -112,13 +127,13 @@ func (ob *Observer) RefreshLastStuckOutbound( return nil } -// GetLastOutbound gets the last outbound (with highest nonce) that had been sent to Bitcoin network. +// GetLastPendingOutbound gets the last pending outbound (with highest nonce) that sits in the Bitcoin mempool. // Bitcoin outbound txs can be found from two sources: // 1. txs that had been reported to tracker and then checked and included by this observer self. // 2. txs that had been broadcasted by this observer self. // -// Once 2/3+ of the observers reach consensus on last outbound, RBF will start. -func GetLastOutbound(ctx context.Context, ob *Observer) (*btcutil.Tx, uint64, error) { +// Returns error if last pending outbound is not found +func GetLastPendingOutbound(ctx context.Context, ob *Observer) (*btcutil.Tx, uint64, error) { var ( lastNonce uint64 lastHash string @@ -161,6 +176,12 @@ func GetLastOutbound(ctx context.Context, ob *Observer) (*btcutil.Tx, uint64, er return nil, 0, errors.New("last tx not found") } + // is tx in the mempool? + _, err = ob.btcClient.GetMempoolEntry(lastHash) + if err != nil { + return nil, 0, errors.New("last tx is not in mempool") + } + // ensure this tx is the REAL last transaction // cross-check the latest UTXO list, the nonce-mark utxo exists ONLY for last nonce if ob.FetchUTXOs(ctx) != nil { @@ -182,3 +203,19 @@ func GetLastOutbound(ctx context.Context, ob *Observer) (*btcutil.Tx, uint64, er return lastTx, lastNonce, nil } + +// GetStuckTxChecker returns the stuck tx checker function based on the chain ID. +func GetStuckTxChecker(chainID int64) StuckTxChecker { + if chains.IsBitcoinRegnet(chainID) { + return rpc.IsTxStuckInMempoolRegnet + } + return rpc.IsTxStuckInMempool +} + +// GetFeeBumpWaitBlocks returns the number of blocks to await before bumping tx fees +func GetFeeBumpWaitBlocks(chainID int64) int64 { + if chains.IsBitcoinRegnet(chainID) { + return PendingTxFeeBumpWaitBlocksRegnet + } + return PendingTxFeeBumpWaitBlocks +} diff --git a/zetaclient/chains/bitcoin/observer/mempool_test.go b/zetaclient/chains/bitcoin/observer/mempool_test.go index 621a053a8d..c9029a63f1 100644 --- a/zetaclient/chains/bitcoin/observer/mempool_test.go +++ b/zetaclient/chains/bitcoin/observer/mempool_test.go @@ -3,6 +3,7 @@ package observer_test import ( "context" "errors" + "reflect" "testing" "time" @@ -14,6 +15,7 @@ import ( "github.com/zeta-chain/node/pkg/chains" crosschaintypes "github.com/zeta-chain/node/x/observer/types" "github.com/zeta-chain/node/zetaclient/chains/bitcoin/observer" + "github.com/zeta-chain/node/zetaclient/chains/bitcoin/rpc" "github.com/zeta-chain/node/zetaclient/chains/interfaces" "github.com/zeta-chain/node/zetaclient/testutils" ) @@ -35,7 +37,7 @@ func Test_FefreshLastStuckOutbound(t *testing.T) { tests := []struct { name string - txFinder observer.LastTxFinder + txFinder observer.PendingTxFinder txChecker observer.StuckTxChecker oldStuckTx *observer.LastStuckOutbound expectedTx *observer.LastStuckOutbound @@ -43,34 +45,33 @@ func Test_FefreshLastStuckOutbound(t *testing.T) { }{ { name: "should set last stuck tx successfully", - txFinder: makeLastTxFinder(sampleTx1, 1, ""), + txFinder: makePendingTxFinder(sampleTx1, 1, ""), txChecker: makeStuckTxChecker(true, 30*time.Minute, ""), oldStuckTx: nil, expectedTx: observer.NewLastStuckOutbound(1, sampleTx1, 30*time.Minute), }, { name: "should update last stuck tx successfully", - txFinder: makeLastTxFinder(sampleTx2, 2, ""), + txFinder: makePendingTxFinder(sampleTx2, 2, ""), txChecker: makeStuckTxChecker(true, 40*time.Minute, ""), oldStuckTx: observer.NewLastStuckOutbound(1, sampleTx1, 30*time.Minute), expectedTx: observer.NewLastStuckOutbound(2, sampleTx2, 40*time.Minute), }, { name: "should clear last stuck tx successfully", - txFinder: makeLastTxFinder(sampleTx1, 1, ""), + txFinder: makePendingTxFinder(sampleTx1, 1, ""), txChecker: makeStuckTxChecker(false, 1*time.Minute, ""), oldStuckTx: observer.NewLastStuckOutbound(1, sampleTx1, 30*time.Minute), expectedTx: nil, }, { - name: "should return error if txFinder failed", - txFinder: makeLastTxFinder(nil, 0, "txFinder failed"), + name: "do nothing if unable to find last pending tx", + txFinder: makePendingTxFinder(nil, 0, "txFinder failed"), expectedTx: nil, - errMsg: "unable to find last outbound", }, { name: "should return error if txChecker failed", - txFinder: makeLastTxFinder(sampleTx1, 1, ""), + txFinder: makePendingTxFinder(sampleTx1, 1, ""), txChecker: makeStuckTxChecker(false, 0, "txChecker failed"), expectedTx: nil, errMsg: "cannot determine", @@ -104,7 +105,7 @@ func Test_FefreshLastStuckOutbound(t *testing.T) { } } -func Test_GetLastOutbound(t *testing.T) { +func Test_GetLastPendingOutbound(t *testing.T) { sampleTx := btcutil.NewTx(wire.NewMsgTx(wire.TxVersion)) tests := []struct { @@ -116,6 +117,7 @@ func Test_GetLastOutbound(t *testing.T) { tx *btcutil.Tx saveTx bool includeTx bool + failMempool bool failGetTx bool expectedTx *btcutil.Tx expectedNonce uint64 @@ -197,6 +199,22 @@ func Test_GetLastOutbound(t *testing.T) { expectedNonce: 0, errMsg: "last tx not found", }, + { + name: "return error if GetMempoolEntry failed", + chain: chains.BitcoinMainnet, + pendingNonce: 10, + pendingNonces: &crosschaintypes.PendingNonces{ + NonceLow: 9, + NonceHigh: 10, + }, + tx: sampleTx, + saveTx: true, + includeTx: false, + failMempool: true, + expectedTx: nil, + expectedNonce: 0, + errMsg: "last tx is not in mempool", + }, { name: "return error if FetchUTXOs failed", chain: chains.BitcoinMainnet, @@ -302,6 +320,11 @@ func Test_GetLastOutbound(t *testing.T) { } else { ob.client.On("ListUnspentMinMaxAddresses", mock.Anything, mock.Anything, mock.Anything).Maybe().Return(nil, errors.New("failed")) } + if !tt.failMempool { + ob.client.On("GetMempoolEntry", mock.Anything).Maybe().Return(nil, nil) + } else { + ob.client.On("GetMempoolEntry", mock.Anything).Maybe().Return(nil, errors.New("failed")) + } if tt.tx != nil && !tt.failGetTx { ob.client.On("GetRawTransaction", mock.Anything).Maybe().Return(tt.tx, nil) } else { @@ -309,7 +332,7 @@ func Test_GetLastOutbound(t *testing.T) { } ctx := context.Background() - lastTx, lastNonce, err := observer.GetLastOutbound(ctx, ob.Observer) + lastTx, lastNonce, err := observer.GetLastPendingOutbound(ctx, ob.Observer) if tt.errMsg != "" { require.ErrorContains(t, err, tt.errMsg) @@ -325,8 +348,80 @@ func Test_GetLastOutbound(t *testing.T) { } } -// makeLastTxFinder is a helper function to create a mock tx finder -func makeLastTxFinder(tx *btcutil.Tx, nonce uint64, errMsg string) observer.LastTxFinder { +func Test_GetStuckTxCheck(t *testing.T) { + tests := []struct { + name string + chainID int64 + txChecker observer.StuckTxChecker + }{ + { + name: "should return 3 blocks for Bitcoin mainnet", + chainID: chains.BitcoinMainnet.ChainId, + txChecker: rpc.IsTxStuckInMempool, + }, + { + name: "should return 3 blocks for Bitcoin testnet4", + chainID: chains.BitcoinTestnet.ChainId, + txChecker: rpc.IsTxStuckInMempool, + }, + { + name: "should return 3 blocks for Bitcoin Signet", + chainID: chains.BitcoinSignetTestnet.ChainId, + txChecker: rpc.IsTxStuckInMempool, + }, + { + name: "should return 10 blocks for Bitcoin regtest", + chainID: chains.BitcoinRegtest.ChainId, + txChecker: rpc.IsTxStuckInMempoolRegnet, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + txChecker := observer.GetStuckTxChecker(tt.chainID) + require.Equal(t, reflect.ValueOf(tt.txChecker).Pointer(), reflect.ValueOf(txChecker).Pointer()) + }) + } +} + +func Test_GetFeeBumpWaitBlocks(t *testing.T) { + tests := []struct { + name string + chainID int64 + expectedWaitBlocks int64 + }{ + { + name: "should return 3 blocks for Bitcoin mainnet", + chainID: chains.BitcoinMainnet.ChainId, + expectedWaitBlocks: 3, + }, + { + name: "should return 3 blocks for Bitcoin testnet4", + chainID: chains.BitcoinTestnet.ChainId, + expectedWaitBlocks: 3, + }, + { + name: "should return 3 blocks for Bitcoin Signet", + chainID: chains.BitcoinSignetTestnet.ChainId, + expectedWaitBlocks: 3, + }, + { + name: "should return 10 blocks for Bitcoin regtest", + chainID: chains.BitcoinRegtest.ChainId, + expectedWaitBlocks: 10, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + blocks := observer.GetFeeBumpWaitBlocks(tt.chainID) + require.Equal(t, tt.expectedWaitBlocks, blocks) + }) + } +} + +// makePendingTxFinder is a helper function to create a mock pending tx finder +func makePendingTxFinder(tx *btcutil.Tx, nonce uint64, errMsg string) observer.PendingTxFinder { var err error if errMsg != "" { err = errors.New(errMsg) diff --git a/zetaclient/chains/bitcoin/observer/outbound.go b/zetaclient/chains/bitcoin/observer/outbound.go index 1c7983043c..d3b79f103d 100644 --- a/zetaclient/chains/bitcoin/observer/outbound.go +++ b/zetaclient/chains/bitcoin/observer/outbound.go @@ -25,6 +25,12 @@ import ( "github.com/zeta-chain/node/zetaclient/zetacore" ) +const ( + // minTxConfirmations is the minimum confirmations for a Bitcoin tx to be considered valid by the observer + // Note: please change this value to 1 to be able to run the Bitcoin E2E RBF test + minTxConfirmations = 0 +) + // WatchOutbound watches Bitcoin chain for outgoing txs status // TODO(revamp): move ticker functions to a specific file // TODO(revamp): move into a separate package @@ -121,6 +127,8 @@ func (ob *Observer) ProcessOutboundTrackers(ctx context.Context) error { // 2. a valid tx included in a block with confirmation > 0 // // Returns: (txResult, included) +// +// Note: A 'included' tx may still be considered stuck if it sits in the mempool for too long. func (ob *Observer) TryIncludeOutbound( ctx context.Context, cctx *crosschaintypes.CrossChainTx, @@ -320,8 +328,6 @@ func (ob *Observer) getOutboundHashByNonce(ctx context.Context, nonce uint64, te } // checkTxInclusion checks if a txHash is included and returns (txResult, included) -// -// Note: a 'included' tx may still be considered stuck if it's in mempool for too long. func (ob *Observer) checkTxInclusion( ctx context.Context, cctx *crosschaintypes.CrossChainTx, @@ -341,6 +347,12 @@ func (ob *Observer) checkTxInclusion( return nil, false } + // check minimum confirmations + if txResult.Confirmations < minTxConfirmations { + ob.logger.Outbound.Warn().Fields(lf).Msgf("invalid confirmations %d", txResult.Confirmations) + return nil, false + } + // validate tx result err = ob.checkTssOutboundResult(ctx, cctx, hash, txResult) if err != nil { @@ -420,11 +432,6 @@ func (ob *Observer) checkTssOutboundResult( hash *chainhash.Hash, res *btcjson.GetTransactionResult, ) error { - // negative confirmation means invalid tx, return error - if res.Confirmations < 0 { - return fmt.Errorf("checkTssOutboundResult: negative confirmations %d", res.Confirmations) - } - params := cctx.GetCurrentOutboundParam() nonce := params.TssNonce rawResult, err := rpc.GetRawTxResult(ob.btcClient, hash, res) diff --git a/zetaclient/chains/bitcoin/rpc/rpc.go b/zetaclient/chains/bitcoin/rpc/rpc.go index 435f844c94..8c429dd756 100644 --- a/zetaclient/chains/bitcoin/rpc/rpc.go +++ b/zetaclient/chains/bitcoin/rpc/rpc.go @@ -22,8 +22,11 @@ const ( // Bitcoin block time is 10 minutes, 1200s (20 minutes) is a reasonable threshold for Bitcoin RPCAlertLatency = time.Duration(1200) * time.Second - // PendingTxFeeBumpWaitBlocks is the number of blocks to await before considering a tx stuck in mempool - PendingTxFeeBumpWaitBlocks = 3 + // FeeRateRegnet is the hardcoded fee rate for regnet + FeeRateRegnet = 1 + + // FeeRateRegnetRBF is the hardcoded fee rate for regnet RBF + FeeRateRegnetRBF = 5 // blockTimeBTC represents the average time to mine a block in Bitcoin blockTimeBTC = 10 * time.Minute @@ -153,7 +156,12 @@ func FeeRateToSatPerByte(rate float64) *big.Int { } // GetEstimatedFeeRate gets estimated smart fee rate (BTC/Kb) targeting given block confirmation -func GetEstimatedFeeRate(rpcClient interfaces.BTCRPCClient, confTarget int64) (int64, error) { +func GetEstimatedFeeRate(rpcClient interfaces.BTCRPCClient, confTarget int64, regnet bool) (int64, error) { + // RPC 'EstimateSmartFee' is not available in regnet + if regnet { + return FeeRateRegnet, nil + } + feeResult, err := rpcClient.EstimateSmartFee(confTarget, &btcjson.EstimateModeEconomical) if err != nil { return 0, errors.Wrap(err, "unable to estimate smart fee") @@ -162,10 +170,10 @@ func GetEstimatedFeeRate(rpcClient interfaces.BTCRPCClient, confTarget int64) (i return 0, fmt.Errorf("fee result contains errors: %s", feeResult.Errors) } if feeResult.FeeRate == nil { - return 0, fmt.Errorf("fee rate is nil") + return 0, fmt.Errorf("nil fee rate") } if *feeResult.FeeRate <= 0 || *feeResult.FeeRate >= maxBTCSupply { - return 0, fmt.Errorf("fee rate is invalid: %f", *feeResult.FeeRate) + return 0, fmt.Errorf("invalid fee rate: %f", *feeResult.FeeRate) } return FeeRateToSatPerByte(*feeResult.FeeRate).Int64(), nil @@ -254,9 +262,41 @@ func IsTxStuckInMempool( return false, pendingTime, nil } -// GetTotalMempoolParentsSizeNFees returns the total fee and vsize of all pending parents of a given pending child tx (inclusive) +// IsTxStuckInMempoolRegnet checks if the transaction is stuck in the mempool in regnet. +// Note: this function is a simplified version used in regnet for E2E test. +func IsTxStuckInMempoolRegnet( + client interfaces.BTCRPCClient, + txHash string, + maxWaitBlocks int64, +) (bool, time.Duration, error) { + lastBlock, err := client.GetBlockCount() + if err != nil { + return false, 0, errors.Wrap(err, "GetBlockCount failed") + } + + memplEntry, err := client.GetMempoolEntry(txHash) + if err != nil { + if strings.Contains(err.Error(), "Transaction not in mempool") { + return false, 0, nil // not a mempool tx, of course not stuck + } + return false, 0, errors.Wrap(err, "GetMempoolEntry failed") + } + + // is the tx pending for too long? + pendingTime := time.Since(time.Unix(memplEntry.Time, 0)) + pendingTimeAllowed := time.Second * time.Duration(maxWaitBlocks) + + // the block mining is frozen in Regnet for E2E test + if pendingTime > pendingTimeAllowed && memplEntry.Height == lastBlock { + return true, pendingTime, nil + } + + return false, pendingTime, nil +} + +// GetTotalMempoolParentsSizeNFees returns the total fee and vsize of all pending parents of a given tx (inclusive) // -// A parent is defined as: +// A parent tx is defined as: // - a tx that is also pending in the mempool // - a tx that has its first output spent by the child as first input // @@ -284,7 +324,7 @@ func GetTotalMempoolParentsSizeNFees( return 0, 0, 0, 0, errors.Wrapf(err, "unable to get mempool entry for tx %s", parentHash) } - // sum up the total fees and vsize + // accumulate fees and vsize totalTxs++ totalFees += memplEntry.Fee totalVSize += int64(memplEntry.VSize) @@ -297,14 +337,14 @@ func GetTotalMempoolParentsSizeNFees( parentHash = tx.MsgTx().TxIn[0].PreviousOutPoint.Hash.String() } - // sanity check, should never happen - if totalFees <= 0 || totalVSize <= 0 { - return 0, 0, 0, 0, errors.Errorf("invalid result: totalFees %f, totalVSize %d", totalFees, totalVSize) - } - // no pending tx found if totalTxs == 0 { - return 0, 0, 0, 0, errors.Errorf("no pending tx found for given child %s", childHash) + return 0, 0, 0, 0, errors.Errorf("given tx is not pending: %s", childHash) + } + + // sanity check, should never happen + if totalFees < 0 || totalVSize <= 0 { + return 0, 0, 0, 0, errors.Errorf("invalid result: totalFees %f, totalVSize %d", totalFees, totalVSize) } // calculate the average fee rate diff --git a/zetaclient/chains/bitcoin/rpc/rpc_test.go b/zetaclient/chains/bitcoin/rpc/rpc_test.go new file mode 100644 index 0000000000..fd782178d8 --- /dev/null +++ b/zetaclient/chains/bitcoin/rpc/rpc_test.go @@ -0,0 +1,92 @@ +package rpc_test + +import ( + "testing" + + "github.com/btcsuite/btcd/btcjson" + "github.com/pkg/errors" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + "github.com/zeta-chain/node/zetaclient/chains/bitcoin/rpc" + "github.com/zeta-chain/node/zetaclient/testutils/mocks" +) + +func Test_GetEstimatedFeeRate(t *testing.T) { + tests := []struct { + name string + rate float64 + regnet bool + resultError bool + rpcError bool + expectedRate int64 + errMsg string + }{ + { + name: "normal", + rate: 0.0001, + regnet: false, + expectedRate: 10, + }, + { + name: "should return 1 for regnet", + rate: 0.0001, + regnet: true, + expectedRate: 1, + }, + { + name: "should return error on rpc error", + rpcError: true, + errMsg: "unable to estimate smart fee", + }, + { + name: "should return error on result error", + rate: 0.0001, + resultError: true, + errMsg: "fee result contains errors", + }, + { + name: "should return error on negative rate", + rate: -0.0001, + expectedRate: 0, + errMsg: "invalid fee rate", + }, + { + name: "should return error if it's greater than max supply", + rate: 21000000, + expectedRate: 0, + errMsg: "invalid fee rate", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + client := mocks.NewBTCRPCClient(t) + + switch { + case tt.rpcError: + client.On("EstimateSmartFee", mock.Anything, mock.Anything).Return(nil, errors.New("error")) + case tt.resultError: + client.On("EstimateSmartFee", mock.Anything, mock.Anything).Return(&btcjson.EstimateSmartFeeResult{ + Errors: []string{"error"}, + }, nil) + default: + client.On("EstimateSmartFee", mock.Anything, mock.Anything). + Maybe(). + Return(&btcjson.EstimateSmartFeeResult{ + Errors: nil, + FeeRate: &tt.rate, + }, nil) + } + + rate, err := rpc.GetEstimatedFeeRate(client, 1, tt.regnet) + if tt.errMsg != "" { + require.ErrorContains(t, err, tt.errMsg) + require.Zero(t, rate) + return + } + + require.NoError(t, err) + require.Equal(t, tt.expectedRate, rate) + }) + } +} diff --git a/zetaclient/chains/bitcoin/signer/fee_bumper.go b/zetaclient/chains/bitcoin/signer/fee_bumper.go index 9c4fc0f3bc..59fa6f64ad 100644 --- a/zetaclient/chains/bitcoin/signer/fee_bumper.go +++ b/zetaclient/chains/bitcoin/signer/fee_bumper.go @@ -10,6 +10,7 @@ import ( "github.com/pkg/errors" "github.com/rs/zerolog" + "github.com/zeta-chain/node/pkg/chains" "github.com/zeta-chain/node/pkg/constant" mathpkg "github.com/zeta-chain/node/pkg/math" "github.com/zeta-chain/node/zetaclient/chains/bitcoin" @@ -19,7 +20,7 @@ import ( const ( // gasRateCap is the maximum average gas rate for CPFP fee bumping - // 100 sat/vB is a typical heuristic based on Bitcoin mempool statistics + // 100 sat/vB is a heuristic based on Bitcoin mempool statistics to avoid excessive fees // see: https://mempool.space/graphs/mempool#3y gasRateCap = 100 @@ -34,6 +35,8 @@ type MempoolTxsInfoFetcher func(interfaces.BTCRPCClient, string) (int64, float64 // CPFPFeeBumper is a helper struct to contain CPFP (child-pays-for-parent) fee bumping logic type CPFPFeeBumper struct { + Chain chains.Chain + // Client is the RPC Client to interact with the Bitcoin chain Client interfaces.BTCRPCClient @@ -64,6 +67,7 @@ type CPFPFeeBumper struct { // NewCPFPFeeBumper creates a new CPFPFeeBumper func NewCPFPFeeBumper( + chain chains.Chain, client interfaces.BTCRPCClient, memplTxsInfoFetcher MempoolTxsInfoFetcher, tx *btcutil.Tx, @@ -72,6 +76,7 @@ func NewCPFPFeeBumper( logger zerolog.Logger, ) (*CPFPFeeBumper, error) { fb := &CPFPFeeBumper{ + Chain: chain, Client: client, Tx: tx, MinRelayFee: minRelayFee, @@ -93,12 +98,12 @@ func (b *CPFPFeeBumper) BumpTxFee() (*wire.MsgTx, int64, int64, error) { newTx.TxIn[idx].Witness = wire.TxWitness{} } - // check reserved bump fees amount in the original tx + // ensure the original tx has reserved bump fees if len(newTx.TxOut) < 3 { return nil, 0, 0, errors.New("original tx has no reserved bump fees") } - // tx replacement is triggered only when market fee rate goes 20% higher than current paid fee rate. + // tx replacement is triggered only when market fee rate goes 20% higher than current paid rate. // zetacore updates the cctx fee rate evey 10 minutes, we could hold on and retry later. minBumpRate := mathpkg.IncreaseIntByPercent(b.AvgFeeRate, minCPFPFeeBumpPercent, true) if b.CCTXRate < minBumpRate { @@ -141,7 +146,11 @@ func (b *CPFPFeeBumper) BumpTxFee() (*wire.MsgTx, int64, int64, error) { // see: https://github.com/bitcoin/bitcoin/blob/master/src/policy/rbf.cpp#L166-L183 additionalFees := b.TotalVSize*gasRateNew - b.TotalFees if additionalFees < minRelayTxFees { - additionalFees = minRelayTxFees + return nil, 0, 0, fmt.Errorf( + "hold on RBF: additional fees %d is lower than min relay fees %d", + additionalFees, + minRelayTxFees, + ) } // bump fees in two ways: @@ -162,8 +171,9 @@ func (b *CPFPFeeBumper) BumpTxFee() (*wire.MsgTx, int64, int64, error) { // fetchFeeBumpInfo fetches all necessary information needed to bump the stuck tx func (b *CPFPFeeBumper) FetchFeeBumpInfo(memplTxsInfoFetcher MempoolTxsInfoFetcher, logger zerolog.Logger) error { - // query live network fee rate - liveRate, err := rpc.GetEstimatedFeeRate(b.Client, 1) + // query live fee rate + isRegnet := chains.IsBitcoinRegnet(b.Chain.ChainId) + liveRate, err := rpc.GetEstimatedFeeRate(b.Client, 1, isRegnet) if err != nil { return errors.Wrap(err, "GetEstimatedFeeRate failed") } @@ -188,3 +198,12 @@ func (b *CPFPFeeBumper) FetchFeeBumpInfo(memplTxsInfoFetcher MempoolTxsInfoFetch return nil } + +// CopyMsgTxNoWitness creates a deep copy of the given MsgTx and clears the witness data +func CopyMsgTxNoWitness(tx *wire.MsgTx) *wire.MsgTx { + copyTx := tx.Copy() + for idx := range copyTx.TxIn { + copyTx.TxIn[idx].Witness = wire.TxWitness{} + } + return copyTx +} diff --git a/zetaclient/chains/bitcoin/signer/fee_bumper_test.go b/zetaclient/chains/bitcoin/signer/fee_bumper_test.go index 27676084c5..3312ae6341 100644 --- a/zetaclient/chains/bitcoin/signer/fee_bumper_test.go +++ b/zetaclient/chains/bitcoin/signer/fee_bumper_test.go @@ -21,6 +21,7 @@ import ( func Test_NewCPFPFeeBumper(t *testing.T) { tests := []struct { name string + chain chains.Chain client *mocks.BTCRPCClient tx *btcutil.Tx cctxRate int64 @@ -31,6 +32,7 @@ func Test_NewCPFPFeeBumper(t *testing.T) { expected *signer.CPFPFeeBumper }{ { + chain: chains.BitcoinMainnet, name: "should create new CPFPFeeBumper successfully", client: mocks.NewBTCRPCClient(t), tx: btcutil.NewTx(wire.NewMsgTx(wire.TxVersion)), @@ -45,6 +47,7 @@ func Test_NewCPFPFeeBumper(t *testing.T) { "", // no error ), expected: &signer.CPFPFeeBumper{ + Chain: chains.BitcoinMainnet, Tx: btcutil.NewTx(wire.NewMsgTx(wire.TxVersion)), MinRelayFee: 0.00001, CCTXRate: 10, @@ -56,6 +59,7 @@ func Test_NewCPFPFeeBumper(t *testing.T) { }, }, { + chain: chains.BitcoinMainnet, name: "should fail when mempool txs info fetcher returns error", client: mocks.NewBTCRPCClient(t), tx: btcutil.NewTx(wire.NewMsgTx(wire.TxVersion)), @@ -80,6 +84,7 @@ func Test_NewCPFPFeeBumper(t *testing.T) { }, nil) bumper, err := signer.NewCPFPFeeBumper( + tt.chain, tt.client, tt.memplTxsInfoFetcher, tt.tx, @@ -128,31 +133,11 @@ func Test_BumpTxFee(t *testing.T) { expectedNewRate: 57, expectedNewTx: func() *wire.MsgTx { // deduct additional fees - newTx := copyMsgTx(msgTx) + newTx := signer.CopyMsgTxNoWitness(msgTx) newTx.TxOut[2].Value -= 5790 return newTx }(), }, - { - name: "should cover min relay fees", - feeBumper: &signer.CPFPFeeBumper{ - Tx: btcutil.NewTx(msgTx), - MinRelayFee: 0.00002, // min relay fee will be 579vB * 2 = 1158 sats - CCTXRate: 6, - LiveRate: 7, - TotalFees: 2895, - TotalVSize: 579, - AvgFeeRate: 5, - }, - additionalFees: 1158, - expectedNewRate: 7, // (2895 + 1158) / 579 = 7 - expectedNewTx: func() *wire.MsgTx { - // deduct additional fees - newTx := copyMsgTx(msgTx) - newTx.TxOut[2].Value -= 1158 - return newTx - }(), - }, { name: "should give up all reserved bump fees", feeBumper: &signer.CPFPFeeBumper{ @@ -173,7 +158,7 @@ func Test_BumpTxFee(t *testing.T) { expectedNewRate: 59, // (27213 + 6789) / 579 ≈ 59 expectedNewTx: func() *wire.MsgTx { // give up all reserved bump fees - newTx := copyMsgTx(msgTx) + newTx := signer.CopyMsgTxNoWitness(msgTx) newTx.TxOut = newTx.TxOut[:2] return newTx }(), @@ -193,7 +178,7 @@ func Test_BumpTxFee(t *testing.T) { expectedNewRate: 100, expectedNewTx: func() *wire.MsgTx { // deduct additional fees - newTx := copyMsgTx(msgTx) + newTx := signer.CopyMsgTxNoWitness(msgTx) newTx.TxOut[2].Value -= 30687 return newTx }(), @@ -229,6 +214,19 @@ func Test_BumpTxFee(t *testing.T) { }, errMsg: "much higher than the cctx rate", }, + { + name: "should hold on RBF if additional fees is lower than min relay fees", + feeBumper: &signer.CPFPFeeBumper{ + Tx: btcutil.NewTx(msgTx), + MinRelayFee: 0.00002, // min relay fee will be 579vB * 2 = 1158 sats + CCTXRate: 6, + LiveRate: 7, + TotalFees: 2895, + TotalVSize: 579, + AvgFeeRate: 5, + }, + errMsg: "lower than min relay fees", + }, } for _, tt := range tests { @@ -327,6 +325,24 @@ func Test_FetchFeeBumpInfo(t *testing.T) { } } +func Test_CopyMsgTxNoWitness(t *testing.T) { + chain := chains.BitcoinMainnet + txid := "030cd813443f7b70cc6d8a544d320c6d8465e4528fc0f3410b599dc0b26753a0" + msgTx := testutils.LoadBTCMsgTx(t, TestDataDir, chain.ChainId, txid) + + // make a non-witness copy + copyTx := signer.CopyMsgTxNoWitness(msgTx) + + // make another copy and clear witness data manually + newTx := msgTx.Copy() + for idx := range newTx.TxIn { + newTx.TxIn[idx].Witness = wire.TxWitness{} + } + + // check + require.Equal(t, newTx, copyTx) +} + // makeMempoolTxsInfoFetcher is a helper function to create a mock MempoolTxsInfoFetcher func makeMempoolTxsInfoFetcher( totalTxs int64, @@ -344,12 +360,3 @@ func makeMempoolTxsInfoFetcher( return totalTxs, totalFees, totalVSize, avgFeeRate, err } } - -// copyMsgTx is a helper function to copy a MsgTx and clean witness data -func copyMsgTx(tx *wire.MsgTx) *wire.MsgTx { - newTx := tx.Copy() - for idx := range newTx.TxIn { - newTx.TxIn[idx].Witness = wire.TxWitness{} - } - return newTx -} diff --git a/zetaclient/chains/bitcoin/signer/sign_rbf.go b/zetaclient/chains/bitcoin/signer/sign_rbf.go index 27fd956093..2bd0e9b1c9 100644 --- a/zetaclient/chains/bitcoin/signer/sign_rbf.go +++ b/zetaclient/chains/bitcoin/signer/sign_rbf.go @@ -9,7 +9,9 @@ import ( "github.com/btcsuite/btcd/wire" "github.com/pkg/errors" + "github.com/zeta-chain/node/pkg/chains" "github.com/zeta-chain/node/x/crosschain/types" + "github.com/zeta-chain/node/zetaclient/chains/bitcoin/rpc" "github.com/zeta-chain/node/zetaclient/logs" ) @@ -44,14 +46,22 @@ func (signer *Signer) SignRBFTx( logger = signer.Logger().Std.With().Fields(lf).Logger() ) - // parse recent fee rate from CCTX - cctxRate, err := strconv.ParseInt(params.GasPriorityFee, 10, 64) - if err != nil || cctxRate <= 0 { - return nil, fmt.Errorf("invalid fee rate %s", params.GasPrice) + var cctxRate int64 + switch signer.Chain().ChainId { + case chains.BitcoinRegtest.ChainId: + // hardcode for regnet E2E test, zetacore won't feed it to CCTX + cctxRate = rpc.FeeRateRegnetRBF + default: + // parse recent fee rate from CCTX + cctxRate, err := strconv.ParseInt(params.GasPriorityFee, 10, 64) + if err != nil || cctxRate <= 0 { + return nil, fmt.Errorf("invalid fee rate %s", params.GasPrice) + } } // create fee bumper fb, err := NewCPFPFeeBumper( + signer.Chain(), signer.client, memplTxsInfoFetcher, lastTx, @@ -68,7 +78,8 @@ func (signer *Signer) SignRBFTx( if err != nil { return nil, errors.Wrap(err, "BumpTxFee failed") } - logger.Info().Msgf("BumpTxFee succeed, additional fees: %d satoshis, new rate: %d sat/vB", additionalFees, newRate) + logger.Info(). + Msgf("BumpTxFee succeed, additional fees: %d sats, rate: %d => %d sat/vB", additionalFees, fb.AvgFeeRate, newRate) // collect input amounts for signing inAmounts := make([]int64, len(newTx.TxIn)) diff --git a/zetaclient/chains/bitcoin/signer/sign_rbf_test.go b/zetaclient/chains/bitcoin/signer/sign_rbf_test.go index 494df94e34..325b758b7b 100644 --- a/zetaclient/chains/bitcoin/signer/sign_rbf_test.go +++ b/zetaclient/chains/bitcoin/signer/sign_rbf_test.go @@ -110,7 +110,7 @@ func Test_SignRBFTx(t *testing.T) { ), expectedTx: func() *wire.MsgTx { // deduct additional fees - newTx := copyMsgTx(msgTx) + newTx := signer.CopyMsgTxNoWitness(msgTx) newTx.TxOut[2].Value -= 5790 return newTx }(), diff --git a/zetaclient/chains/bitcoin/signer/signer.go b/zetaclient/chains/bitcoin/signer/signer.go index fa63ebde37..88ba3ea9ab 100644 --- a/zetaclient/chains/bitcoin/signer/signer.go +++ b/zetaclient/chains/bitcoin/signer/signer.go @@ -172,19 +172,13 @@ func (signer *Signer) TryProcessOutbound( // sign outbound if stuckTx != nil && params.TssNonce == stuckTx.Nonce { // sign RBF tx - signedTx, err = signer.SignRBFTx( - ctx, - cctx, - height, - stuckTx.Tx, - minRelayFee, - rpc.GetTotalMempoolParentsSizeNFees, - ) + mempoolFetcher := rpc.GetTotalMempoolParentsSizeNFees + signedTx, err = signer.SignRBFTx(ctx, cctx, height, stuckTx.Tx, minRelayFee, mempoolFetcher) if err != nil { logger.Error().Err(err).Msg("SignRBFTx failed") return } - logger.Info().Msg("SignRBFTx success") + logger.Info().Str(logs.FieldTx, signedTx.TxID()).Msg("SignRBFTx succeed") } else { // setup outbound data txData, err := NewOutboundData(cctx, chain.ChainId, height, minRelayFee, logger, signer.Logger().Compliance) @@ -199,7 +193,7 @@ func (signer *Signer) TryProcessOutbound( logger.Error().Err(err).Msg("SignWithdrawTx failed") return } - logger.Info().Msg("SignWithdrawTx success") + logger.Info().Str(logs.FieldTx, signedTx.TxID()).Msg("SignWithdrawTx succeed") } // broadcast signed outbound diff --git a/zetaclient/common/constant.go b/zetaclient/common/constant.go index acf80a6e24..e7714ff5e6 100644 --- a/zetaclient/common/constant.go +++ b/zetaclient/common/constant.go @@ -16,5 +16,5 @@ const ( RPCStatusCheckInterval = time.Minute // MempoolStuckTxCheckInterval is the interval to check for stuck transactions in the mempool - MempoolStuckTxCheckInterval = time.Minute + MempoolStuckTxCheckInterval = 30 * time.Second ) From e03ba4ba7c5df6698bd9b38bf84b43b6f4742fd1 Mon Sep 17 00:00:00 2001 From: Charlie Chen Date: Thu, 9 Jan 2025 23:08:10 -0600 Subject: [PATCH 14/20] fix unit tests --- .../chains/bitcoin/observer/mempool_test.go | 16 ++++++++-------- .../chains/bitcoin/rpc/rpc_rbf_live_test.go | 8 +++++--- zetaclient/chains/bitcoin/signer/fee_bumper.go | 9 ++------- .../chains/bitcoin/signer/outbound_data.go | 2 +- zetaclient/chains/bitcoin/signer/sign_rbf.go | 5 +++-- 5 files changed, 19 insertions(+), 21 deletions(-) diff --git a/zetaclient/chains/bitcoin/observer/mempool_test.go b/zetaclient/chains/bitcoin/observer/mempool_test.go index c9029a63f1..19e0e632f8 100644 --- a/zetaclient/chains/bitcoin/observer/mempool_test.go +++ b/zetaclient/chains/bitcoin/observer/mempool_test.go @@ -391,24 +391,24 @@ func Test_GetFeeBumpWaitBlocks(t *testing.T) { expectedWaitBlocks int64 }{ { - name: "should return 3 blocks for Bitcoin mainnet", + name: "should return wait blocks for Bitcoin mainnet", chainID: chains.BitcoinMainnet.ChainId, - expectedWaitBlocks: 3, + expectedWaitBlocks: observer.PendingTxFeeBumpWaitBlocks, }, { - name: "should return 3 blocks for Bitcoin testnet4", + name: "should return wait blocks for Bitcoin testnet4", chainID: chains.BitcoinTestnet.ChainId, - expectedWaitBlocks: 3, + expectedWaitBlocks: observer.PendingTxFeeBumpWaitBlocks, }, { - name: "should return 3 blocks for Bitcoin Signet", + name: "should return wait blocks for Bitcoin signet", chainID: chains.BitcoinSignetTestnet.ChainId, - expectedWaitBlocks: 3, + expectedWaitBlocks: observer.PendingTxFeeBumpWaitBlocks, }, { - name: "should return 10 blocks for Bitcoin regtest", + name: "should return wait blocks for Bitcoin regtest", chainID: chains.BitcoinRegtest.ChainId, - expectedWaitBlocks: 10, + expectedWaitBlocks: observer.PendingTxFeeBumpWaitBlocksRegnet, }, } diff --git a/zetaclient/chains/bitcoin/rpc/rpc_rbf_live_test.go b/zetaclient/chains/bitcoin/rpc/rpc_rbf_live_test.go index c91f303898..88192bfd6a 100644 --- a/zetaclient/chains/bitcoin/rpc/rpc_rbf_live_test.go +++ b/zetaclient/chains/bitcoin/rpc/rpc_rbf_live_test.go @@ -62,10 +62,12 @@ func Test_BitcoinRBFLive(t *testing.T) { return } + LiveTest_RBFTransaction(t) + LiveTest_RBFTransaction_Chained_CPFP(t) LiveTest_PendingMempoolTx(t) } -func Test_RBFTransaction(t *testing.T) { +func LiveTest_RBFTransaction(t *testing.T) { // setup test client, privKey, sender, to := setupTest(t) @@ -143,8 +145,8 @@ func Test_RBFTransaction(t *testing.T) { //fmt.Println("tx2 dropped") } -// Test_RBFTransactionChained_CPFP tests Child-Pays-For-Parent (CPFP) fee bumping strategy for chained RBF transactions -func Test_RBFTransaction_Chained_CPFP(t *testing.T) { +// LiveTest_RBFTransaction_Chained_CPFP tests Child-Pays-For-Parent (CPFP) fee bumping strategy for chained RBF transactions +func LiveTest_RBFTransaction_Chained_CPFP(t *testing.T) { // setup test client, privKey, sender, to := setupTest(t) diff --git a/zetaclient/chains/bitcoin/signer/fee_bumper.go b/zetaclient/chains/bitcoin/signer/fee_bumper.go index 59fa6f64ad..762c5948e0 100644 --- a/zetaclient/chains/bitcoin/signer/fee_bumper.go +++ b/zetaclient/chains/bitcoin/signer/fee_bumper.go @@ -92,13 +92,8 @@ func NewCPFPFeeBumper( // BumpTxFee bumps the fee of the stuck transaction using reserved bump fees func (b *CPFPFeeBumper) BumpTxFee() (*wire.MsgTx, int64, int64, error) { - // reuse old tx body and clear witness data (e.g., signatures) - newTx := b.Tx.MsgTx().Copy() - for idx := range newTx.TxIn { - newTx.TxIn[idx].Witness = wire.TxWitness{} - } - - // ensure the original tx has reserved bump fees + // reuse old tx body + newTx := CopyMsgTxNoWitness(b.Tx.MsgTx()) if len(newTx.TxOut) < 3 { return nil, 0, 0, errors.New("original tx has no reserved bump fees") } diff --git a/zetaclient/chains/bitcoin/signer/outbound_data.go b/zetaclient/chains/bitcoin/signer/outbound_data.go index 115b13b28a..7e5835ed10 100644 --- a/zetaclient/chains/bitcoin/signer/outbound_data.go +++ b/zetaclient/chains/bitcoin/signer/outbound_data.go @@ -71,7 +71,7 @@ func NewOutboundData( // use current gas rate if fed by zetacore newRate, err := strconv.ParseInt(params.GasPriorityFee, 10, 64) if err == nil && newRate > 0 && newRate != feeRate { - logger.Info().Msgf("use new gas rate %d sat/vB instead of %d sat/vB", newRate, feeRate) + logger.Info().Msgf("use new fee rate %d sat/vB instead of %d sat/vB", newRate, feeRate) feeRate = newRate } diff --git a/zetaclient/chains/bitcoin/signer/sign_rbf.go b/zetaclient/chains/bitcoin/signer/sign_rbf.go index 2bd0e9b1c9..e28bfbdb8c 100644 --- a/zetaclient/chains/bitcoin/signer/sign_rbf.go +++ b/zetaclient/chains/bitcoin/signer/sign_rbf.go @@ -53,10 +53,11 @@ func (signer *Signer) SignRBFTx( cctxRate = rpc.FeeRateRegnetRBF default: // parse recent fee rate from CCTX - cctxRate, err := strconv.ParseInt(params.GasPriorityFee, 10, 64) - if err != nil || cctxRate <= 0 { + recentRate, err := strconv.ParseInt(params.GasPriorityFee, 10, 64) + if err != nil || recentRate <= 0 { return nil, fmt.Errorf("invalid fee rate %s", params.GasPrice) } + cctxRate = recentRate } // create fee bumper From 5df65fb64b7eab6c92f52cbdc3f35e16855b086e Mon Sep 17 00:00:00 2001 From: Charlie Chen Date: Sun, 12 Jan 2025 23:56:13 -0600 Subject: [PATCH 15/20] handle integer overflow and correct typo in function comment --- e2e/e2etests/test_bitcoin_withdraw_rbf.go | 2 +- pkg/math/integer.go | 44 ++++++++++++------- pkg/math/integer_test.go | 19 ++++---- .../chains/bitcoin/signer/fee_bumper.go | 4 +- 4 files changed, 41 insertions(+), 28 deletions(-) diff --git a/e2e/e2etests/test_bitcoin_withdraw_rbf.go b/e2e/e2etests/test_bitcoin_withdraw_rbf.go index e9546219db..ae95958567 100644 --- a/e2e/e2etests/test_bitcoin_withdraw_rbf.go +++ b/e2e/e2etests/test_bitcoin_withdraw_rbf.go @@ -17,7 +17,7 @@ import ( // 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. -// Chainging the 'minTxConfirmations' to 1 to not include Bitcoin a pending tx. +// Changing the 'minTxConfirmations' to 1 to not include Bitcoin pending txs. // https://github.com/zeta-chain/node/blob/feat-bitcoin-Replace-By-Fee/zetaclient/chains/bitcoin/observer/outbound.go#L30 func TestBitcoinWithdrawRBF(r *runner.E2ERunner, args []string) { require.Len(r, args, 2) diff --git a/pkg/math/integer.go b/pkg/math/integer.go index 18370aed0c..7753d6c168 100644 --- a/pkg/math/integer.go +++ b/pkg/math/integer.go @@ -1,23 +1,35 @@ package math -import "math" +import ( + "math" + "math/big" +) // IncreaseIntByPercent is a function that increases integer by a percentage. -// Example1: IncreaseIntByPercent(10, 15, true) = 10 * 1.15 = 12 -// Example2: IncreaseIntByPercent(10, 15, false) = 10 + 10 * 0.15 = 11 -func IncreaseIntByPercent(value int64, percent uint32, round bool) int64 { - switch { - case percent == 0: +func IncreaseIntByPercent(value int64, percent uint32) int64 { + if percent == 0 { return value - case percent%100 == 0: - // optimization: a simple multiplication - increase := value * int64(percent/100) - return value + increase - default: - increase := float64(value) * float64(percent) / 100 - if round { - return value + int64(math.Round(increase)) - } - return value + int64(increase) } + + if value < 0 { + return -IncreaseIntByPercent(-value, percent) + } + + bigValue := big.NewInt(value) + bigPercent := big.NewInt(int64(percent)) + + // product = value * percent + product := new(big.Int).Mul(bigValue, bigPercent) + + // dividing product by 100 + product.Div(product, big.NewInt(100)) + + // result = original value + product + result := new(big.Int).Add(bigValue, product) + + // be mindful if result > MaxInt64 + if result.Cmp(big.NewInt(math.MaxInt64)) > 0 { + return math.MaxInt64 + } + return result.Int64() } diff --git a/pkg/math/integer_test.go b/pkg/math/integer_test.go index 9bcc63e8d5..daf6f966fc 100644 --- a/pkg/math/integer_test.go +++ b/pkg/math/integer_test.go @@ -2,6 +2,7 @@ package math import ( "fmt" + "math" "testing" "github.com/stretchr/testify/assert" @@ -11,19 +12,19 @@ func Test_IncreaseIntByPercent(t *testing.T) { for i, tt := range []struct { value int64 percent uint32 - round bool expected int64 }{ - {value: 10, percent: 0, round: false, expected: 10}, - {value: 10, percent: 15, round: false, expected: 11}, - {value: 10, percent: 15, round: true, expected: 12}, - {value: 10, percent: 14, round: false, expected: 11}, - {value: 10, percent: 14, round: true, expected: 11}, - {value: 10, percent: 200, round: false, expected: 30}, - {value: 10, percent: 200, round: true, expected: 30}, + {value: 10, percent: 0, expected: 10}, + {value: 10, percent: 15, expected: 11}, + {value: 10, percent: 225, expected: 32}, + {value: math.MaxInt64 / 2, percent: 101, expected: math.MaxInt64}, + {value: -10, percent: 0, expected: -10}, + {value: -10, percent: 15, expected: -11}, + {value: -10, percent: 225, expected: -32}, + {value: -math.MaxInt64 / 2, percent: 101, expected: -math.MaxInt64}, } { t.Run(fmt.Sprintf("%d", i), func(t *testing.T) { - result := IncreaseIntByPercent(tt.value, tt.percent, tt.round) + result := IncreaseIntByPercent(tt.value, tt.percent) assert.Equal(t, tt.expected, result) }) } diff --git a/zetaclient/chains/bitcoin/signer/fee_bumper.go b/zetaclient/chains/bitcoin/signer/fee_bumper.go index 762c5948e0..5b8ff6adc7 100644 --- a/zetaclient/chains/bitcoin/signer/fee_bumper.go +++ b/zetaclient/chains/bitcoin/signer/fee_bumper.go @@ -100,7 +100,7 @@ func (b *CPFPFeeBumper) BumpTxFee() (*wire.MsgTx, int64, int64, error) { // tx replacement is triggered only when market fee rate goes 20% higher than current paid rate. // zetacore updates the cctx fee rate evey 10 minutes, we could hold on and retry later. - minBumpRate := mathpkg.IncreaseIntByPercent(b.AvgFeeRate, minCPFPFeeBumpPercent, true) + minBumpRate := mathpkg.IncreaseIntByPercent(b.AvgFeeRate, minCPFPFeeBumpPercent) if b.CCTXRate < minBumpRate { return nil, 0, 0, fmt.Errorf( "hold on RBF: cctx rate %d is lower than the min bumped rate %d", @@ -112,7 +112,7 @@ func (b *CPFPFeeBumper) BumpTxFee() (*wire.MsgTx, int64, int64, error) { // the live rate may continue increasing during network congestion, we should wait until it stabilizes a bit. // this is to ensure the live rate is not 20%+ higher than the cctx rate, otherwise, the replacement tx may // also get stuck and need another replacement. - bumpedRate := mathpkg.IncreaseIntByPercent(b.CCTXRate, minCPFPFeeBumpPercent, true) + bumpedRate := mathpkg.IncreaseIntByPercent(b.CCTXRate, minCPFPFeeBumpPercent) if b.LiveRate > bumpedRate { return nil, 0, 0, fmt.Errorf( "hold on RBF: live rate %d is much higher than the cctx rate %d", From f4d3fc8cedde9b1675680916492c4ea687c93306 Mon Sep 17 00:00:00 2001 From: Charlie Chen Date: Mon, 13 Jan 2025 00:05:09 -0600 Subject: [PATCH 16/20] renamed a few functions to use capitalized CCTX --- e2e/e2etests/test_bitcoin_withdraw_rbf.go | 2 +- e2e/utils/zetacore.go | 4 ++-- x/crosschain/keeper/abci.go | 28 +++++++++++------------ x/crosschain/keeper/abci_test.go | 26 ++++++++++----------- x/crosschain/module.go | 2 +- 5 files changed, 31 insertions(+), 31 deletions(-) diff --git a/e2e/e2etests/test_bitcoin_withdraw_rbf.go b/e2e/e2etests/test_bitcoin_withdraw_rbf.go index ae95958567..c90c0163ef 100644 --- a/e2e/e2etests/test_bitcoin_withdraw_rbf.go +++ b/e2e/e2etests/test_bitcoin_withdraw_rbf.go @@ -32,7 +32,7 @@ func TestBitcoinWithdrawRBF(r *runner.E2ERunner, args []string) { // initiate a withdraw CCTX receipt := approveAndWithdrawBTCZRC20(r, to, amount) - cctx := utils.GetCctxByInboundHash(r.Ctx, receipt.TxHash.Hex(), r.CctxClient) + cctx := utils.GetCCTXByInboundHash(r.Ctx, receipt.TxHash.Hex(), r.CctxClient) // wait for the 1st outbound tracker hash to come in nonce := cctx.GetCurrentOutboundParam().TssNonce diff --git a/e2e/utils/zetacore.go b/e2e/utils/zetacore.go index 41756b6732..2e64d90f7b 100644 --- a/e2e/utils/zetacore.go +++ b/e2e/utils/zetacore.go @@ -29,8 +29,8 @@ const ( DefaultCctxTimeout = 8 * time.Minute ) -// GetCctxByInboundHash gets cctx by inbound hash -func GetCctxByInboundHash( +// GetCCTXByInboundHash gets cctx by inbound hash +func GetCCTXByInboundHash( ctx context.Context, inboundHash string, client crosschaintypes.QueryClient, diff --git a/x/crosschain/keeper/abci.go b/x/crosschain/keeper/abci.go index b5bb5dff01..2bf0b868c2 100644 --- a/x/crosschain/keeper/abci.go +++ b/x/crosschain/keeper/abci.go @@ -19,20 +19,20 @@ const ( RemainingFeesToStabilityPoolPercent = 95 ) -// CheckAndUpdateCctxGasPriceFunc is a function type for checking and updating the gas price of a cctx -type CheckAndUpdateCctxGasPriceFunc func( +// CheckAndUpdateCCTXGasPriceFunc is a function type for checking and updating the gas price of a cctx +type CheckAndUpdateCCTXGasPriceFunc func( ctx sdk.Context, k Keeper, cctx types.CrossChainTx, flags observertypes.GasPriceIncreaseFlags, ) (math.Uint, math.Uint, error) -// IterateAndUpdateCctxGasPrice iterates through all cctx and updates the gas price if pending for too long +// IterateAndUpdateCCTXGasPrice iterates through all cctx and updates the gas price if pending for too long // The function returns the number of cctxs updated and the gas price increase flags used -func (k Keeper) IterateAndUpdateCctxGasPrice( +func (k Keeper) IterateAndUpdateCCTXGasPrice( ctx sdk.Context, chains []zetachains.Chain, - updateFunc CheckAndUpdateCctxGasPriceFunc, + updateFunc CheckAndUpdateCCTXGasPriceFunc, ) (int, observertypes.GasPriceIncreaseFlags) { // fetch the gas price increase flags or use default gasPriceIncreaseFlags := observertypes.DefaultGasPriceIncreaseFlags @@ -58,7 +58,7 @@ IterateChains: // support only external evm chains and bitcoin chain // use provided updateFunc if available, otherwise get updater based on chain type - updater, found := GetCctxGasPriceUpdater(chain.ChainId, additionalChains) + updater, found := GetCCTXGasPriceUpdater(chain.ChainId, additionalChains) if found && updateFunc == nil { updateFunc = updater } @@ -110,9 +110,9 @@ IterateChains: return cctxCount, gasPriceIncreaseFlags } -// CheckAndUpdateCctxGasPriceEVM checks if the retry interval is reached and updates the gas price if so +// CheckAndUpdateCCTXGasPriceEVM checks if the retry interval is reached and updates the gas price if so // The function returns the gas price increase and the additional fees paid from the gas stability pool -func CheckAndUpdateCctxGasPriceEVM( +func CheckAndUpdateCCTXGasPriceEVM( ctx sdk.Context, k Keeper, cctx types.CrossChainTx, @@ -186,9 +186,9 @@ func CheckAndUpdateCctxGasPriceEVM( return gasPriceIncrease, additionalFees, nil } -// CheckAndUpdateCctxGasRateBTC checks if the retry interval is reached and updates the gas rate if so +// CheckAndUpdateCCTXGasRateBTC checks if the retry interval is reached and updates the gas rate if so // Zetacore only needs to update the gas rate in CCTX and fee bumping will be handled by zetaclient -func CheckAndUpdateCctxGasRateBTC( +func CheckAndUpdateCCTXGasRateBTC( ctx sdk.Context, k Keeper, cctx types.CrossChainTx, @@ -223,16 +223,16 @@ func CheckAndUpdateCctxGasRateBTC( return math.ZeroUint(), math.ZeroUint(), nil } -// GetCctxGasPriceUpdater returns the function to update gas price according to chain type -func GetCctxGasPriceUpdater(chainID int64, additionalChains []zetachains.Chain) (CheckAndUpdateCctxGasPriceFunc, bool) { +// GetCCTXGasPriceUpdater returns the function to update gas price according to chain type +func GetCCTXGasPriceUpdater(chainID int64, additionalChains []zetachains.Chain) (CheckAndUpdateCCTXGasPriceFunc, bool) { switch { case zetachains.IsEVMChain(chainID, additionalChains): if !zetachains.IsZetaChain(chainID, additionalChains) { - return CheckAndUpdateCctxGasPriceEVM, true + return CheckAndUpdateCCTXGasPriceEVM, true } return nil, false case zetachains.IsBitcoinChain(chainID, additionalChains): - return CheckAndUpdateCctxGasRateBTC, true + return CheckAndUpdateCCTXGasRateBTC, true default: return nil, false } diff --git a/x/crosschain/keeper/abci_test.go b/x/crosschain/keeper/abci_test.go index 5db8261904..50c20f78e4 100644 --- a/x/crosschain/keeper/abci_test.go +++ b/x/crosschain/keeper/abci_test.go @@ -65,7 +65,7 @@ func TestKeeper_IterateAndUpdateCctxGasPrice(t *testing.T) { // test that the default crosschain flags are used when not set and the epoch length is not reached ctx = ctx.WithBlockHeight(observertypes.DefaultCrosschainFlags().GasPriceIncreaseFlags.EpochLength + 1) - cctxCount, flags := k.IterateAndUpdateCctxGasPrice(ctx, supportedChains, updateFunc) + cctxCount, flags := k.IterateAndUpdateCCTXGasPrice(ctx, supportedChains, updateFunc) require.Equal(t, 0, cctxCount) require.Equal(t, *observertypes.DefaultCrosschainFlags().GasPriceIncreaseFlags, flags) @@ -81,13 +81,13 @@ func TestKeeper_IterateAndUpdateCctxGasPrice(t *testing.T) { crosschainFlags.GasPriceIncreaseFlags = &customFlags zk.ObserverKeeper.SetCrosschainFlags(ctx, *crosschainFlags) - cctxCount, flags = k.IterateAndUpdateCctxGasPrice(ctx, supportedChains, updateFunc) + cctxCount, flags = k.IterateAndUpdateCCTXGasPrice(ctx, supportedChains, updateFunc) require.Equal(t, 0, cctxCount) require.Equal(t, customFlags, flags) // test that cctx are iterated and updated when the epoch length is reached ctx = ctx.WithBlockHeight(observertypes.DefaultCrosschainFlags().GasPriceIncreaseFlags.EpochLength * 2) - cctxCount, flags = k.IterateAndUpdateCctxGasPrice(ctx, supportedChains, updateFunc) + cctxCount, flags = k.IterateAndUpdateCCTXGasPrice(ctx, supportedChains, updateFunc) // 2 eth + 5 btc + 5 bsc = 12 require.Equal(t, 12, cctxCount) @@ -111,7 +111,7 @@ func TestKeeper_IterateAndUpdateCctxGasPrice(t *testing.T) { require.Contains(t, updateFuncMap, sample.GetCctxIndexFromString("56-34")) } -func Test_CheckAndUpdateCctxGasPriceEVM(t *testing.T) { +func Test_CheckAndUpdateCCTXGasPriceEVM(t *testing.T) { sampleTimestamp := time.Now() retryIntervalReached := sampleTimestamp.Add(observertypes.DefaultGasPriceIncreaseFlags.RetryInterval + time.Second) retryIntervalNotReached := sampleTimestamp.Add( @@ -414,7 +414,7 @@ func Test_CheckAndUpdateCctxGasPriceEVM(t *testing.T) { } // check and update gas price - gasPriceIncrease, feesPaid, err := keeper.CheckAndUpdateCctxGasPriceEVM(ctx, *k, tc.cctx, tc.flags) + gasPriceIncrease, feesPaid, err := keeper.CheckAndUpdateCCTXGasPriceEVM(ctx, *k, tc.cctx, tc.flags) if tc.isError { require.Error(t, err) @@ -458,7 +458,7 @@ func Test_CheckAndUpdateCctxGasPriceEVM(t *testing.T) { } } -func Test_CheckAndUpdateCctxGasRateBTC(t *testing.T) { +func Test_CheckAndUpdateCCTXGasRateBTC(t *testing.T) { sampleTimestamp := time.Now() gasRateUpdateInterval := observertypes.DefaultGasPriceIncreaseFlags.RetryInterval retryIntervalReached := sampleTimestamp.Add(gasRateUpdateInterval + time.Second) @@ -597,7 +597,7 @@ func Test_CheckAndUpdateCctxGasRateBTC(t *testing.T) { ctx = ctx.WithBlockTime(tc.blockTimestamp) // check and update gas rate - gasPriceIncrease, feesPaid, err := keeper.CheckAndUpdateCctxGasRateBTC(ctx, *k, tc.cctx, tc.flags) + gasPriceIncrease, feesPaid, err := keeper.CheckAndUpdateCCTXGasRateBTC(ctx, *k, tc.cctx, tc.flags) if tc.isError { require.Error(t, err) return @@ -621,30 +621,30 @@ func Test_CheckAndUpdateCctxGasRateBTC(t *testing.T) { } } -func Test_GetCctxGasPriceUpdater(t *testing.T) { +func Test_GetCCTXGasPriceUpdater(t *testing.T) { tests := []struct { name string chainID int64 found bool - updateFunc keeper.CheckAndUpdateCctxGasPriceFunc + updateFunc keeper.CheckAndUpdateCCTXGasPriceFunc }{ { name: "Ethereum is enabled", chainID: chains.Ethereum.ChainId, found: true, - updateFunc: keeper.CheckAndUpdateCctxGasPriceEVM, + updateFunc: keeper.CheckAndUpdateCCTXGasPriceEVM, }, { name: "Binance Smart Chain is enabled", chainID: chains.BscMainnet.ChainId, found: true, - updateFunc: keeper.CheckAndUpdateCctxGasPriceEVM, + updateFunc: keeper.CheckAndUpdateCCTXGasPriceEVM, }, { name: "Bitcoin is enabled", chainID: chains.BitcoinMainnet.ChainId, found: true, - updateFunc: keeper.CheckAndUpdateCctxGasRateBTC, + updateFunc: keeper.CheckAndUpdateCCTXGasRateBTC, }, { name: "ZetaChain is not enabled", @@ -668,7 +668,7 @@ func Test_GetCctxGasPriceUpdater(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - updateFunc, found := keeper.GetCctxGasPriceUpdater(tt.chainID, []zetachains.Chain{}) + updateFunc, found := keeper.GetCCTXGasPriceUpdater(tt.chainID, []zetachains.Chain{}) require.Equal(t, tt.found, found) require.Equal(t, reflect.ValueOf(tt.updateFunc).Pointer(), reflect.ValueOf(updateFunc).Pointer()) }) diff --git a/x/crosschain/module.go b/x/crosschain/module.go index 743043c190..3b2c00a630 100644 --- a/x/crosschain/module.go +++ b/x/crosschain/module.go @@ -172,7 +172,7 @@ func (am AppModule) BeginBlock(ctx sdk.Context, _ abci.RequestBeginBlock) { // iterate and update gas price for cctx that are pending for too long // error is logged in the function - am.keeper.IterateAndUpdateCctxGasPrice(ctx, supportedChains, nil) + am.keeper.IterateAndUpdateCCTXGasPrice(ctx, supportedChains, nil) } // EndBlock executes all ABCI EndBlock logic respective to the crosschain module. It From 0b954d0c201858fe95c3d25289d24e04f2723912 Mon Sep 17 00:00:00 2001 From: Charlie Chen Date: Mon, 13 Jan 2025 12:58:48 -0600 Subject: [PATCH 17/20] unify log fields and add Base chain URL for live test --- zetaclient/chains/evm/rpc/rpc_live_test.go | 12 ++++++++++++ zetaclient/chains/evm/signer/signer.go | 5 ++--- zetaclient/zetacore/broadcast.go | 10 ++++++---- zetaclient/zetacore/broadcast_test.go | 2 +- 4 files changed, 21 insertions(+), 8 deletions(-) diff --git a/zetaclient/chains/evm/rpc/rpc_live_test.go b/zetaclient/chains/evm/rpc/rpc_live_test.go index ec99fe6ebd..04d70e33b1 100644 --- a/zetaclient/chains/evm/rpc/rpc_live_test.go +++ b/zetaclient/chains/evm/rpc/rpc_live_test.go @@ -3,6 +3,7 @@ package rpc_test import ( "context" "math" + "math/big" "github.com/ethereum/go-ethereum/ethclient" "github.com/stretchr/testify/require" @@ -17,6 +18,7 @@ const ( URLEthSepolia = "https://rpc.ankr.com/eth_sepolia" URLBscMainnet = "https://rpc.ankr.com/bsc" URLPolygonMainnet = "https://rpc.ankr.com/polygon" + URLBaseMainnet = "https://rpc.ankr.com/base" ) // Test_EVMRPCLive is a phony test to run each live test individually @@ -58,3 +60,13 @@ func LiveTest_CheckRPCStatus(t *testing.T) { _, err = rpc.CheckRPCStatus(ctx, client) require.NoError(t, err) } + +func LiveTest_SuggestGasPrice(t *testing.T) { + client, err := ethclient.Dial(URLBaseMainnet) + require.NoError(t, err) + + ctx := context.Background() + gasPrice, err := client.SuggestGasPrice(ctx) + require.NoError(t, err) + require.True(t, gasPrice.Cmp(big.NewInt(0)) > 0) +} diff --git a/zetaclient/chains/evm/signer/signer.go b/zetaclient/chains/evm/signer/signer.go index 4a6c194b21..98ef49214e 100644 --- a/zetaclient/chains/evm/signer/signer.go +++ b/zetaclient/chains/evm/signer/signer.go @@ -6,7 +6,6 @@ import ( "encoding/hex" "fmt" "math/big" - "strconv" "strings" "time" @@ -520,8 +519,8 @@ func (signer *Signer) BroadcastOutbound( outboundHash, toChain.ID(), cctx.GetCurrentOutboundParam().TssNonce, i, myID) retry, report := zetacore.HandleBroadcastError( err, - strconv.FormatUint(cctx.GetCurrentOutboundParam().TssNonce, 10), - fmt.Sprintf("%d", toChain.ID()), + cctx.GetCurrentOutboundParam().TssNonce, + toChain.ID(), outboundHash, ) if report { diff --git a/zetaclient/zetacore/broadcast.go b/zetaclient/zetacore/broadcast.go index 26480df638..498308622a 100644 --- a/zetaclient/zetacore/broadcast.go +++ b/zetaclient/zetacore/broadcast.go @@ -19,6 +19,7 @@ import ( "github.com/zeta-chain/node/app/ante" "github.com/zeta-chain/node/cmd/zetacored/config" "github.com/zeta-chain/node/zetaclient/authz" + "github.com/zeta-chain/node/zetaclient/logs" ) // paying 50% more than the current base gas price to buffer for potential block-by-block @@ -158,16 +159,17 @@ func (c *Client) QueryTxResult(hash string) (*sdktypes.TxResponse, error) { // HandleBroadcastError returns whether to retry in a few seconds, and whether to report via AddOutboundTracker // returns (bool retry, bool report) -func HandleBroadcastError(err error, nonce, toChain, outboundHash string) (bool, bool) { +func HandleBroadcastError(err error, nonce uint64, toChain int64, outboundHash string) (bool, bool) { if err == nil { return false, false } msg := err.Error() evt := log.Warn().Err(err). - Str("broadcast.nonce", nonce). - Str("broadcast.to_chain", toChain). - Str("broadcast.outbound_hash", outboundHash) + Str(logs.FieldMethod, "HandleBroadcastError"). + Int64(logs.FieldChain, toChain). + Uint64(logs.FieldNonce, nonce). + Str(logs.FieldTx, outboundHash) switch { case strings.Contains(msg, "nonce too low"): diff --git a/zetaclient/zetacore/broadcast_test.go b/zetaclient/zetacore/broadcast_test.go index 3fb5093963..56607ee585 100644 --- a/zetaclient/zetacore/broadcast_test.go +++ b/zetaclient/zetacore/broadcast_test.go @@ -31,7 +31,7 @@ func TestHandleBroadcastError(t *testing.T) { errors.New(""): {retry: true, report: false}, } for input, output := range testCases { - retry, report := HandleBroadcastError(input, "", "", "") + retry, report := HandleBroadcastError(input, 100, 1, "") require.Equal(t, output.report, report) require.Equal(t, output.retry, retry) } From 24d12ae23bb5cd37092394a76b2ef55a8aadce94 Mon Sep 17 00:00:00 2001 From: Charlie Chen Date: Mon, 13 Jan 2025 18:51:09 -0600 Subject: [PATCH 18/20] use CheckAndUpdateCCTXGasPrice to dispatch evm/btc gas price updating; resolve code rabbit comments --- pkg/math/integer.go | 5 + x/crosschain/keeper/abci.go | 148 +++--- x/crosschain/keeper/abci_test.go | 446 +++++++++--------- x/crosschain/module.go | 2 +- zetaclient/chains/bitcoin/rpc/rpc.go | 7 + .../chains/bitcoin/rpc/rpc_rbf_live_test.go | 121 ++--- .../chains/bitcoin/signer/fee_bumper.go | 25 +- .../chains/bitcoin/signer/fee_bumper_test.go | 35 +- .../chains/bitcoin/signer/outbound_data.go | 28 +- .../bitcoin/signer/outbound_data_test.go | 93 ++-- zetaclient/chains/bitcoin/signer/sign.go | 15 +- zetaclient/chains/bitcoin/signer/sign_rbf.go | 2 +- zetaclient/chains/bitcoin/signer/sign_test.go | 168 +++---- 13 files changed, 537 insertions(+), 558 deletions(-) diff --git a/pkg/math/integer.go b/pkg/math/integer.go index 7753d6c168..8d74b5dc27 100644 --- a/pkg/math/integer.go +++ b/pkg/math/integer.go @@ -1,3 +1,4 @@ +// Package implements helper functions for integer math operations. package math import ( @@ -6,6 +7,10 @@ import ( ) // IncreaseIntByPercent is a function that increases integer by a percentage. +// Example1: IncreaseIntByPercent(10, 15) = 10 * 1.15 = 11 +// Example2: IncreaseIntByPercent(-10, 15) = -10 * 1.15 = -11 +// +// Note: use with caution if passing negative values. func IncreaseIntByPercent(value int64, percent uint32) int64 { if percent == 0 { return value diff --git a/x/crosschain/keeper/abci.go b/x/crosschain/keeper/abci.go index 2bf0b868c2..2dea64b629 100644 --- a/x/crosschain/keeper/abci.go +++ b/x/crosschain/keeper/abci.go @@ -56,52 +56,43 @@ IterateChains: continue } - // support only external evm chains and bitcoin chain - // use provided updateFunc if available, otherwise get updater based on chain type - updater, found := GetCCTXGasPriceUpdater(chain.ChainId, additionalChains) - if found && updateFunc == nil { - updateFunc = updater + res, err := k.ListPendingCctx(sdk.UnwrapSDKContext(ctx), &types.QueryListPendingCctxRequest{ + ChainId: chain.ChainId, + Limit: gasPriceIncreaseFlags.MaxPendingCctxs, + }) + if err != nil { + ctx.Logger().Info("GasStabilityPool: fetching pending cctx failed", + "chainID", chain.ChainId, + "err", err.Error(), + ) + continue IterateChains } - if updateFunc != nil { - res, err := k.ListPendingCctx(sdk.UnwrapSDKContext(ctx), &types.QueryListPendingCctxRequest{ - ChainId: chain.ChainId, - Limit: gasPriceIncreaseFlags.MaxPendingCctxs, - }) - if err != nil { - ctx.Logger().Info("GasStabilityPool: fetching pending cctx failed", - "chainID", chain.ChainId, - "err", err.Error(), - ) - continue IterateChains - } - - // iterate through all pending cctx - for _, pendingCctx := range res.CrossChainTx { - if pendingCctx != nil { - gasPriceIncrease, additionalFees, err := updateFunc(ctx, k, *pendingCctx, gasPriceIncreaseFlags) - if err != nil { - ctx.Logger().Info("GasStabilityPool: updating gas price for pending cctx failed", - "cctxIndex", pendingCctx.Index, + // iterate through all pending cctx + for _, pendingCctx := range res.CrossChainTx { + if pendingCctx != nil { + gasPriceIncrease, additionalFees, err := updateFunc(ctx, k, *pendingCctx, gasPriceIncreaseFlags) + if err != nil { + ctx.Logger().Info("GasStabilityPool: updating gas price for pending cctx failed", + "cctxIndex", pendingCctx.Index, + "err", err.Error(), + ) + continue IterateChains + } + if !gasPriceIncrease.IsNil() && !gasPriceIncrease.IsZero() { + // Emit typed event for gas price increase + if err := ctx.EventManager().EmitTypedEvent( + &types.EventCCTXGasPriceIncreased{ + CctxIndex: pendingCctx.Index, + GasPriceIncrease: gasPriceIncrease.String(), + AdditionalFees: additionalFees.String(), + }); err != nil { + ctx.Logger().Error( + "GasStabilityPool: failed to emit EventCCTXGasPriceIncreased", "err", err.Error(), ) - continue IterateChains - } - if !gasPriceIncrease.IsNil() && !gasPriceIncrease.IsZero() { - // Emit typed event for gas price increase - if err := ctx.EventManager().EmitTypedEvent( - &types.EventCCTXGasPriceIncreased{ - CctxIndex: pendingCctx.Index, - GasPriceIncrease: gasPriceIncrease.String(), - AdditionalFees: additionalFees.String(), - }); err != nil { - ctx.Logger().Error( - "GasStabilityPool: failed to emit EventCCTXGasPriceIncreased", - "err", err.Error(), - ) - } - cctxCount++ } + cctxCount++ } } } @@ -110,9 +101,9 @@ IterateChains: return cctxCount, gasPriceIncreaseFlags } -// CheckAndUpdateCCTXGasPriceEVM checks if the retry interval is reached and updates the gas price if so +// CheckAndUpdateCctxGasPrice checks if the retry interval is reached and updates the gas price if so // The function returns the gas price increase and the additional fees paid from the gas stability pool -func CheckAndUpdateCCTXGasPriceEVM( +func CheckAndUpdateCCTXGasPrice( ctx sdk.Context, k Keeper, cctx types.CrossChainTx, @@ -138,6 +129,30 @@ func CheckAndUpdateCCTXGasPriceEVM( fmt.Sprintf("cannot get gas price for chain %d", chainID), ) } + + // dispatch to chain-specific gas price update function + additionalChains := k.GetAuthorityKeeper().GetAdditionalChainList(ctx) + switch { + case zetachains.IsEVMChain(chainID, additionalChains): + return CheckAndUpdateCCTXGasPriceEVM(ctx, k, medianGasPrice, medianPriorityFee, cctx, flags) + case zetachains.IsBitcoinChain(chainID, additionalChains): + return CheckAndUpdateCCTXGasPriceBTC(ctx, k, medianGasPrice, cctx) + default: + return math.ZeroUint(), math.ZeroUint(), nil + } +} + +// CheckAndUpdateCCTXGasPriceEVM updates the gas price for the given EVM chain CCTX +func CheckAndUpdateCCTXGasPriceEVM( + ctx sdk.Context, + k Keeper, + medianGasPrice math.Uint, + medianPriorityFee math.Uint, + cctx types.CrossChainTx, + flags observertypes.GasPriceIncreaseFlags, +) (math.Uint, math.Uint, error) { + // compute gas price increase + chainID := cctx.GetCurrentOutboundParam().ReceiverChainId gasPriceIncrease := medianGasPrice.MulUint64(uint64(flags.GasPriceIncreasePercent)).QuoUint64(100) // compute new gas price @@ -186,54 +201,17 @@ func CheckAndUpdateCCTXGasPriceEVM( return gasPriceIncrease, additionalFees, nil } -// CheckAndUpdateCCTXGasRateBTC checks if the retry interval is reached and updates the gas rate if so -// Zetacore only needs to update the gas rate in CCTX and fee bumping will be handled by zetaclient -func CheckAndUpdateCCTXGasRateBTC( +// CheckAndUpdateCCTXGasPriceBTC updates the fee rate for the given Bitcoin chain CCTX +func CheckAndUpdateCCTXGasPriceBTC( ctx sdk.Context, k Keeper, + medianGasPrice math.Uint, cctx types.CrossChainTx, - flags observertypes.GasPriceIncreaseFlags, ) (math.Uint, math.Uint, error) { - // skip if gas price or gas limit is not set - if cctx.GetCurrentOutboundParam().GasPrice == "" || cctx.GetCurrentOutboundParam().CallOptions.GasLimit == 0 { - return math.ZeroUint(), math.ZeroUint(), nil - } - - // skip if retry interval is not reached - lastUpdated := time.Unix(cctx.CctxStatus.LastUpdateTimestamp, 0) - if ctx.BlockTime().Before(lastUpdated.Add(flags.RetryInterval)) { - return math.ZeroUint(), math.ZeroUint(), nil - } - - // compute gas price increase - chainID := cctx.GetCurrentOutboundParam().ReceiverChainId - medianGasPrice, _, isFound := k.GetMedianGasValues(ctx, chainID) - if !isFound { - return math.ZeroUint(), math.ZeroUint(), cosmoserrors.Wrap( - types.ErrUnableToGetGasPrice, - fmt.Sprintf("cannot get gas price for chain %d", chainID), - ) - } - - // set new gas rate and last update timestamp - // there is no priority fee in Bitcoin, we reuse 'GasPriorityFee' to store latest gas rate in satoshi/vByte + // zetacore simply update 'GasPriorityFee', and zetaclient will use it to schedule RBF tx + // there is no priority fee in Bitcoin, the 'GasPriorityFee' is repurposed to store latest fee rate in sat/vB cctx.GetCurrentOutboundParam().GasPriorityFee = medianGasPrice.String() k.SetCrossChainTx(ctx, cctx) return math.ZeroUint(), math.ZeroUint(), nil } - -// GetCCTXGasPriceUpdater returns the function to update gas price according to chain type -func GetCCTXGasPriceUpdater(chainID int64, additionalChains []zetachains.Chain) (CheckAndUpdateCCTXGasPriceFunc, bool) { - switch { - case zetachains.IsEVMChain(chainID, additionalChains): - if !zetachains.IsZetaChain(chainID, additionalChains) { - return CheckAndUpdateCCTXGasPriceEVM, true - } - return nil, false - case zetachains.IsBitcoinChain(chainID, additionalChains): - return CheckAndUpdateCCTXGasRateBTC, true - default: - return nil, false - } -} diff --git a/x/crosschain/keeper/abci_test.go b/x/crosschain/keeper/abci_test.go index 50c20f78e4..c09817aeb9 100644 --- a/x/crosschain/keeper/abci_test.go +++ b/x/crosschain/keeper/abci_test.go @@ -2,7 +2,6 @@ package keeper_test import ( "errors" - "reflect" "testing" "time" @@ -11,7 +10,6 @@ import ( "github.com/stretchr/testify/require" "github.com/zeta-chain/node/pkg/chains" - zetachains "github.com/zeta-chain/node/pkg/chains" testkeeper "github.com/zeta-chain/node/testutil/keeper" "github.com/zeta-chain/node/testutil/sample" "github.com/zeta-chain/node/x/crosschain/keeper" @@ -111,7 +109,7 @@ func TestKeeper_IterateAndUpdateCctxGasPrice(t *testing.T) { require.Contains(t, updateFuncMap, sample.GetCctxIndexFromString("56-34")) } -func Test_CheckAndUpdateCCTXGasPriceEVM(t *testing.T) { +func Test_CheckAndUpdateCCTXGasPrice(t *testing.T) { sampleTimestamp := time.Now() retryIntervalReached := sampleTimestamp.Add(observertypes.DefaultGasPriceIncreaseFlags.RetryInterval + time.Second) retryIntervalNotReached := sampleTimestamp.Add( @@ -141,7 +139,7 @@ func Test_CheckAndUpdateCCTXGasPriceEVM(t *testing.T) { }, OutboundParams: []*types.OutboundParams{ { - ReceiverChainId: 42, + ReceiverChainId: 1, CallOptions: &types.CallOptions{ GasLimit: 1000, }, @@ -158,98 +156,6 @@ func Test_CheckAndUpdateCCTXGasPriceEVM(t *testing.T) { expectedGasPriceIncrease: math.NewUint(50), // 100% medianGasPrice expectedAdditionalFees: math.NewUint(50000), // gasLimit * increase }, - { - name: "can update gas price at max limit", - cctx: types.CrossChainTx{ - Index: "a2", - CctxStatus: &types.Status{ - CreatedTimestamp: sampleTimestamp.Unix(), - LastUpdateTimestamp: sampleTimestamp.Unix(), - }, - OutboundParams: []*types.OutboundParams{ - { - ReceiverChainId: 42, - CallOptions: &types.CallOptions{ - GasLimit: 1000, - }, - GasPrice: "100", - }, - }, - }, - flags: observertypes.GasPriceIncreaseFlags{ - EpochLength: 100, - RetryInterval: time.Minute * 10, - GasPriceIncreasePercent: 200, // Increase gas price to 100+50*2 = 200 - GasPriceIncreaseMax: 400, // Max gas price is 50*4 = 200 - }, - blockTimestamp: retryIntervalReached, - medianGasPrice: 50, - withdrawFromGasStabilityPoolReturn: nil, - expectWithdrawFromGasStabilityPoolCall: true, - expectedGasPriceIncrease: math.NewUint(100), // 200% medianGasPrice - expectedAdditionalFees: math.NewUint(100000), // gasLimit * increase - }, - { - name: "default gas price increase limit used if not defined", - cctx: types.CrossChainTx{ - Index: "a3", - CctxStatus: &types.Status{ - CreatedTimestamp: sampleTimestamp.Unix(), - LastUpdateTimestamp: sampleTimestamp.Unix(), - }, - OutboundParams: []*types.OutboundParams{ - { - ReceiverChainId: 42, - CallOptions: &types.CallOptions{ - GasLimit: 1000, - }, - GasPrice: "100", - }, - }, - }, - flags: observertypes.GasPriceIncreaseFlags{ - EpochLength: 100, - RetryInterval: time.Minute * 10, - GasPriceIncreasePercent: 100, - GasPriceIncreaseMax: 0, // Limit should not be reached - }, - blockTimestamp: retryIntervalReached, - medianGasPrice: 50, - withdrawFromGasStabilityPoolReturn: nil, - expectWithdrawFromGasStabilityPoolCall: true, - expectedGasPriceIncrease: math.NewUint(50), // 100% medianGasPrice - expectedAdditionalFees: math.NewUint(50000), // gasLimit * increase - }, - { - name: "skip if max limit reached", - cctx: types.CrossChainTx{ - Index: "b0", - CctxStatus: &types.Status{ - CreatedTimestamp: sampleTimestamp.Unix(), - LastUpdateTimestamp: sampleTimestamp.Unix(), - }, - OutboundParams: []*types.OutboundParams{ - { - ReceiverChainId: 42, - CallOptions: &types.CallOptions{ - GasLimit: 1000, - }, - GasPrice: "100", - }, - }, - }, - flags: observertypes.GasPriceIncreaseFlags{ - EpochLength: 100, - RetryInterval: time.Minute * 10, - GasPriceIncreasePercent: 200, // Increase gas price to 100+50*2 = 200 - GasPriceIncreaseMax: 300, // Max gas price is 50*3 = 150 - }, - blockTimestamp: retryIntervalReached, - medianGasPrice: 50, - expectWithdrawFromGasStabilityPoolCall: false, - expectedGasPriceIncrease: math.NewUint(0), - expectedAdditionalFees: math.NewUint(0), - }, { name: "skip if gas price is not set", cctx: types.CrossChainTx{ @@ -328,7 +234,7 @@ func Test_CheckAndUpdateCCTXGasPriceEVM(t *testing.T) { { name: "returns error if can't find median gas price", cctx: types.CrossChainTx{ - Index: "c1", + Index: "b4", CctxStatus: &types.Status{ CreatedTimestamp: sampleTimestamp.Unix(), LastUpdateTimestamp: sampleTimestamp.Unix(), @@ -350,16 +256,16 @@ func Test_CheckAndUpdateCCTXGasPriceEVM(t *testing.T) { isError: true, }, { - name: "returns error if can't withdraw from gas stability pool", + name: "do nothing for non-EVM, non-BTC chain", cctx: types.CrossChainTx{ - Index: "c2", + Index: "c", CctxStatus: &types.Status{ CreatedTimestamp: sampleTimestamp.Unix(), LastUpdateTimestamp: sampleTimestamp.Unix(), }, OutboundParams: []*types.OutboundParams{ { - ReceiverChainId: 42, + ReceiverChainId: 100, CallOptions: &types.CallOptions{ GasLimit: 1000, }, @@ -367,14 +273,13 @@ func Test_CheckAndUpdateCCTXGasPriceEVM(t *testing.T) { }, }, }, - flags: observertypes.DefaultGasPriceIncreaseFlags, - blockTimestamp: retryIntervalReached, - medianGasPrice: 50, - expectWithdrawFromGasStabilityPoolCall: true, - expectedGasPriceIncrease: math.NewUint(50), // 100% medianGasPrice - expectedAdditionalFees: math.NewUint(50000), // gasLimit * increase - withdrawFromGasStabilityPoolReturn: errors.New("withdraw error"), - isError: true, + flags: observertypes.DefaultGasPriceIncreaseFlags, + blockTimestamp: retryIntervalReached, + medianGasPrice: 50, + medianPriorityFee: 20, + withdrawFromGasStabilityPoolReturn: nil, + expectedGasPriceIncrease: math.NewUint(0), + expectedAdditionalFees: math.NewUint(0), }, } for _, tc := range tt { @@ -382,6 +287,7 @@ func Test_CheckAndUpdateCCTXGasPriceEVM(t *testing.T) { t.Run(tc.name, func(t *testing.T) { k, ctx := testkeeper.CrosschainKeeperAllMocks(t) fungibleMock := testkeeper.GetCrosschainFungibleMock(t, k) + authorityMock := testkeeper.GetCrosschainAuthorityMock(t, k) chainID := tc.cctx.GetCurrentOutboundParam().ReceiverChainId previousGasPrice, err := tc.cctx.GetCurrentOutboundParam().GetGasPriceUInt64() if err != nil { @@ -407,6 +313,8 @@ func Test_CheckAndUpdateCCTXGasPriceEVM(t *testing.T) { // set block timestamp ctx = ctx.WithBlockTime(tc.blockTimestamp) + authorityMock.On("GetAdditionalChainList", ctx).Maybe().Return([]chains.Chain{}) + if tc.expectWithdrawFromGasStabilityPoolCall { fungibleMock.On( "WithdrawFromGasStabilityPool", ctx, chainID, tc.expectedAdditionalFees.BigInt(), @@ -414,7 +322,7 @@ func Test_CheckAndUpdateCCTXGasPriceEVM(t *testing.T) { } // check and update gas price - gasPriceIncrease, feesPaid, err := keeper.CheckAndUpdateCCTXGasPriceEVM(ctx, *k, tc.cctx, tc.flags) + gasPriceIncrease, feesPaid, err := keeper.CheckAndUpdateCCTXGasPrice(ctx, *k, tc.cctx, tc.flags) if tc.isError { require.Error(t, err) @@ -458,146 +366,204 @@ func Test_CheckAndUpdateCCTXGasPriceEVM(t *testing.T) { } } -func Test_CheckAndUpdateCCTXGasRateBTC(t *testing.T) { +func Test_CheckAndUpdateCCTXGasPriceEVM(t *testing.T) { sampleTimestamp := time.Now() - gasRateUpdateInterval := observertypes.DefaultGasPriceIncreaseFlags.RetryInterval - retryIntervalReached := sampleTimestamp.Add(gasRateUpdateInterval + time.Second) - retryIntervalNotReached := sampleTimestamp.Add(gasRateUpdateInterval - time.Second) + retryIntervalReached := sampleTimestamp.Add(observertypes.DefaultGasPriceIncreaseFlags.RetryInterval + time.Second) tt := []struct { - name string - cctx types.CrossChainTx - flags observertypes.GasPriceIncreaseFlags - blockTimestamp time.Time - medianGasPrice uint64 - medianPriorityFee uint64 - shouldUpdate bool - isError bool + name string + cctx types.CrossChainTx + flags observertypes.GasPriceIncreaseFlags + blockTimestamp time.Time + medianGasPrice uint64 + medianPriorityFee uint64 + withdrawFromGasStabilityPoolReturn error + expectWithdrawFromGasStabilityPoolCall bool + expectedGasPriceIncrease math.Uint + expectedAdditionalFees math.Uint + isError bool }{ { - name: "can update gas rate when retry interval is reached", + name: "can update gas price", cctx: types.CrossChainTx{ - Index: "a", + Index: "a1", CctxStatus: &types.Status{ CreatedTimestamp: sampleTimestamp.Unix(), LastUpdateTimestamp: sampleTimestamp.Unix(), }, OutboundParams: []*types.OutboundParams{ { - ReceiverChainId: 8332, + ReceiverChainId: 1, CallOptions: &types.CallOptions{ - GasLimit: 254, + GasLimit: 1000, }, - GasPrice: "10", + GasPrice: "100", }, }, }, - flags: observertypes.DefaultGasPriceIncreaseFlags, - blockTimestamp: retryIntervalReached, - medianGasPrice: 12, - shouldUpdate: true, + flags: observertypes.DefaultGasPriceIncreaseFlags, + blockTimestamp: retryIntervalReached, + medianGasPrice: 50, + medianPriorityFee: 20, + withdrawFromGasStabilityPoolReturn: nil, + expectWithdrawFromGasStabilityPoolCall: true, + expectedGasPriceIncrease: math.NewUint(50), // 100% medianGasPrice + expectedAdditionalFees: math.NewUint(50000), // gasLimit * increase }, { - name: "skip if gas price is not set", + name: "can update gas price at max limit", cctx: types.CrossChainTx{ - Index: "b1", + Index: "a2", + CctxStatus: &types.Status{ + CreatedTimestamp: sampleTimestamp.Unix(), + LastUpdateTimestamp: sampleTimestamp.Unix(), + }, OutboundParams: []*types.OutboundParams{ { - GasPrice: "", + ReceiverChainId: 1, + CallOptions: &types.CallOptions{ + GasLimit: 1000, + }, + GasPrice: "100", }, }, }, - flags: observertypes.DefaultGasPriceIncreaseFlags, - blockTimestamp: retryIntervalReached, - medianGasPrice: 12, + flags: observertypes.GasPriceIncreaseFlags{ + EpochLength: 100, + RetryInterval: time.Minute * 10, + GasPriceIncreasePercent: 200, // Increase gas price to 100+50*2 = 200 + GasPriceIncreaseMax: 400, // Max gas price is 50*4 = 200 + }, + blockTimestamp: retryIntervalReached, + medianGasPrice: 50, + medianPriorityFee: 20, + withdrawFromGasStabilityPoolReturn: nil, + expectWithdrawFromGasStabilityPoolCall: true, + expectedGasPriceIncrease: math.NewUint(100), // 200% medianGasPrice + expectedAdditionalFees: math.NewUint(100000), // gasLimit * increase }, { - name: "skip if gas limit is not set", + name: "default gas price increase limit used if not defined", cctx: types.CrossChainTx{ - Index: "b2", + Index: "a3", + CctxStatus: &types.Status{ + CreatedTimestamp: sampleTimestamp.Unix(), + LastUpdateTimestamp: sampleTimestamp.Unix(), + }, OutboundParams: []*types.OutboundParams{ { + ReceiverChainId: 1, CallOptions: &types.CallOptions{ - GasLimit: 0, + GasLimit: 1000, }, - GasPrice: "10", + GasPrice: "100", }, }, }, - flags: observertypes.DefaultGasPriceIncreaseFlags, - blockTimestamp: retryIntervalReached, - medianGasPrice: 12, + flags: observertypes.GasPriceIncreaseFlags{ + EpochLength: 100, + RetryInterval: time.Minute * 10, + GasPriceIncreasePercent: 100, + GasPriceIncreaseMax: 0, // Limit should not be reached + }, + blockTimestamp: retryIntervalReached, + medianGasPrice: 50, + medianPriorityFee: 20, + withdrawFromGasStabilityPoolReturn: nil, + expectWithdrawFromGasStabilityPoolCall: true, + expectedGasPriceIncrease: math.NewUint(50), // 100% medianGasPrice + expectedAdditionalFees: math.NewUint(50000), // gasLimit * increase }, { - name: "skip if retry interval is not reached", + name: "skip if max limit reached", cctx: types.CrossChainTx{ - Index: "b3", + Index: "b", CctxStatus: &types.Status{ CreatedTimestamp: sampleTimestamp.Unix(), LastUpdateTimestamp: sampleTimestamp.Unix(), }, OutboundParams: []*types.OutboundParams{ { + ReceiverChainId: 1, CallOptions: &types.CallOptions{ - GasLimit: 254, + GasLimit: 1000, }, - GasPrice: "10", + GasPrice: "100", }, }, }, - flags: observertypes.DefaultGasPriceIncreaseFlags, - blockTimestamp: retryIntervalNotReached, - medianGasPrice: 12, + flags: observertypes.GasPriceIncreaseFlags{ + EpochLength: 100, + RetryInterval: time.Minute * 10, + GasPriceIncreasePercent: 200, // Increase gas price to 100+50*2 = 200 + GasPriceIncreaseMax: 300, // Max gas price is 50*3 = 150 + }, + blockTimestamp: retryIntervalReached, + medianGasPrice: 50, + medianPriorityFee: 20, + expectWithdrawFromGasStabilityPoolCall: false, + expectedGasPriceIncrease: math.NewUint(0), + expectedAdditionalFees: math.NewUint(0), }, { - name: "returns error if can't find median gas price", + name: "returns error if can't withdraw from gas stability pool", cctx: types.CrossChainTx{ - Index: "b4", + Index: "c", CctxStatus: &types.Status{ CreatedTimestamp: sampleTimestamp.Unix(), LastUpdateTimestamp: sampleTimestamp.Unix(), }, OutboundParams: []*types.OutboundParams{ { - ReceiverChainId: 8332, + ReceiverChainId: 1, CallOptions: &types.CallOptions{ - GasLimit: 254, + GasLimit: 1000, }, - GasPrice: "10", + GasPrice: "100", }, }, }, - flags: observertypes.DefaultGasPriceIncreaseFlags, - blockTimestamp: retryIntervalReached, - medianGasPrice: 0, - isError: true, + flags: observertypes.DefaultGasPriceIncreaseFlags, + blockTimestamp: retryIntervalReached, + medianGasPrice: 50, + medianPriorityFee: 20, + expectWithdrawFromGasStabilityPoolCall: true, + expectedGasPriceIncrease: math.NewUint(50), // 100% medianGasPrice + expectedAdditionalFees: math.NewUint(50000), // gasLimit * increase + withdrawFromGasStabilityPoolReturn: errors.New("withdraw error"), + isError: true, }, } for _, tc := range tt { tc := tc t.Run(tc.name, func(t *testing.T) { k, ctx := testkeeper.CrosschainKeeperAllMocks(t) + fungibleMock := testkeeper.GetCrosschainFungibleMock(t, k) chainID := tc.cctx.GetCurrentOutboundParam().ReceiverChainId - - // set median gas price if not zero - if tc.medianGasPrice != 0 { - k.SetGasPrice(ctx, types.GasPrice{ - ChainId: chainID, - Prices: []uint64{tc.medianGasPrice}, - MedianIndex: 0, - }) - - // ensure median gas price is set - medianGasPrice, _, isFound := k.GetMedianGasValues(ctx, chainID) - require.True(t, isFound) - require.True(t, medianGasPrice.Equal(math.NewUint(tc.medianGasPrice))) + previousGasPrice, err := tc.cctx.GetCurrentOutboundParam().GetGasPriceUInt64() + if err != nil { + previousGasPrice = 0 } // set block timestamp ctx = ctx.WithBlockTime(tc.blockTimestamp) - // check and update gas rate - gasPriceIncrease, feesPaid, err := keeper.CheckAndUpdateCCTXGasRateBTC(ctx, *k, tc.cctx, tc.flags) + if tc.expectWithdrawFromGasStabilityPoolCall { + fungibleMock.On( + "WithdrawFromGasStabilityPool", ctx, chainID, tc.expectedAdditionalFees.BigInt(), + ).Return(tc.withdrawFromGasStabilityPoolReturn) + } + + // check and update gas price + gasPriceIncrease, feesPaid, err := keeper.CheckAndUpdateCCTXGasPriceEVM( + ctx, + *k, + math.NewUint(tc.medianGasPrice), + math.NewUint(tc.medianPriorityFee), + tc.cctx, + tc.flags, + ) + if tc.isError { require.Error(t, err) return @@ -605,72 +571,102 @@ func Test_CheckAndUpdateCCTXGasRateBTC(t *testing.T) { require.NoError(t, err) // check values - require.True(t, gasPriceIncrease.IsZero()) - require.True(t, feesPaid.IsZero()) + require.True( + t, + gasPriceIncrease.Equal(tc.expectedGasPriceIncrease), + "expected %s, got %s", + tc.expectedGasPriceIncrease.String(), + gasPriceIncrease.String(), + ) + require.True( + t, + feesPaid.Equal(tc.expectedAdditionalFees), + "expected %s, got %s", + tc.expectedAdditionalFees.String(), + feesPaid.String(), + ) - // check cctx if gas rate is updated - if tc.shouldUpdate { + // check cctx + if !tc.expectedGasPriceIncrease.IsZero() { cctx, found := k.GetCrossChainTx(ctx, tc.cctx.Index) require.True(t, found) - newGasPrice, err := cctx.GetCurrentOutboundParam().GetGasPriorityFeeUInt64() + newGasPrice, err := cctx.GetCurrentOutboundParam().GetGasPriceUInt64() require.NoError(t, err) - require.Equal(t, tc.medianGasPrice, newGasPrice) + require.EqualValues( + t, + tc.expectedGasPriceIncrease.AddUint64(previousGasPrice).Uint64(), + newGasPrice, + "%d - %d", + tc.expectedGasPriceIncrease.Uint64(), + previousGasPrice, + ) require.EqualValues(t, tc.blockTimestamp.Unix(), cctx.CctxStatus.LastUpdateTimestamp) } }) } } -func Test_GetCCTXGasPriceUpdater(t *testing.T) { - tests := []struct { - name string - chainID int64 - found bool - updateFunc keeper.CheckAndUpdateCCTXGasPriceFunc +func Test_CheckAndUpdateCCTXGasPriceBTC(t *testing.T) { + sampleTimestamp := time.Now() + gasRateUpdateInterval := observertypes.DefaultGasPriceIncreaseFlags.RetryInterval + retryIntervalReached := sampleTimestamp.Add(gasRateUpdateInterval + time.Second) + + tt := []struct { + name string + cctx types.CrossChainTx + blockTimestamp time.Time + medianGasPrice uint64 }{ { - name: "Ethereum is enabled", - chainID: chains.Ethereum.ChainId, - found: true, - updateFunc: keeper.CheckAndUpdateCCTXGasPriceEVM, - }, - { - name: "Binance Smart Chain is enabled", - chainID: chains.BscMainnet.ChainId, - found: true, - updateFunc: keeper.CheckAndUpdateCCTXGasPriceEVM, - }, - { - name: "Bitcoin is enabled", - chainID: chains.BitcoinMainnet.ChainId, - found: true, - updateFunc: keeper.CheckAndUpdateCCTXGasRateBTC, - }, - { - name: "ZetaChain is not enabled", - chainID: chains.ZetaChainMainnet.ChainId, - found: false, - updateFunc: nil, - }, - { - name: "Solana is not enabled", - chainID: chains.SolanaMainnet.ChainId, - found: false, - updateFunc: nil, - }, - { - name: "TON is not enabled", - chainID: chains.TONMainnet.ChainId, - found: false, - updateFunc: nil, + name: "can update fee rate", + cctx: types.CrossChainTx{ + Index: "a", + CctxStatus: &types.Status{ + CreatedTimestamp: sampleTimestamp.Unix(), + LastUpdateTimestamp: sampleTimestamp.Unix(), + }, + OutboundParams: []*types.OutboundParams{ + { + ReceiverChainId: 8332, + CallOptions: &types.CallOptions{ + GasLimit: 254, + }, + GasPrice: "10", + }, + }, + }, + blockTimestamp: retryIntervalReached, + medianGasPrice: 12, }, } + for _, tc := range tt { + tc := tc + t.Run(tc.name, func(t *testing.T) { + k, ctx := testkeeper.CrosschainKeeperAllMocks(t) + + // set block timestamp + ctx = ctx.WithBlockTime(tc.blockTimestamp) + + // check and update gas rate + gasPriceIncrease, feesPaid, err := keeper.CheckAndUpdateCCTXGasPriceBTC( + ctx, + *k, + math.NewUint(tc.medianGasPrice), + tc.cctx, + ) + require.NoError(t, err) - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - updateFunc, found := keeper.GetCCTXGasPriceUpdater(tt.chainID, []zetachains.Chain{}) - require.Equal(t, tt.found, found) - require.Equal(t, reflect.ValueOf(tt.updateFunc).Pointer(), reflect.ValueOf(updateFunc).Pointer()) + // check values + require.True(t, gasPriceIncrease.IsZero()) + require.True(t, feesPaid.IsZero()) + + // check cctx if fee rate is updated + cctx, found := k.GetCrossChainTx(ctx, tc.cctx.Index) + require.True(t, found) + newGasPrice, err := cctx.GetCurrentOutboundParam().GetGasPriorityFeeUInt64() + require.NoError(t, err) + require.Equal(t, tc.medianGasPrice, newGasPrice) + require.EqualValues(t, tc.blockTimestamp.Unix(), cctx.CctxStatus.LastUpdateTimestamp) }) } } diff --git a/x/crosschain/module.go b/x/crosschain/module.go index 3b2c00a630..5015a8e7fc 100644 --- a/x/crosschain/module.go +++ b/x/crosschain/module.go @@ -172,7 +172,7 @@ func (am AppModule) BeginBlock(ctx sdk.Context, _ abci.RequestBeginBlock) { // iterate and update gas price for cctx that are pending for too long // error is logged in the function - am.keeper.IterateAndUpdateCCTXGasPrice(ctx, supportedChains, nil) + am.keeper.IterateAndUpdateCCTXGasPrice(ctx, supportedChains, keeper.CheckAndUpdateCCTXGasPrice) } // EndBlock executes all ABCI EndBlock logic respective to the crosschain module. It diff --git a/zetaclient/chains/bitcoin/rpc/rpc.go b/zetaclient/chains/bitcoin/rpc/rpc.go index 8c429dd756..9f54e4dca7 100644 --- a/zetaclient/chains/bitcoin/rpc/rpc.go +++ b/zetaclient/chains/bitcoin/rpc/rpc.go @@ -304,6 +304,7 @@ func IsTxStuckInMempoolRegnet( func GetTotalMempoolParentsSizeNFees( client interfaces.BTCRPCClient, childHash string, + timeout time.Duration, ) (int64, float64, int64, int64, error) { var ( totalTxs int64 @@ -313,6 +314,7 @@ func GetTotalMempoolParentsSizeNFees( ) // loop through all parents + startTime := time.Now() parentHash := childHash for { memplEntry, err := client.GetMempoolEntry(parentHash) @@ -335,6 +337,11 @@ func GetTotalMempoolParentsSizeNFees( return 0, 0, 0, 0, errors.Wrapf(err, "unable to get tx %s", parentHash) } parentHash = tx.MsgTx().TxIn[0].PreviousOutPoint.Hash.String() + + // check timeout to avoid infinite loop + if time.Since(startTime) > timeout { + return 0, 0, 0, 0, errors.Errorf("timeout reached on %dth tx: %s", totalTxs, parentHash) + } } // no pending tx found diff --git a/zetaclient/chains/bitcoin/rpc/rpc_rbf_live_test.go b/zetaclient/chains/bitcoin/rpc/rpc_rbf_live_test.go index 88192bfd6a..34fccd2569 100644 --- a/zetaclient/chains/bitcoin/rpc/rpc_rbf_live_test.go +++ b/zetaclient/chains/bitcoin/rpc/rpc_rbf_live_test.go @@ -71,22 +71,11 @@ func LiveTest_RBFTransaction(t *testing.T) { // setup test client, privKey, sender, to := setupTest(t) - // try querying tx result - _, getTxResult, err := rpc.GetTxResultByHash( - client, - "329d9204b906adc5f220954d53d9d990ebe92404c19297233aacb4a2ae799b69", - ) - if err == nil { - fmt.Printf("tx confirmations: %d\n", getTxResult.Confirmations) - } else { - fmt.Printf("GetTxResultByHash failed: %s\n", err) - } - // define amount, fee rate and bump fee reserved amount := 0.00001 nonceMark := chains.NonceMarkAmount(1) - feeRate := int64(6) - bumpFeeReserved := int64(0) + feeRate := int64(2) + bumpFeeReserved := int64(10000) // STEP 1 // build and send tx1 @@ -96,13 +85,13 @@ func LiveTest_RBFTransaction(t *testing.T) { // STEP 2 // build and send tx2 (child of tx1) - // nonceMark += 1 - // txHash2 := buildAndSendRBFTx(t, client, privKey, txHash1, sender, to, amount, nonceMark, feeRate, bumpFeeReserved) - // fmt.Printf("sent tx2: %s\n", txHash2) + nonceMark += 1 + txHash2 := buildAndSendRBFTx(t, client, privKey, txHash1, sender, to, amount, nonceMark, feeRate, bumpFeeReserved) + fmt.Printf("sent tx2: %s\n", txHash2) // STEP 3 // wait for a short time before bumping fee - rawTx1, confirmed := waitForTxConfirmation(client, sender, txHash1, 600*time.Second) + rawTx1, confirmed := waitForTxConfirmation(client, sender, txHash1, 10*time.Second) if confirmed { fmt.Println("Opps: tx1 confirmed, no chance to bump fee; please start over") return @@ -141,69 +130,42 @@ func LiveTest_RBFTransaction(t *testing.T) { // tx1 and tx2 must be dropped ensureTxDropped(t, client, txHash1) fmt.Println("tx1 dropped") - //ensureTxDropped(t, client, txHash2) - //fmt.Println("tx2 dropped") + ensureTxDropped(t, client, txHash2) + fmt.Println("tx2 dropped") } -// LiveTest_RBFTransaction_Chained_CPFP tests Child-Pays-For-Parent (CPFP) fee bumping strategy for chained RBF transactions +// Test_RBFTransactionChained_CPFP tests Child-Pays-For-Parent (CPFP) fee bumping strategy for chained RBF transactions func LiveTest_RBFTransaction_Chained_CPFP(t *testing.T) { // setup test client, privKey, sender, to := setupTest(t) // define amount, fee rate and bump fee reserved amount := 0.00001 - nonceMark := int64(0) - feeRate := int64(20) - bumpFeeReserved := int64(0) - - //// - txid := "a5028b27a82aaea7f1bc6da41cb42e5f69478ef2b2e2cca7335db62f689f7e18" - oldHash, err := chainhash.NewHashFromStr(txid) - require.NoError(t, err) - rawTx2, err := client.GetRawTransaction(oldHash) - - // STEP 5 - // bump gas fee for tx3 (the child/grandchild of tx1/tx2) - // we assume that tx3 has same vBytes as the fee-bump tx (tx4) for simplicity - // two rules to satisfy: - // - feeTx4 >= feeTx3 - // - additionalFees >= vSizeTx4 * minRelayFeeRate - // see: https://github.com/bitcoin/bitcoin/blob/master/src/policy/rbf.cpp#L166-L183 - minRelayFeeRate := int64(1) - feeRateIncrease := minRelayFeeRate + feeRate - 1 - additionalFees := (110) * feeRateIncrease - fmt.Printf("additional fee: %d sats\n", additionalFees) - tx3, err := bumpRBFTxFee(rawTx2.MsgTx(), additionalFees) - require.NoError(t, err) - - // STEP 6 - // sign and send tx3, which replaces tx2 - signTx(t, client, privKey, tx3) - txHash, err := client.SendRawTransaction(tx3, true) - require.NoError(t, err) - fmt.Printf("sent tx3: %s\n", txHash) + nonceMark := chains.NonceMarkAmount(0) + feeRate := int64(2) + bumpFeeReserved := int64(10000) // STEP 1 // build and send tx1 - nonceMark = 0 + nonceMark += 1 txHash1 := buildAndSendRBFTx(t, client, privKey, nil, sender, to, amount, nonceMark, feeRate, bumpFeeReserved) fmt.Printf("sent tx1: %s\n", txHash1) // STEP 2 // build and send tx2 (child of tx1) - //nonceMark += 1 + nonceMark += 1 txHash2 := buildAndSendRBFTx(t, client, privKey, txHash1, sender, to, amount, nonceMark, feeRate, bumpFeeReserved) fmt.Printf("sent tx2: %s\n", txHash2) // STEP 3 // build and send tx3 (child of tx2) - //nonceMark += 1 + nonceMark += 1 txHash3 := buildAndSendRBFTx(t, client, privKey, txHash2, sender, to, amount, nonceMark, feeRate, bumpFeeReserved) fmt.Printf("sent tx3: %s\n", txHash3) // STEP 4 // wait for a short time before bumping fee - rawTx2, confirmed := waitForTxConfirmation(client, sender, txHash3, 10*time.Second) + rawTx3, confirmed := waitForTxConfirmation(client, sender, txHash3, 10*time.Second) if confirmed { fmt.Println("Opps: tx3 confirmed, no chance to bump fee; please start over") return @@ -216,11 +178,11 @@ func LiveTest_RBFTransaction_Chained_CPFP(t *testing.T) { // - feeTx4 >= feeTx3 // - additionalFees >= vSizeTx4 * minRelayFeeRate // see: https://github.com/bitcoin/bitcoin/blob/master/src/policy/rbf.cpp#L166-L183 - minRelayFeeRate = int64(1) - feeRateIncrease = minRelayFeeRate - additionalFees = (mempool.GetTxVirtualSize(rawTx2) + 1) * feeRateIncrease + minRelayFeeRate := int64(1) + feeRateIncrease := minRelayFeeRate + additionalFees := (mempool.GetTxVirtualSize(rawTx3) + 1) * feeRateIncrease fmt.Printf("additional fee: %d sats\n", additionalFees) - tx4, err := bumpRBFTxFee(rawTx2.MsgTx(), additionalFees) + tx4, err := bumpRBFTxFee(rawTx3.MsgTx(), additionalFees) require.NoError(t, err) // STEP 6 @@ -323,18 +285,7 @@ func buildAndSendRBFTx( ) *chainhash.Hash { // list outputs utxos := listUTXOs(client, sender) - //require.NotEmpty(t, utxos) - - // use hardcoded utxos if none found - if len(utxos) == 0 { - utxos = []btcjson.ListUnspentResult{ - { - TxID: "329d9204b906adc5f220954d53d9d990ebe92404c19297233aacb4a2ae799b69", - Vout: 0, - Amount: 0.00014399, - }, - } - } + require.NotEmpty(t, utxos) // ensure all inputs are from the parent tx if parent != nil { @@ -416,37 +367,31 @@ func buildRBFTx( require.NoError(t, err) // amount to send in satoshis - //amountSats, err := bitcoin.GetSatoshis(amount) - //require.NoError(t, err) + amountSats, err := bitcoin.GetSatoshis(amount) + require.NoError(t, err) // calculate tx fee txSize, err := bitcoin.EstimateOutboundSize(int64(len(utxos)), []btcutil.Address{to}) require.NoError(t, err) - require.Greater(t, txSize, uint64(62)) - //txSize = 125 // remove the size of the nonce-mark and payee outputs - txSize -= 62 // remove the size of the nonce-mark and payee outputs fees := int64(txSize) * feeRate - // adjust amount - amountSats := totalSats - fees - // make sure total is greater than amount + fees - //require.GreaterOrEqual(t, totalSats, nonceMark+amountSats+fees+bumpFeeReserved) + require.GreaterOrEqual(t, totalSats, nonceMark+amountSats+fees+bumpFeeReserved) // 1st output: simulated nonce-mark amount to self pkScriptSender, err := txscript.PayToAddrScript(sender) require.NoError(t, err) - // txOut0 := wire.NewTxOut(nonceMark, pkScriptSender) - // tx.AddTxOut(txOut0) + txOut0 := wire.NewTxOut(nonceMark, pkScriptSender) + tx.AddTxOut(txOut0) // 2nd output: payment to receiver - // pkScriptReceiver, err := txscript.PayToAddrScript(to) - // require.NoError(t, err) - // txOut1 := wire.NewTxOut(amountSats, pkScriptReceiver) - // tx.AddTxOut(txOut1) + pkScriptReceiver, err := txscript.PayToAddrScript(to) + require.NoError(t, err) + txOut1 := wire.NewTxOut(amountSats, pkScriptReceiver) + tx.AddTxOut(txOut1) // 3rd output: change to self - changeSats := amountSats //totalSats - nonceMark - amountSats - fees + changeSats := totalSats - nonceMark - amountSats - fees require.GreaterOrEqual(t, changeSats, bumpFeeReserved) txOut2 := wire.NewTxOut(changeSats, pkScriptSender) tx.AddTxOut(txOut2) @@ -576,12 +521,12 @@ func bumpRBFTxFee(oldTx *wire.MsgTx, additionalFee int64) (*wire.MsgTx, error) { } // original change needs to be enough to cover the additional fee - if newTx.TxOut[0].Value <= additionalFee { + if newTx.TxOut[2].Value <= additionalFee { return nil, errors.New("change amount is not enough to cover the additional fee") } // bump fee by reducing the change amount - newTx.TxOut[0].Value = newTx.TxOut[0].Value - additionalFee + newTx.TxOut[2].Value = newTx.TxOut[2].Value - additionalFee return newTx, nil } diff --git a/zetaclient/chains/bitcoin/signer/fee_bumper.go b/zetaclient/chains/bitcoin/signer/fee_bumper.go index 5b8ff6adc7..403a0210a8 100644 --- a/zetaclient/chains/bitcoin/signer/fee_bumper.go +++ b/zetaclient/chains/bitcoin/signer/fee_bumper.go @@ -3,6 +3,7 @@ package signer import ( "fmt" "math" + "time" "github.com/btcsuite/btcd/btcutil" "github.com/btcsuite/btcd/mempool" @@ -19,10 +20,10 @@ import ( ) const ( - // gasRateCap is the maximum average gas rate for CPFP fee bumping + // feeRateCap is the maximum average fee rate for CPFP fee bumping // 100 sat/vB is a heuristic based on Bitcoin mempool statistics to avoid excessive fees // see: https://mempool.space/graphs/mempool#3y - gasRateCap = 100 + feeRateCap = 100 // minCPFPFeeBumpPercent is the minimum percentage by which the CPFP average fee rate should be bumped. // This value 20% is a heuristic, not mandated by the Bitcoin protocol, designed to balance effectiveness @@ -31,7 +32,7 @@ const ( ) // MempoolTxsInfoFetcher is a function type to fetch mempool txs information -type MempoolTxsInfoFetcher func(interfaces.BTCRPCClient, string) (int64, float64, int64, int64, error) +type MempoolTxsInfoFetcher func(interfaces.BTCRPCClient, string, time.Duration) (int64, float64, int64, int64, error) // CPFPFeeBumper is a helper struct to contain CPFP (child-pays-for-parent) fee bumping logic type CPFPFeeBumper struct { @@ -121,10 +122,10 @@ func (b *CPFPFeeBumper) BumpTxFee() (*wire.MsgTx, int64, int64, error) { ) } - // cap the gas rate to avoid excessive fees - gasRateNew := b.CCTXRate - if b.CCTXRate > gasRateCap { - gasRateNew = gasRateCap + // cap the fee rate to avoid excessive fees + feeRateNew := b.CCTXRate + if b.CCTXRate > feeRateCap { + feeRateNew = feeRateCap } // calculate minmimum relay fees of the new replacement tx @@ -139,7 +140,7 @@ func (b *CPFPFeeBumper) BumpTxFee() (*wire.MsgTx, int64, int64, error) { // 2. additionalFees >= minRelayTxFees // // see: https://github.com/bitcoin/bitcoin/blob/master/src/policy/rbf.cpp#L166-L183 - additionalFees := b.TotalVSize*gasRateNew - b.TotalFees + additionalFees := b.TotalVSize*feeRateNew - b.TotalFees if additionalFees < minRelayTxFees { return nil, 0, 0, fmt.Errorf( "hold on RBF: additional fees %d is lower than min relay fees %d", @@ -158,10 +159,10 @@ func (b *CPFPFeeBumper) BumpTxFee() (*wire.MsgTx, int64, int64, error) { newTx.TxOut = newTx.TxOut[:2] } - // effective gas rate - gasRateNew = int64(math.Ceil(float64(b.TotalFees+additionalFees) / float64(b.TotalVSize))) + // effective fee rate + feeRateNew = int64(math.Ceil(float64(b.TotalFees+additionalFees) / float64(b.TotalVSize))) - return newTx, additionalFees, gasRateNew, nil + return newTx, additionalFees, feeRateNew, nil } // fetchFeeBumpInfo fetches all necessary information needed to bump the stuck tx @@ -175,7 +176,7 @@ func (b *CPFPFeeBumper) FetchFeeBumpInfo(memplTxsInfoFetcher MempoolTxsInfoFetch b.LiveRate = liveRate // query total fees and sizes of all pending parent TSS txs - totalTxs, totalFees, totalVSize, avgFeeRate, err := memplTxsInfoFetcher(b.Client, b.Tx.MsgTx().TxID()) + totalTxs, totalFees, totalVSize, avgFeeRate, err := memplTxsInfoFetcher(b.Client, b.Tx.MsgTx().TxID(), time.Minute) if err != nil { return errors.Wrap(err, "unable to fetch mempool txs info") } diff --git a/zetaclient/chains/bitcoin/signer/fee_bumper_test.go b/zetaclient/chains/bitcoin/signer/fee_bumper_test.go index 3312ae6341..a5c4037a86 100644 --- a/zetaclient/chains/bitcoin/signer/fee_bumper_test.go +++ b/zetaclient/chains/bitcoin/signer/fee_bumper_test.go @@ -2,6 +2,7 @@ package signer_test import ( "testing" + "time" "github.com/btcsuite/btcd/btcjson" "github.com/btcsuite/btcd/btcutil" @@ -326,21 +327,29 @@ func Test_FetchFeeBumpInfo(t *testing.T) { } func Test_CopyMsgTxNoWitness(t *testing.T) { - chain := chains.BitcoinMainnet - txid := "030cd813443f7b70cc6d8a544d320c6d8465e4528fc0f3410b599dc0b26753a0" - msgTx := testutils.LoadBTCMsgTx(t, TestDataDir, chain.ChainId, txid) + t.Run("should copy tx msg without witness", func(t *testing.T) { + chain := chains.BitcoinMainnet + txid := "030cd813443f7b70cc6d8a544d320c6d8465e4528fc0f3410b599dc0b26753a0" + msgTx := testutils.LoadBTCMsgTx(t, TestDataDir, chain.ChainId, txid) - // make a non-witness copy - copyTx := signer.CopyMsgTxNoWitness(msgTx) + // make a non-witness copy + copyTx := signer.CopyMsgTxNoWitness(msgTx) - // make another copy and clear witness data manually - newTx := msgTx.Copy() - for idx := range newTx.TxIn { - newTx.TxIn[idx].Witness = wire.TxWitness{} - } + // make another copy and clear witness data manually + newTx := msgTx.Copy() + for idx := range newTx.TxIn { + newTx.TxIn[idx].Witness = wire.TxWitness{} + } + + // check + require.Equal(t, newTx, copyTx) + }) - // check - require.Equal(t, newTx, copyTx) + t.Run("should handle nil input", func(t *testing.T) { + require.Panics(t, func() { + signer.CopyMsgTxNoWitness(nil) + }, "should panic on nil input") + }) } // makeMempoolTxsInfoFetcher is a helper function to create a mock MempoolTxsInfoFetcher @@ -356,7 +365,7 @@ func makeMempoolTxsInfoFetcher( err = errors.New(errMsg) } - return func(interfaces.BTCRPCClient, string) (int64, float64, int64, int64, error) { + return func(interfaces.BTCRPCClient, string, time.Duration) (int64, float64, int64, int64, error) { return totalTxs, totalFees, totalVSize, avgFeeRate, err } } diff --git a/zetaclient/chains/bitcoin/signer/outbound_data.go b/zetaclient/chains/bitcoin/signer/outbound_data.go index 7e5835ed10..8a4cf5581c 100644 --- a/zetaclient/chains/bitcoin/signer/outbound_data.go +++ b/zetaclient/chains/bitcoin/signer/outbound_data.go @@ -2,6 +2,7 @@ package signer import ( "fmt" + "math" "strconv" "github.com/btcsuite/btcd/btcutil" @@ -27,6 +28,9 @@ type OutboundData struct { // amount is the amount in BTC amount float64 + // amountSats is the amount in satoshis + amountSats int64 + // feeRate is the fee rate in satoshis/vByte feeRate int64 @@ -64,8 +68,8 @@ func NewOutboundData( // initial fee rate feeRate, err := strconv.ParseInt(params.GasPrice, 10, 64) - if err != nil || feeRate < 0 { - return nil, fmt.Errorf("cannot convert gas price %s", params.GasPrice) + if err != nil || feeRate <= 0 { + return nil, fmt.Errorf("invalid fee rate %s", params.GasPrice) } // use current gas rate if fed by zetacore @@ -83,7 +87,15 @@ func NewOutboundData( if !chains.IsBtcAddressSupported(to) { return nil, fmt.Errorf("unsupported receiver address %s", params.Receiver) } + + // amount in BTC and satoshis amount := float64(params.Amount.Uint64()) / 1e8 + amountSats := params.Amount.BigInt().Int64() + + // check gas limit + if params.CallOptions.GasLimit > math.MaxInt64 { + return nil, fmt.Errorf("invalid gas limit %d", params.CallOptions.GasLimit) + } // add minimum relay fee (1000 satoshis/KB by default) to gasPrice to avoid minRelayTxFee error // see: https://github.com/bitcoin/bitcoin/blob/master/src/policy/policy.h#L35 @@ -107,14 +119,16 @@ func NewOutboundData( cancelTx := restrictedCCTX || dustAmount if cancelTx { amount = 0.0 + amountSats = 0 } return &OutboundData{ - chainID: chainID, - to: to, - amount: amount, - feeRate: feeRate, - // #nosec G115 always in range + chainID: chainID, + to: to, + amount: amount, + amountSats: amountSats, + feeRate: feeRate, + // #nosec G115 checked in range txSize: int64(params.CallOptions.GasLimit), nonce: params.TssNonce, height: height, diff --git a/zetaclient/chains/bitcoin/signer/outbound_data_test.go b/zetaclient/chains/bitcoin/signer/outbound_data_test.go index 4a619d1302..55b4eb7349 100644 --- a/zetaclient/chains/bitcoin/signer/outbound_data_test.go +++ b/zetaclient/chains/bitcoin/signer/outbound_data_test.go @@ -1,6 +1,7 @@ package signer import ( + "math" "testing" sdk "github.com/cosmos/cosmos-sdk/types" @@ -53,14 +54,15 @@ func Test_NewOutboundData(t *testing.T) { height: 101, minRelayFee: 0.00001, // 1000 sat/KB expected: &OutboundData{ - chainID: chain.ChainId, - to: receiver, - amount: 0.1, - feeRate: 11, // 10 + 1 (minRelayFee) - txSize: 254, - nonce: 1, - height: 101, - cancelTx: false, + chainID: chain.ChainId, + to: receiver, + amount: 0.1, + amountSats: 10000000, + feeRate: 11, // 10 + 1 (minRelayFee) + txSize: 254, + nonce: 1, + height: 101, + cancelTx: false, }, errMsg: "", }, @@ -81,14 +83,15 @@ func Test_NewOutboundData(t *testing.T) { height: 101, minRelayFee: 0.00001, // 1000 sat/KB expected: &OutboundData{ - chainID: chain.ChainId, - to: receiver, - amount: 0.1, - feeRate: 16, // 15 + 1 (minRelayFee) - txSize: 254, - nonce: 1, - height: 101, - cancelTx: false, + chainID: chain.ChainId, + to: receiver, + amount: 0.1, + amountSats: 10000000, + feeRate: 16, // 15 + 1 (minRelayFee) + txSize: 254, + nonce: 1, + height: 101, + cancelTx: false, }, errMsg: "", }, @@ -116,7 +119,17 @@ func Test_NewOutboundData(t *testing.T) { cctx.GetCurrentOutboundParam().GasPrice = "invalid" }, expected: nil, - errMsg: "cannot convert gas price", + errMsg: "invalid fee rate", + }, + { + name: "zero fee rate", + cctx: sample.CrossChainTx(t, "0x123"), + cctxModifier: func(cctx *crosschaintypes.CrossChainTx) { + cctx.InboundParams.CoinType = coin.CoinType_Gas + cctx.GetCurrentOutboundParam().GasPrice = "0" + }, + expected: nil, + errMsg: "invalid fee rate", }, { name: "invalid receiver address", @@ -139,6 +152,18 @@ func Test_NewOutboundData(t *testing.T) { expected: nil, errMsg: "unsupported receiver address", }, + { + name: "invalid gas limit", + cctx: sample.CrossChainTx(t, "0x123"), + cctxModifier: func(cctx *crosschaintypes.CrossChainTx) { + cctx.InboundParams.CoinType = coin.CoinType_Gas + cctx.GetCurrentOutboundParam().Receiver = receiver.String() + cctx.GetCurrentOutboundParam().ReceiverChainId = chain.ChainId + cctx.GetCurrentOutboundParam().CallOptions.GasLimit = math.MaxInt64 + 1 + }, + expected: nil, + errMsg: "invalid gas limit", + }, { name: "should cancel restricted CCTX", cctx: sample.CrossChainTx(t, "0x123"), @@ -156,14 +181,15 @@ func Test_NewOutboundData(t *testing.T) { height: 101, minRelayFee: 0.00001, // 1000 sat/KB expected: &OutboundData{ - chainID: chain.ChainId, - to: receiver, - amount: 0, // should cancel the tx - feeRate: 11, // 10 + 1 (minRelayFee) - txSize: 254, - nonce: 1, - height: 101, - cancelTx: true, + chainID: chain.ChainId, + to: receiver, + amount: 0, // should cancel the tx + amountSats: 0, + feeRate: 11, // 10 + 1 (minRelayFee) + txSize: 254, + nonce: 1, + height: 101, + cancelTx: true, }, }, { @@ -182,14 +208,15 @@ func Test_NewOutboundData(t *testing.T) { height: 101, minRelayFee: 0.00001, // 1000 sat/KB expected: &OutboundData{ - chainID: chain.ChainId, - to: receiver, - amount: 0, // should cancel the tx - feeRate: 11, // 10 + 1 (minRelayFee) - txSize: 254, - nonce: 1, - height: 101, - cancelTx: true, + chainID: chain.ChainId, + to: receiver, + amount: 0, // should cancel the tx + amountSats: 0, + feeRate: 11, // 10 + 1 (minRelayFee) + txSize: 254, + nonce: 1, + height: 101, + cancelTx: true, }, }, } diff --git a/zetaclient/chains/bitcoin/signer/sign.go b/zetaclient/chains/bitcoin/signer/sign.go index 13f3271fdf..ebf7ba57fa 100644 --- a/zetaclient/chains/bitcoin/signer/sign.go +++ b/zetaclient/chains/bitcoin/signer/sign.go @@ -104,7 +104,7 @@ func (signer *Signer) SignWithdrawTx( txData.nonce, txData.feeRate, txSize, fees, consolidatedUtxo, consolidatedValue) // add tx outputs - err = signer.AddWithdrawTxOutputs(tx, txData.to, total, txData.amount, nonceMark, fees, txData.cancelTx) + err = signer.AddWithdrawTxOutputs(tx, txData.to, total, txData.amountSats, nonceMark, fees, txData.cancelTx) if err != nil { return nil, err } @@ -155,16 +155,13 @@ func (signer *Signer) AddWithdrawTxOutputs( tx *wire.MsgTx, to btcutil.Address, total float64, - amount float64, + amountSats int64, nonceMark int64, fees int64, cancelTx bool, ) error { - // convert withdraw amount to satoshis - amountSatoshis, err := bitcoin.GetSatoshis(amount) - if err != nil { - return err - } + // convert withdraw amount to BTC + amount := float64(amountSats) / 1e8 // calculate remaining btc (the change) to TSS self remaining := total - amount @@ -195,11 +192,11 @@ func (signer *Signer) AddWithdrawTxOutputs( if err != nil { return err } - txOut2 := wire.NewTxOut(amountSatoshis, pkScript) + txOut2 := wire.NewTxOut(amountSats, pkScript) tx.AddTxOut(txOut2) } else { // send the amount to TSS self if tx is cancelled - remainingSats += amountSatoshis + remainingSats += amountSats } // 3rd output: the remaining btc to TSS self diff --git a/zetaclient/chains/bitcoin/signer/sign_rbf.go b/zetaclient/chains/bitcoin/signer/sign_rbf.go index e28bfbdb8c..090a409a42 100644 --- a/zetaclient/chains/bitcoin/signer/sign_rbf.go +++ b/zetaclient/chains/bitcoin/signer/sign_rbf.go @@ -55,7 +55,7 @@ func (signer *Signer) SignRBFTx( // parse recent fee rate from CCTX recentRate, err := strconv.ParseInt(params.GasPriorityFee, 10, 64) if err != nil || recentRate <= 0 { - return nil, fmt.Errorf("invalid fee rate %s", params.GasPrice) + return nil, fmt.Errorf("invalid fee rate %s", params.GasPriorityFee) } cctxRate = recentRate } diff --git a/zetaclient/chains/bitcoin/signer/sign_test.go b/zetaclient/chains/bitcoin/signer/sign_test.go index bf2138e8a9..f086720b30 100644 --- a/zetaclient/chains/bitcoin/signer/sign_test.go +++ b/zetaclient/chains/bitcoin/signer/sign_test.go @@ -46,27 +46,27 @@ func TestAddWithdrawTxOutputs(t *testing.T) { // test cases tests := []struct { - name string - tx *wire.MsgTx - to btcutil.Address - total float64 - amount float64 - nonceMark int64 - fees int64 - cancelTx bool - fail bool - message string - txout []*wire.TxOut + name string + tx *wire.MsgTx + to btcutil.Address + total float64 + amountSats int64 + nonceMark int64 + fees int64 + cancelTx bool + fail bool + message string + txout []*wire.TxOut }{ { - name: "should add outputs successfully", - tx: wire.NewMsgTx(wire.TxVersion), - to: to, - total: 1.00012000, - amount: 0.2, - nonceMark: 10000, - fees: 2000, - fail: false, + name: "should add outputs successfully", + tx: wire.NewMsgTx(wire.TxVersion), + to: to, + total: 1.00012000, + amountSats: 20000000, + nonceMark: 10000, + fees: 2000, + fail: false, txout: []*wire.TxOut{ {Value: 10000, PkScript: tssScript}, {Value: 20000000, PkScript: toScript}, @@ -74,70 +74,62 @@ func TestAddWithdrawTxOutputs(t *testing.T) { }, }, { - name: "should add outputs without change successfully", - tx: wire.NewMsgTx(wire.TxVersion), - to: to, - total: 0.20012000, - amount: 0.2, - nonceMark: 10000, - fees: 2000, - fail: false, + name: "should add outputs without change successfully", + tx: wire.NewMsgTx(wire.TxVersion), + to: to, + total: 0.20012000, + amountSats: 20000000, + nonceMark: 10000, + fees: 2000, + fail: false, txout: []*wire.TxOut{ {Value: 10000, PkScript: tssScript}, {Value: 20000000, PkScript: toScript}, }, }, { - name: "should cancel tx successfully", - tx: wire.NewMsgTx(wire.TxVersion), - to: to, - total: 1.00012000, - amount: 0.2, - nonceMark: 10000, - fees: 2000, - cancelTx: true, - fail: false, + name: "should cancel tx successfully", + tx: wire.NewMsgTx(wire.TxVersion), + to: to, + total: 1.00012000, + amountSats: 20000000, + nonceMark: 10000, + fees: 2000, + cancelTx: true, + fail: false, txout: []*wire.TxOut{ {Value: 10000, PkScript: tssScript}, {Value: 100000000, PkScript: tssScript}, }, }, { - name: "should fail on invalid amount", - tx: wire.NewMsgTx(wire.TxVersion), - to: to, - total: 1.00012000, - amount: -0.5, - fail: true, + name: "should fail when total < amount", + tx: wire.NewMsgTx(wire.TxVersion), + to: to, + total: 0.00012000, + amountSats: 20000000, + fail: true, }, { - name: "should fail when total < amount", - tx: wire.NewMsgTx(wire.TxVersion), - to: to, - total: 0.00012000, - amount: 0.2, - fail: true, + name: "should fail when total < fees + amount + nonce", + tx: wire.NewMsgTx(wire.TxVersion), + to: to, + total: 0.20011000, + amountSats: 20000000, + nonceMark: 10000, + fees: 2000, + fail: true, + message: "remainder value is negative", }, { - name: "should fail when total < fees + amount + nonce", - tx: wire.NewMsgTx(wire.TxVersion), - to: to, - total: 0.20011000, - amount: 0.2, - nonceMark: 10000, - fees: 2000, - fail: true, - message: "remainder value is negative", - }, - { - name: "should not produce duplicate nonce mark", - tx: wire.NewMsgTx(wire.TxVersion), - to: to, - total: 0.20022000, // 0.2 + fee + nonceMark * 2 - amount: 0.2, - nonceMark: 10000, - fees: 2000, - fail: false, + name: "should not produce duplicate nonce mark", + tx: wire.NewMsgTx(wire.TxVersion), + to: to, + total: 0.20022000, // 0.2 + fee + nonceMark * 2 + amountSats: 20000000, + nonceMark: 10000, + fees: 2000, + fail: false, txout: []*wire.TxOut{ {Value: 10000, PkScript: tssScript}, {Value: 20000000, PkScript: toScript}, @@ -145,34 +137,42 @@ func TestAddWithdrawTxOutputs(t *testing.T) { }, }, { - name: "should not produce dust change to TSS self", - tx: wire.NewMsgTx(wire.TxVersion), - to: to, - total: 0.20012999, // 0.2 + fee + nonceMark - amount: 0.2, - nonceMark: 10000, - fees: 2000, - fail: false, + name: "should not produce dust change to TSS self", + tx: wire.NewMsgTx(wire.TxVersion), + to: to, + total: 0.20012999, // 0.2 + fee + nonceMark + amountSats: 20000000, + nonceMark: 10000, + fees: 2000, + fail: false, txout: []*wire.TxOut{ // 3rd output 999 is dust and removed {Value: 10000, PkScript: tssScript}, {Value: 20000000, PkScript: toScript}, }, }, { - name: "should fail on invalid to address", - tx: wire.NewMsgTx(wire.TxVersion), - to: nil, - total: 1.00012000, - amount: 0.2, - nonceMark: 10000, - fees: 2000, - fail: true, + name: "should fail on invalid to address", + tx: wire.NewMsgTx(wire.TxVersion), + to: nil, + total: 1.00012000, + amountSats: 20000000, + nonceMark: 10000, + fees: 2000, + fail: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - err := signer.AddWithdrawTxOutputs(tt.tx, tt.to, tt.total, tt.amount, tt.nonceMark, tt.fees, tt.cancelTx) + err := signer.AddWithdrawTxOutputs( + tt.tx, + tt.to, + tt.total, + tt.amountSats, + tt.nonceMark, + tt.fees, + tt.cancelTx, + ) if tt.fail { require.Error(t, err) if tt.message != "" { From 728f69772b6b603201996136fc35168310ad6da7 Mon Sep 17 00:00:00 2001 From: Charlie Chen Date: Tue, 14 Jan 2025 13:41:30 -0600 Subject: [PATCH 19/20] double check to ensure the RBF tx is the last tx before broadcasting --- zetaclient/chains/bitcoin/observer/mempool.go | 3 +- zetaclient/chains/bitcoin/signer/signer.go | 21 +++++++--- .../chains/bitcoin/signer/signer_test.go | 40 ++++++++++++++++--- 3 files changed, 51 insertions(+), 13 deletions(-) diff --git a/zetaclient/chains/bitcoin/observer/mempool.go b/zetaclient/chains/bitcoin/observer/mempool.go index 1c865b0565..e5000a5960 100644 --- a/zetaclient/chains/bitcoin/observer/mempool.go +++ b/zetaclient/chains/bitcoin/observer/mempool.go @@ -166,8 +166,7 @@ func GetLastPendingOutbound(ctx context.Context, ob *Observer) (*btcutil.Tx, uin } // is tx in the mempool? - _, err = ob.btcClient.GetMempoolEntry(lastHash) - if err != nil { + if _, err = ob.btcClient.GetMempoolEntry(lastHash); err != nil { return nil, 0, errors.New("last tx is not in mempool") } diff --git a/zetaclient/chains/bitcoin/signer/signer.go b/zetaclient/chains/bitcoin/signer/signer.go index 25099f28fa..a0e1ebb2d8 100644 --- a/zetaclient/chains/bitcoin/signer/signer.go +++ b/zetaclient/chains/bitcoin/signer/signer.go @@ -127,6 +127,7 @@ func (signer *Signer) TryProcessOutbound( } var ( + rbfTx = false signedTx *wire.MsgTx stuckTx = observer.GetLastStuckOutbound() ) @@ -134,6 +135,7 @@ func (signer *Signer) TryProcessOutbound( // sign outbound if stuckTx != nil && params.TssNonce == stuckTx.Nonce { // sign RBF tx + rbfTx = true mempoolFetcher := rpc.GetTotalMempoolParentsSizeNFees signedTx, err = signer.SignRBFTx(ctx, cctx, height, stuckTx.Tx, minRelayFee, mempoolFetcher) if err != nil { @@ -159,7 +161,7 @@ func (signer *Signer) TryProcessOutbound( } // broadcast signed outbound - signer.BroadcastOutbound(ctx, signedTx, params.TssNonce, cctx, observer, zetacoreClient) + signer.BroadcastOutbound(ctx, signedTx, params.TssNonce, rbfTx, cctx, observer, zetacoreClient) } // BroadcastOutbound sends the signed transaction to the Bitcoin network @@ -167,6 +169,7 @@ func (signer *Signer) BroadcastOutbound( ctx context.Context, tx *wire.MsgTx, nonce uint64, + rbfTx bool, cctx *types.CrossChainTx, ob *observer.Observer, zetacoreClient interfaces.ZetacoreClient, @@ -182,6 +185,14 @@ func (signer *Signer) BroadcastOutbound( } logger := signer.Logger().Std + // double check to ensure the tx is still the last outbound + if rbfTx { + if ob.GetPendingNonce() > nonce+1 { + logger.Warn().Fields(lf).Msgf("RBF tx nonce is outdated, skipping broadcast") + return + } + } + // try broacasting tx with increasing backoff (1s, 2s, 4s, 8s, 16s) in case of RPC error backOff := broadcastBackoff for i := 0; i < broadcastRetries; i++ { @@ -194,7 +205,7 @@ func (signer *Signer) BroadcastOutbound( backOff *= 2 continue } - logger.Info().Fields(lf).Msgf("broadcasted Bitcoin outbound successfully") + logger.Info().Fields(lf).Msg("broadcasted Bitcoin outbound successfully") // save tx local db ob.SaveBroadcastedTx(txHash, nonce) @@ -202,16 +213,16 @@ func (signer *Signer) BroadcastOutbound( // add tx to outbound tracker so that all observers know about it zetaHash, err := zetacoreClient.PostOutboundTracker(ctx, ob.Chain().ChainId, nonce, txHash) if err != nil { - logger.Err(err).Fields(lf).Msgf("unable to add Bitcoin outbound tracker") + logger.Err(err).Fields(lf).Msg("unable to add Bitcoin outbound tracker") } else { lf[logs.FieldZetaTx] = zetaHash - logger.Info().Fields(lf).Msgf("add Bitcoin outbound tracker successfully") + logger.Info().Fields(lf).Msg("add Bitcoin outbound tracker successfully") } // try including this outbound as early as possible _, included := ob.TryIncludeOutbound(ctx, cctx, txHash) if included { - logger.Info().Fields(lf).Msgf("included newly broadcasted Bitcoin outbound") + logger.Info().Fields(lf).Msg("included newly broadcasted Bitcoin outbound") } // successful broadcast; no need to retry diff --git a/zetaclient/chains/bitcoin/signer/signer_test.go b/zetaclient/chains/bitcoin/signer/signer_test.go index 2f32d0a05b..9187213e3b 100644 --- a/zetaclient/chains/bitcoin/signer/signer_test.go +++ b/zetaclient/chains/bitcoin/signer/signer_test.go @@ -100,6 +100,8 @@ func Test_BroadcastOutbound(t *testing.T) { name string chain chains.Chain nonce uint64 + rbfTx bool + skipRBFTx bool failTracker bool }{ { @@ -108,11 +110,24 @@ func Test_BroadcastOutbound(t *testing.T) { nonce: uint64(148), }, { - name: "should successfully broadcast and include outbound, but failed to post outbound tracker", + name: "should successfully broadcast and include RBF outbound", + chain: chains.BitcoinMainnet, + nonce: uint64(148), + rbfTx: true, + }, + { + name: "should successfully broadcast and include outbound, but fail to post outbound tracker", chain: chains.BitcoinMainnet, nonce: uint64(148), failTracker: true, }, + { + name: "should skip broadcasting RBF tx", + chain: chains.BitcoinMainnet, + nonce: uint64(148), + rbfTx: true, + skipRBFTx: true, + }, } for _, tt := range tests { @@ -128,9 +143,9 @@ func Test_BroadcastOutbound(t *testing.T) { msgTx := testutils.LoadBTCMsgTx(t, TestDataDir, chainID, rawResult.Txid) // mock RPC response - s.client.On("SendRawTransaction", mock.Anything, mock.Anything).Return(nil, nil) - s.client.On("GetTransaction", mock.Anything).Return(txResult, nil) - s.client.On("GetRawTransactionVerbose", mock.Anything).Return(rawResult, nil) + s.client.On("SendRawTransaction", mock.Anything, mock.Anything).Maybe().Return(nil, nil) + s.client.On("GetTransaction", mock.Anything).Maybe().Return(txResult, nil) + s.client.On("GetRawTransactionVerbose", mock.Anything).Maybe().Return(rawResult, nil) // mock Zetacore client response if tt.failTracker { @@ -145,11 +160,17 @@ func Test_BroadcastOutbound(t *testing.T) { TxID: rawResult.Vin[0].Txid, }) + // set a higher pending nonce so the RBF tx is not the last tx + if tt.rbfTx && tt.skipRBFTx { + observer.SetPendingNonce(tt.nonce + 2) + } + ctx := makeCtx(t) s.BroadcastOutbound( ctx, msgTx, tt.nonce, + tt.rbfTx, cctx, observer, s.zetacoreClient, @@ -157,7 +178,11 @@ func Test_BroadcastOutbound(t *testing.T) { // check if outbound is included gotResult := observer.GetIncludedTx(tt.nonce) - require.Equal(t, txResult, gotResult) + if tt.skipRBFTx { + require.Nil(t, gotResult) + } else { + require.Equal(t, txResult, gotResult) + } }) } } @@ -360,13 +385,16 @@ func getNewOutboundProcessor() *outboundprocessor.Processor { func (s *testSuite) getNewObserver(t *testing.T) *observer.Observer { // prepare mock arguments to create observer params := mocks.MockChainParams(s.Chain().ChainId, 2) - logger := base.DefaultLogger() ts := &metrics.TelemetryServer{} // create in-memory db database, err := db.NewFromSqliteInMemory(true) require.NoError(t, err) + // create logger + testLogger := zerolog.New(zerolog.NewTestWriter(t)) + logger := base.Logger{Std: testLogger, Compliance: testLogger} + ob, err := observer.NewObserver( s.Chain(), s.client, From 03d737c14fb52890c9471726f06a05d96b8637dd Mon Sep 17 00:00:00 2001 From: Charlie Chen Date: Wed, 15 Jan 2025 12:12:59 -0600 Subject: [PATCH 20/20] add optGenericSkipper to mempool txs watcher --- zetaclient/chains/bitcoin/bitcoin.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/zetaclient/chains/bitcoin/bitcoin.go b/zetaclient/chains/bitcoin/bitcoin.go index 3a901c4bee..c93fdea315 100644 --- a/zetaclient/chains/bitcoin/bitcoin.go +++ b/zetaclient/chains/bitcoin/bitcoin.go @@ -104,7 +104,7 @@ func (b *Bitcoin) Start(ctx context.Context) error { register(b.observer.ObserveInbound, "observe_inbound", optInboundInterval, optInboundSkipper) register(b.observer.ObserveInboundTrackers, "observe_inbound_trackers", optInboundInterval, optInboundSkipper) register(b.observer.FetchUTXOs, "fetch_utxos", optUTXOInterval, optGenericSkipper) - register(b.observer.WatchMempoolTxs, "watch_mempool_txs", optMempoolInterval) + register(b.observer.WatchMempoolTxs, "watch_mempool_txs", optMempoolInterval, optGenericSkipper) register(b.observer.PostGasPrice, "post_gas_price", optGasInterval, optGenericSkipper) register(b.observer.CheckRPCStatus, "check_rpc_status") register(b.observer.ObserveOutbound, "observe_outbound", optOutboundInterval, optOutboundSkipper)