diff --git a/changelog.md b/changelog.md index 4cc4454097..fb30eba963 100644 --- a/changelog.md +++ b/changelog.md @@ -1,11 +1,19 @@ # CHANGELOG -## unreleased +## Unreleased + +### Features + +* [3306](https://github.com/zeta-chain/node/pull/3306) - add support for Bitcoin RBF (Replace-By-Fee) + +### Tests ### Refactor * [3332](https://github.com/zeta-chain/node/pull/3332) - implement orchestrator V2. Move BTC observer-signer to V2 +### Fixes + ## v25.0.0 ### Features 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/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: 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..c90c0163ef --- /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. +// 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) + + // 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..2e64d90f7b 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/go.mod b/go.mod index a0169489b7..12b967a5ed 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/pkg/math/integer.go b/pkg/math/integer.go new file mode 100644 index 0000000000..8d74b5dc27 --- /dev/null +++ b/pkg/math/integer.go @@ -0,0 +1,40 @@ +// Package implements helper functions for integer math operations. +package math + +import ( + "math" + "math/big" +) + +// 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 + } + + 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 new file mode 100644 index 0000000000..daf6f966fc --- /dev/null +++ b/pkg/math/integer_test.go @@ -0,0 +1,31 @@ +package math + +import ( + "fmt" + "math" + "testing" + + "github.com/stretchr/testify/assert" +) + +func Test_IncreaseIntByPercent(t *testing.T) { + for i, tt := range []struct { + value int64 + percent uint32 + expected int64 + }{ + {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) + assert.Equal(t, tt.expected, result) + }) + } +} diff --git a/testutil/sample/crypto.go b/testutil/sample/crypto.go index 783ffa4a8d..6ebf295010 100644 --- a/testutil/sample/crypto.go +++ b/testutil/sample/crypto.go @@ -12,6 +12,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" @@ -91,7 +92,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) @@ -99,7 +100,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/keeper/abci.go b/x/crosschain/keeper/abci.go index 03d13fb6d5..2dea64b629 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 @@ -52,46 +52,47 @@ 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) { - 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 zetachains.IsZetaChain(chain.ChainId, additionalChains) { + continue + } + + 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++ } } } @@ -102,7 +103,7 @@ IterateChains: // 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 CheckAndUpdateCctxGasPrice( +func CheckAndUpdateCCTXGasPrice( ctx sdk.Context, k Keeper, cctx types.CrossChainTx, @@ -128,6 +129,30 @@ func CheckAndUpdateCctxGasPrice( 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 @@ -175,3 +200,18 @@ func CheckAndUpdateCctxGasPrice( return gasPriceIncrease, additionalFees, nil } + +// CheckAndUpdateCCTXGasPriceBTC updates the fee rate for the given Bitcoin chain CCTX +func CheckAndUpdateCCTXGasPriceBTC( + ctx sdk.Context, + k Keeper, + medianGasPrice math.Uint, + cctx types.CrossChainTx, +) (math.Uint, math.Uint, error) { + // 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 +} diff --git a/x/crosschain/keeper/abci_test.go b/x/crosschain/keeper/abci_test.go index 32499e1827..c09817aeb9 100644 --- a/x/crosschain/keeper/abci_test.go +++ b/x/crosschain/keeper/abci_test.go @@ -63,7 +63,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) @@ -79,23 +79,29 @@ 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 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")) @@ -103,7 +109,7 @@ func TestKeeper_IterateAndUpdateCctxGasPrice(t *testing.T) { require.Contains(t, updateFuncMap, sample.GetCctxIndexFromString("56-34")) } -func TestCheckAndUpdateCctxGasPrice(t *testing.T) { +func Test_CheckAndUpdateCCTXGasPrice(t *testing.T) { sampleTimestamp := time.Now() retryIntervalReached := sampleTimestamp.Add(observertypes.DefaultGasPriceIncreaseFlags.RetryInterval + time.Second) retryIntervalNotReached := sampleTimestamp.Add( @@ -133,7 +139,7 @@ func TestCheckAndUpdateCctxGasPrice(t *testing.T) { }, OutboundParams: []*types.OutboundParams{ { - ReceiverChainId: 42, + ReceiverChainId: 1, CallOptions: &types.CallOptions{ GasLimit: 1000, }, @@ -151,9 +157,9 @@ func TestCheckAndUpdateCctxGasPrice(t *testing.T) { expectedAdditionalFees: math.NewUint(50000), // gasLimit * increase }, { - name: "can update gas price at max limit", + name: "skip if gas price is not set", cctx: types.CrossChainTx{ - Index: "a2", + Index: "b1", CctxStatus: &types.Status{ CreatedTimestamp: sampleTimestamp.Unix(), LastUpdateTimestamp: sampleTimestamp.Unix(), @@ -162,29 +168,23 @@ func TestCheckAndUpdateCctxGasPrice(t *testing.T) { { ReceiverChainId: 42, CallOptions: &types.CallOptions{ - GasLimit: 1000, + GasLimit: 100, }, - GasPrice: "100", + GasPrice: "", }, }, }, - 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 - }, + flags: observertypes.DefaultGasPriceIncreaseFlags, blockTimestamp: retryIntervalReached, - medianGasPrice: 50, - withdrawFromGasStabilityPoolReturn: nil, - expectWithdrawFromGasStabilityPoolCall: true, - expectedGasPriceIncrease: math.NewUint(100), // 200% medianGasPrice - expectedAdditionalFees: math.NewUint(100000), // gasLimit * increase + medianGasPrice: 100, + expectWithdrawFromGasStabilityPoolCall: false, + expectedGasPriceIncrease: math.NewUint(0), + expectedAdditionalFees: math.NewUint(0), }, { - name: "default gas price increase limit used if not defined", + name: "skip if gas limit is not set", cctx: types.CrossChainTx{ - Index: "a3", + Index: "b2", CctxStatus: &types.Status{ CreatedTimestamp: sampleTimestamp.Unix(), LastUpdateTimestamp: sampleTimestamp.Unix(), @@ -193,29 +193,23 @@ func TestCheckAndUpdateCctxGasPrice(t *testing.T) { { ReceiverChainId: 42, CallOptions: &types.CallOptions{ - GasLimit: 1000, + GasLimit: 0, }, GasPrice: "100", }, }, }, - flags: observertypes.GasPriceIncreaseFlags{ - EpochLength: 100, - RetryInterval: time.Minute * 10, - GasPriceIncreasePercent: 100, - GasPriceIncreaseMax: 0, // Limit should not be reached - }, + flags: observertypes.DefaultGasPriceIncreaseFlags, blockTimestamp: retryIntervalReached, - medianGasPrice: 50, - withdrawFromGasStabilityPoolReturn: nil, - expectWithdrawFromGasStabilityPoolCall: true, - expectedGasPriceIncrease: math.NewUint(50), // 100% medianGasPrice - expectedAdditionalFees: math.NewUint(50000), // gasLimit * increase + medianGasPrice: 100, + expectWithdrawFromGasStabilityPoolCall: false, + expectedGasPriceIncrease: math.NewUint(0), + expectedAdditionalFees: math.NewUint(0), }, { - name: "skip if max limit reached", + name: "skip if retry interval is not reached", cctx: types.CrossChainTx{ - Index: "b0", + Index: "b3", CctxStatus: &types.Status{ CreatedTimestamp: sampleTimestamp.Unix(), LastUpdateTimestamp: sampleTimestamp.Unix(), @@ -224,28 +218,23 @@ func TestCheckAndUpdateCctxGasPrice(t *testing.T) { { ReceiverChainId: 42, CallOptions: &types.CallOptions{ - GasLimit: 1000, + GasLimit: 0, }, 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, + flags: observertypes.DefaultGasPriceIncreaseFlags, + blockTimestamp: retryIntervalNotReached, + medianGasPrice: 100, expectWithdrawFromGasStabilityPoolCall: false, expectedGasPriceIncrease: math.NewUint(0), expectedAdditionalFees: math.NewUint(0), }, { - name: "skip if gas price is not set", + name: "returns error if can't find median gas price", cctx: types.CrossChainTx{ - Index: "b1", + Index: "b4", CctxStatus: &types.Status{ CreatedTimestamp: sampleTimestamp.Unix(), LastUpdateTimestamp: sampleTimestamp.Unix(), @@ -254,32 +243,159 @@ func TestCheckAndUpdateCctxGasPrice(t *testing.T) { { ReceiverChainId: 42, CallOptions: &types.CallOptions{ - GasLimit: 100, + GasLimit: 1000, }, - GasPrice: "", + GasPrice: "100", }, }, }, flags: observertypes.DefaultGasPriceIncreaseFlags, - blockTimestamp: retryIntervalReached, - medianGasPrice: 100, expectWithdrawFromGasStabilityPoolCall: false, - expectedGasPriceIncrease: math.NewUint(0), - expectedAdditionalFees: math.NewUint(0), + blockTimestamp: retryIntervalReached, + medianGasPrice: 0, + isError: true, }, { - name: "skip if gas limit is not set", + name: "do nothing for non-EVM, non-BTC chain", cctx: types.CrossChainTx{ - Index: "b2", + Index: "c", CctxStatus: &types.Status{ CreatedTimestamp: sampleTimestamp.Unix(), LastUpdateTimestamp: sampleTimestamp.Unix(), }, OutboundParams: []*types.OutboundParams{ { - ReceiverChainId: 42, + ReceiverChainId: 100, CallOptions: &types.CallOptions{ - GasLimit: 0, + GasLimit: 1000, + }, + GasPrice: "100", + }, + }, + }, + flags: observertypes.DefaultGasPriceIncreaseFlags, + blockTimestamp: retryIntervalReached, + medianGasPrice: 50, + medianPriorityFee: 20, + withdrawFromGasStabilityPoolReturn: nil, + expectedGasPriceIncrease: math.NewUint(0), + expectedAdditionalFees: math.NewUint(0), + }, + } + for _, tc := range tt { + tc := tc + 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 { + previousGasPrice = 0 + } + + // set median gas price if not zero + if tc.medianGasPrice != 0 { + k.SetGasPrice(ctx, types.GasPrice{ + ChainId: chainID, + Prices: []uint64{tc.medianGasPrice}, + PriorityFees: []uint64{tc.medianPriorityFee}, + MedianIndex: 0, + }) + + // ensure median gas price is set + medianGasPrice, medianPriorityFee, isFound := k.GetMedianGasValues(ctx, chainID) + require.True(t, isFound) + require.True(t, medianGasPrice.Equal(math.NewUint(tc.medianGasPrice))) + require.True(t, medianPriorityFee.Equal(math.NewUint(tc.medianPriorityFee))) + } + + // 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(), + ).Return(tc.withdrawFromGasStabilityPoolReturn) + } + + // check and update gas price + gasPriceIncrease, feesPaid, err := keeper.CheckAndUpdateCCTXGasPrice(ctx, *k, tc.cctx, tc.flags) + + if tc.isError { + require.Error(t, err) + return + } + require.NoError(t, err) + + // check values + 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 !tc.expectedGasPriceIncrease.IsZero() { + cctx, found := k.GetCrossChainTx(ctx, tc.cctx.Index) + require.True(t, found) + newGasPrice, err := cctx.GetCurrentOutboundParam().GetGasPriceUInt64() + require.NoError(t, err) + 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_CheckAndUpdateCCTXGasPriceEVM(t *testing.T) { + sampleTimestamp := time.Now() + 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 + withdrawFromGasStabilityPoolReturn error + expectWithdrawFromGasStabilityPoolCall bool + expectedGasPriceIncrease math.Uint + expectedAdditionalFees math.Uint + isError bool + }{ + { + name: "can update gas price", + cctx: types.CrossChainTx{ + Index: "a1", + CctxStatus: &types.Status{ + CreatedTimestamp: sampleTimestamp.Unix(), + LastUpdateTimestamp: sampleTimestamp.Unix(), + }, + OutboundParams: []*types.OutboundParams{ + { + ReceiverChainId: 1, + CallOptions: &types.CallOptions{ + GasLimit: 1000, }, GasPrice: "100", }, @@ -287,47 +403,56 @@ func TestCheckAndUpdateCctxGasPrice(t *testing.T) { }, flags: observertypes.DefaultGasPriceIncreaseFlags, blockTimestamp: retryIntervalReached, - medianGasPrice: 100, - expectWithdrawFromGasStabilityPoolCall: false, - expectedGasPriceIncrease: math.NewUint(0), - expectedAdditionalFees: math.NewUint(0), + 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: "can update gas price at max limit", cctx: types.CrossChainTx{ - Index: "b3", + Index: "a2", CctxStatus: &types.Status{ CreatedTimestamp: sampleTimestamp.Unix(), LastUpdateTimestamp: sampleTimestamp.Unix(), }, OutboundParams: []*types.OutboundParams{ { - ReceiverChainId: 42, + ReceiverChainId: 1, CallOptions: &types.CallOptions{ - GasLimit: 0, + GasLimit: 1000, }, GasPrice: "100", }, }, }, - flags: observertypes.DefaultGasPriceIncreaseFlags, - blockTimestamp: retryIntervalNotReached, - medianGasPrice: 100, - expectWithdrawFromGasStabilityPoolCall: false, - expectedGasPriceIncrease: math.NewUint(0), - expectedAdditionalFees: math.NewUint(0), + 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: "returns error if can't find median gas price", + name: "default gas price increase limit used if not defined", cctx: types.CrossChainTx{ - Index: "c1", + Index: "a3", CctxStatus: &types.Status{ CreatedTimestamp: sampleTimestamp.Unix(), LastUpdateTimestamp: sampleTimestamp.Unix(), }, OutboundParams: []*types.OutboundParams{ { - ReceiverChainId: 42, + ReceiverChainId: 1, CallOptions: &types.CallOptions{ GasLimit: 1000, }, @@ -335,23 +460,62 @@ func TestCheckAndUpdateCctxGasPrice(t *testing.T) { }, }, }, - flags: observertypes.DefaultGasPriceIncreaseFlags, - expectWithdrawFromGasStabilityPoolCall: false, + flags: observertypes.GasPriceIncreaseFlags{ + EpochLength: 100, + RetryInterval: time.Minute * 10, + GasPriceIncreasePercent: 100, + GasPriceIncreaseMax: 0, // Limit should not be reached + }, blockTimestamp: retryIntervalReached, - medianGasPrice: 0, - isError: true, + medianGasPrice: 50, + medianPriorityFee: 20, + 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: "b", + CctxStatus: &types.Status{ + CreatedTimestamp: sampleTimestamp.Unix(), + LastUpdateTimestamp: sampleTimestamp.Unix(), + }, + OutboundParams: []*types.OutboundParams{ + { + ReceiverChainId: 1, + 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, + medianPriorityFee: 20, + expectWithdrawFromGasStabilityPoolCall: false, + expectedGasPriceIncrease: math.NewUint(0), + expectedAdditionalFees: math.NewUint(0), }, { name: "returns error if can't withdraw from gas stability pool", cctx: types.CrossChainTx{ - Index: "c2", + Index: "c", CctxStatus: &types.Status{ CreatedTimestamp: sampleTimestamp.Unix(), LastUpdateTimestamp: sampleTimestamp.Unix(), }, OutboundParams: []*types.OutboundParams{ { - ReceiverChainId: 42, + ReceiverChainId: 1, CallOptions: &types.CallOptions{ GasLimit: 1000, }, @@ -362,6 +526,7 @@ func TestCheckAndUpdateCctxGasPrice(t *testing.T) { flags: observertypes.DefaultGasPriceIncreaseFlags, blockTimestamp: retryIntervalReached, medianGasPrice: 50, + medianPriorityFee: 20, expectWithdrawFromGasStabilityPoolCall: true, expectedGasPriceIncrease: math.NewUint(50), // 100% medianGasPrice expectedAdditionalFees: math.NewUint(50000), // gasLimit * increase @@ -380,22 +545,6 @@ func TestCheckAndUpdateCctxGasPrice(t *testing.T) { previousGasPrice = 0 } - // set median gas price if not zero - if tc.medianGasPrice != 0 { - k.SetGasPrice(ctx, types.GasPrice{ - ChainId: chainID, - Prices: []uint64{tc.medianGasPrice}, - PriorityFees: []uint64{tc.medianPriorityFee}, - MedianIndex: 0, - }) - - // ensure median gas price is set - medianGasPrice, medianPriorityFee, isFound := k.GetMedianGasValues(ctx, chainID) - require.True(t, isFound) - require.True(t, medianGasPrice.Equal(math.NewUint(tc.medianGasPrice))) - require.True(t, medianPriorityFee.Equal(math.NewUint(tc.medianPriorityFee))) - } - // set block timestamp ctx = ctx.WithBlockTime(tc.blockTimestamp) @@ -406,7 +555,14 @@ 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, + math.NewUint(tc.medianGasPrice), + math.NewUint(tc.medianPriorityFee), + tc.cctx, + tc.flags, + ) if tc.isError { require.Error(t, err) @@ -449,3 +605,68 @@ func TestCheckAndUpdateCctxGasPrice(t *testing.T) { }) } } + +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: "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) + + // 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 e3b71cfeb0..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, keeper.CheckAndUpdateCctxGasPrice) + am.keeper.IterateAndUpdateCCTXGasPrice(ctx, supportedChains, keeper.CheckAndUpdateCCTXGasPrice) } // EndBlock executes all ABCI EndBlock logic respective to the crosschain module. It 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/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/bitcoin.go b/zetaclient/chains/bitcoin/bitcoin.go index 0cfcbe1bad..c93fdea315 100644 --- a/zetaclient/chains/bitcoin/bitcoin.go +++ b/zetaclient/chains/bitcoin/bitcoin.go @@ -13,6 +13,7 @@ import ( "github.com/zeta-chain/node/pkg/ticker" "github.com/zeta-chain/node/zetaclient/chains/bitcoin/observer" "github.com/zeta-chain/node/zetaclient/chains/bitcoin/signer" + "github.com/zeta-chain/node/zetaclient/common" zctx "github.com/zeta-chain/node/zetaclient/context" "github.com/zeta-chain/node/zetaclient/outboundprocessor" ) @@ -72,6 +73,8 @@ func (b *Bitcoin) Start(ctx context.Context) error { return ticker.DurationFromUint64Seconds(b.observer.ChainParams().WatchUtxoTicker) }) + optMempoolInterval := scheduler.Interval(common.MempoolStuckTxCheckInterval) + optOutboundInterval := scheduler.IntervalUpdater(func() time.Duration { return ticker.DurationFromUint64Seconds(b.observer.ChainParams().OutboundTicker) }) @@ -101,6 +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, 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) diff --git a/zetaclient/chains/bitcoin/common/fee.go b/zetaclient/chains/bitcoin/common/fee.go index 84dad1687d..b84886f346 100644 --- a/zetaclient/chains/bitcoin/common/fee.go +++ b/zetaclient/chains/bitcoin/common/fee.go @@ -4,7 +4,6 @@ import ( "encoding/hex" "fmt" "math" - "math/big" "github.com/btcsuite/btcd/blockchain" "github.com/btcsuite/btcd/btcjson" @@ -20,19 +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) - OutboundBytesAvg = uint64(245) // 245vB is a suggested gas limit for zetacore + 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 @@ -49,6 +46,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) @@ -59,34 +57,28 @@ 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) { - if numInputs == 0 { +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) + // #nosec G115 checked positive + 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 +96,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 +129,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 +238,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 +278,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/common/fee_test.go b/zetaclient/chains/bitcoin/common/fee_test.go index 8967c86cfc..e73e4150b9 100644 --- a/zetaclient/chains/bitcoin/common/fee_test.go +++ b/zetaclient/chains/bitcoin/common/fee_test.go @@ -183,6 +183,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] @@ -193,10 +198,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 := 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) @@ -218,9 +222,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) @@ -242,11 +246,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) @@ -263,62 +267,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) { @@ -334,8 +338,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) @@ -354,8 +357,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) @@ -374,8 +376,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) @@ -394,8 +395,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) @@ -412,27 +412,26 @@ 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) 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) + 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, OutboundBytesAvg, txSizeAverage) require.Equal(t, txSizeAverage, txSizeDepositor+txSizeWithdrawer) // check default depositor fee @@ -459,5 +458,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/db.go b/zetaclient/chains/bitcoin/observer/db.go new file mode 100644 index 0000000000..8c16b7292d --- /dev/null +++ b/zetaclient/chains/bitcoin/observer/db.go @@ -0,0 +1,67 @@ +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.tssOutboundHashes[txHash] = true + 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.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..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(), }, }, }, @@ -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,13 +394,13 @@ 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) { // 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/gas_price.go b/zetaclient/chains/bitcoin/observer/gas_price.go new file mode 100644 index 0000000000..735d06f412 --- /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/common" + "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 { + 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") + } + } + + // 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: + return rpc.FeeRateRegnet, nil + case chains.NetworkType_testnet: + feeRateEstimated, err := common.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/inbound_test.go b/zetaclient/chains/bitcoin/observer/inbound_test.go index b438169b9f..614a48a403 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 @@ -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/observer/mempool.go b/zetaclient/chains/bitcoin/observer/mempool.go new file mode 100644 index 0000000000..e5000a5960 --- /dev/null +++ b/zetaclient/chains/bitcoin/observer/mempool.go @@ -0,0 +1,209 @@ +package observer + +import ( + "context" + "time" + + "github.com/btcsuite/btcd/btcutil" + "github.com/pkg/errors" + + "github.com/zeta-chain/node/pkg/chains" + "github.com/zeta-chain/node/zetaclient/chains/bitcoin/rpc" + "github.com/zeta-chain/node/zetaclient/chains/interfaces" + "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. + 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, + } +} + +// 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) + + if err := ob.RefreshLastStuckOutbound(ctx, GetLastPendingOutbound, txChecker); err != nil { + ob.Logger().Chain.Err(err).Msg("RefreshLastStuckOutbound failed") + } + return nil +} + +// 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 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 { + ob.logger.Outbound.Info().Fields(lf).Msgf("last pending outbound not found: %s", err.Error()) + return nil + } + + // log fields + 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? + 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) + } + + // 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. + // 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. + if stuck { + ob.SetLastStuckOutbound(NewLastStuckOutbound(lastNonce, lastTx, stuckFor)) + } else { + ob.SetLastStuckOutbound(nil) + } + + return nil +} + +// 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. +// +// 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 + ) + + // 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 + txResult := ob.GetIncludedTx(pendingNonce - 1) + if txResult != nil { + lastNonce = pendingNonce - 1 + 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") + } + // #nosec G115 always in range + for nonce := uint64(p.NonceLow); nonce < uint64(p.NonceHigh); nonce++ { + if nonce > lastNonce { + txID, found := ob.GetBroadcastedTx(nonce) + if found { + lastNonce = nonce + lastHash = txID + } + } + } + + // 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") + } + + // is tx in the mempool? + if _, err = ob.btcClient.GetMempoolEntry(lastHash); 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 { + 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 +} + +// 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 new file mode 100644 index 0000000000..19e0e632f8 --- /dev/null +++ b/zetaclient/chains/bitcoin/observer/mempool_test.go @@ -0,0 +1,443 @@ +package observer_test + +import ( + "context" + "errors" + "reflect" + "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/bitcoin/rpc" + "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.PendingTxFinder + txChecker observer.StuckTxChecker + oldStuckTx *observer.LastStuckOutbound + expectedTx *observer.LastStuckOutbound + errMsg string + }{ + { + name: "should set last stuck tx successfully", + 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: 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: makePendingTxFinder(sampleTx1, 1, ""), + txChecker: makeStuckTxChecker(false, 1*time.Minute, ""), + oldStuckTx: observer.NewLastStuckOutbound(1, sampleTx1, 30*time.Minute), + expectedTx: nil, + }, + { + name: "do nothing if unable to find last pending tx", + txFinder: makePendingTxFinder(nil, 0, "txFinder failed"), + expectedTx: nil, + }, + { + name: "should return error if txChecker failed", + txFinder: makePendingTxFinder(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_GetLastPendingOutbound(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 + failMempool 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 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, + 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.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 { + ob.client.On("GetRawTransaction", mock.Anything).Maybe().Return(nil, errors.New("failed")) + } + + ctx := context.Background() + lastTx, lastNonce, err := observer.GetLastPendingOutbound(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) + }) + } +} + +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 wait blocks for Bitcoin mainnet", + chainID: chains.BitcoinMainnet.ChainId, + expectedWaitBlocks: observer.PendingTxFeeBumpWaitBlocks, + }, + { + name: "should return wait blocks for Bitcoin testnet4", + chainID: chains.BitcoinTestnet.ChainId, + expectedWaitBlocks: observer.PendingTxFeeBumpWaitBlocks, + }, + { + name: "should return wait blocks for Bitcoin signet", + chainID: chains.BitcoinSignetTestnet.ChainId, + expectedWaitBlocks: observer.PendingTxFeeBumpWaitBlocks, + }, + { + name: "should return wait blocks for Bitcoin regtest", + chainID: chains.BitcoinRegtest.ChainId, + expectedWaitBlocks: observer.PendingTxFeeBumpWaitBlocksRegnet, + }, + } + + 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) + } + 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.go b/zetaclient/chains/bitcoin/observer/observer.go index d345f4da36..ba7bd26981 100644 --- a/zetaclient/chains/bitcoin/observer/observer.go +++ b/zetaclient/chains/bitcoin/observer/observer.go @@ -2,15 +2,10 @@ package observer import ( - "context" - "fmt" - "math" "math/big" - "sort" "sync/atomic" "github.com/btcsuite/btcd/btcjson" - "github.com/btcsuite/btcd/btcutil" "github.com/btcsuite/btcd/chaincfg" "github.com/btcsuite/btcd/wire" "github.com/pkg/errors" @@ -19,11 +14,10 @@ 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/common" "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" - clienttypes "github.com/zeta-chain/node/zetaclient/types" ) const ( @@ -69,11 +63,15 @@ type Observer struct { // pendingNonce is the outbound artificial pending nonce pendingNonce uint64 + // 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 @@ -126,9 +124,8 @@ func NewObserver( Observer: *baseObserver, netParams: netParams, btcClient: btcClient, - pendingNonce: 0, 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{ @@ -152,10 +149,6 @@ func NewObserver( return ob, nil } -func (ob *Observer) isNodeEnabled() bool { - return ob.nodeEnabled.Load() -} - // GetPendingNonce returns the artificial pending nonce // Note: pending nonce is accessed concurrently func (ob *Observer) GetPendingNonce() uint64 { @@ -164,6 +157,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 { @@ -177,142 +177,6 @@ func (ob *Observer) ConfirmationsThreshold(amount *big.Int) int64 { return int64(ob.ChainParams().ConfirmationCount) } -// 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 = common.FeeRateToSatPerByte(*feeResult.FeeRate).Uint64() - } - - // query the current block number - blockNumber, err := ob.btcClient.GetBlockCount() - if err != nil { - return errors.Wrap(err, "GetBlockCount error") - } - - // UTXO 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 -} - -// 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) - } - }() - - // noop - if !ob.isNodeEnabled() { - return nil - } - - // 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 errors.Wrap(err, "unable to get block height") - } - - maxConfirmations := int(bh) - - // List all unspent UTXOs (160ms) - tssAddr, err := ob.TSS().PubKey().AddressBTC(ob.Chain().ChainId) - if err != nil { - return errors.Wrap(err, "unable to get tss address") - } - - utxos, err := ob.btcClient.ListUnspentMinMaxAddresses(0, maxConfirmations, []btcutil.Address{tssAddr}) - if err != nil { - return errors.Wrap(err, "unable to list unspent utxo") - } - - // 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 < common.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 { @@ -346,67 +210,55 @@ 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) - } +// GetLastStuckOutbound returns the last stuck outbound tx information +func (ob *Observer) GetLastStuckOutbound() *LastStuckOutbound { + ob.Mu().Lock() + defer ob.Mu().Unlock() + return ob.lastStuckTx +} - // 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)) +// SetLastStuckOutbound sets the information of last stuck outbound +func (ob *Observer) SetLastStuckOutbound(stuckTx *LastStuckOutbound) { + lf := map[string]any{ + logs.FieldMethod: "SetLastStuckOutbound", } - // 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()) + ob.Mu().Lock() + defer ob.Mu().Unlock() - return nil + 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 } -// 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 +// 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() + _, found := ob.tssOutboundHashes[txid] + return found } -// 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 := common.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) - } +// 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 } -// 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 +func (ob *Observer) isNodeEnabled() bool { + return ob.nodeEnabled.Load() } diff --git a/zetaclient/chains/bitcoin/observer/observer_test.go b/zetaclient/chains/bitcoin/observer/observer_test.go index ab5415ef66..d7e3326e3d 100644 --- a/zetaclient/chains/bitcoin/observer/observer_test.go +++ b/zetaclient/chains/bitcoin/observer/observer_test.go @@ -5,14 +5,16 @@ 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 +166,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 +190,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 +203,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 +240,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 +299,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,20 +309,37 @@ func newTestSuite(t *testing.T, chain chains.Chain) *testSuite { zetacore := mocks.NewZetacoreClient(t) - database, err := db.NewFromSqliteInMemory(true) + var tss interfaces.TSSSigner + if chains.IsBitcoinMainnet(chain.ChainId) { + tss = mocks.NewTSS(t).FakePubKey(testutils.TSSPubKeyMainnet) + } else { + tss = mocks.NewTSS(t).FakePubKey(testutils.TSSPubkeyAthens3) + } + + // 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) - log := zerolog.New(zerolog.NewTestWriter(t)) + // create logger + testLogger := zerolog.New(zerolog.NewTestWriter(t)) + logger := base.Logger{Std: testLogger, Compliance: testLogger} + // create observer ob, err := observer.NewObserver( chain, client, chainParams, zetacore, - nil, + tss, database, - base.Logger{Std: log, Compliance: log}, - nil, + logger, + &metrics.TelemetryServer{}, ) require.NoError(t, err) diff --git a/zetaclient/chains/bitcoin/observer/outbound.go b/zetaclient/chains/bitcoin/observer/outbound.go index 7a7a0f372c..401a45eb24 100644 --- a/zetaclient/chains/bitcoin/observer/outbound.go +++ b/zetaclient/chains/bitcoin/observer/outbound.go @@ -18,69 +18,88 @@ import ( "github.com/zeta-chain/node/zetaclient/chains/bitcoin/rpc" "github.com/zeta-chain/node/zetaclient/chains/interfaces" "github.com/zeta-chain/node/zetaclient/compliance" + "github.com/zeta-chain/node/zetaclient/logs" "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 +) + func (ob *Observer) ObserveOutbound(ctx context.Context) error { chainID := ob.Chain().ChainId - trackers, err := ob.ZetacoreClient().GetAllOutboundTrackerByChain(ctx, chainID, interfaces.Ascending) if err != nil { return errors.Wrap(err, "unable to get all outbound trackers") } + // logger fields + lf := map[string]any{ + logs.FieldMethod: "ProcessOutboundTrackers", + } + for _, tracker := range trackers { - // get original cctx parameters - outboundID := ob.OutboundID(tracker.Nonce) + // set logger fields + lf[logs.FieldNonce] = tracker.Nonce + + // get the CCTX cctx, err := ob.ZetacoreClient().GetCctxByNonce(ctx, chainID, tracker.Nonce) if err != nil { - return errors.Wrapf(err, "unable to get cctx by nonce %d", tracker.Nonce) + ob.logger.Outbound.Err(err).Fields(lf).Msg("cannot find cctx") + break } - - nonce := cctx.GetCurrentOutboundParam().TssNonce - if tracker.Nonce != nonce { // Tanmay: it doesn't hurt to check - return fmt.Errorf("tracker nonce %d not match cctx nonce %d", tracker.Nonce, nonce) - } - if len(tracker.HashList) > 1 { - ob.logger.Outbound.Warn(). - Msgf("WatchOutbound: oops, outboundID %s got multiple (%d) outbound hashes", outboundID, len(tracker.HashList)) + ob.logger.Outbound.Warn().Msgf("oops, got multiple (%d) outbound hashes", 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 + // 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 { - 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) - } + _, included := ob.TryIncludeOutbound(ctx, cctx, txHash.TxHash) + if included { + break } } - - 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) - } } return nil } -// VoteOutboundIfConfirmed checks outbound status and returns (continueKeysign, error) -func (ob *Observer) VoteOutboundIfConfirmed( +// 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) +// +// 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, -) (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 @@ -103,6 +122,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 @@ -117,26 +139,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(). @@ -205,105 +216,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. @@ -316,35 +228,29 @@ 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. - 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 @@ -374,104 +280,101 @@ 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 := common.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) +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) + // check minimum confirmations + if txResult.Confirmations < minTxConfirmations { + ob.logger.Outbound.Warn().Fields(lf).Msgf("invalid confirmations %d", txResult.Confirmations) 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 + // 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 } - return getTxResult, true // in mempool + + // tx is valid and included + return txResult, true } -// setIncludedTx saves included tx result in memory -func (ob *Observer) setIncludedTx(nonce uint64, getTxResult *btcjson.GetTransactionResult) { - txHash := getTxResult.TxID - outboundID := ob.OutboundID(nonce) +// 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) { + 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. - 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 + 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.tssOutboundHashes[txHash] = true + 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.tssOutboundHashes, res.TxID) + delete(ob.includedTxResults, outboundID) + + // add new txHash and txResult + 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)] } -// 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 // @@ -486,7 +389,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 { @@ -532,9 +435,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..b89ce14c5d --- /dev/null +++ b/zetaclient/chains/bitcoin/observer/utxos.go @@ -0,0 +1,220 @@ +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/common" + 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) + + // 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, 9999999, []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 < common.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 := common.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 a553945a7e..2f248052da 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,21 @@ 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 + + // 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 + + // 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 +148,37 @@ 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, 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") + } + if feeResult.Errors != nil { + return 0, fmt.Errorf("fee result contains errors: %s", feeResult.Errors) + } + if feeResult.FeeRate == nil { + return 0, fmt.Errorf("nil fee rate") + } + if *feeResult.FeeRate <= 0 || *feeResult.FeeRate >= maxBTCSupply { + return 0, fmt.Errorf("invalid fee rate: %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 +230,136 @@ 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 +} + +// 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 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 +// +// Returns: (totalTxs, totalFees, totalVSize, error) +func GetTotalMempoolParentsSizeNFees( + client interfaces.BTCRPCClient, + childHash string, + timeout time.Duration, +) (int64, float64, int64, int64, error) { + var ( + totalTxs int64 + totalFees float64 + totalVSize int64 + avgFeeRate int64 + ) + + // loop through all parents + startTime := time.Now() + 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) + } + + // accumulate 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() + + // 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 + if totalTxs == 0 { + 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 + 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_live_test.go b/zetaclient/chains/bitcoin/rpc/rpc_live_test.go index 61369991d9..c26912d508 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 := 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,31 @@ 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_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 +163,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 +176,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 +480,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..41f2bdc400 --- /dev/null +++ b/zetaclient/chains/bitcoin/rpc/rpc_rbf_live_test.go @@ -0,0 +1,551 @@ +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" + btccommon "github.com/zeta-chain/node/zetaclient/chains/bitcoin/common" + "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.BitcoinMainnet + 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_RBFTransaction(t) + LiveTest_RBFTransaction_Chained_CPFP(t) + 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 { + require.Positive(t, entry.Fee) + txTime := time.Unix(entry.Time, 0) + txTimeStr := txTime.Format(time.DateTime) + elapsed := time.Since(txTime) + if elapsed > 30*time.Minute { + // 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 := btccommon.GetSatoshis(total) + require.NoError(t, err) + + // amount to send in satoshis + amountSats, err := btccommon.GetSatoshis(amount) + require.NoError(t, err) + + // calculate tx fee + txSize, err := btccommon.EstimateOutboundSize(int64(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/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 new file mode 100644 index 0000000000..d9a182243f --- /dev/null +++ b/zetaclient/chains/bitcoin/signer/fee_bumper.go @@ -0,0 +1,205 @@ +package signer + +import ( + "fmt" + "math" + "time" + + "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/chains" + "github.com/zeta-chain/node/pkg/constant" + mathpkg "github.com/zeta-chain/node/pkg/math" + "github.com/zeta-chain/node/zetaclient/chains/bitcoin/common" + "github.com/zeta-chain/node/zetaclient/chains/bitcoin/rpc" + "github.com/zeta-chain/node/zetaclient/chains/interfaces" +) + +const ( + // 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 + 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 + // in replacing stuck transactions while avoiding excessive sensitivity to fee market fluctuations. + minCPFPFeeBumpPercent = 20 +) + +// MempoolTxsInfoFetcher is a function type to fetch mempool txs information +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 { + Chain chains.Chain + + // 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( + chain chains.Chain, + client interfaces.BTCRPCClient, + memplTxsInfoFetcher MempoolTxsInfoFetcher, + tx *btcutil.Tx, + cctxRate int64, + minRelayFee float64, + logger zerolog.Logger, +) (*CPFPFeeBumper, error) { + fb := &CPFPFeeBumper{ + Chain: chain, + 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 transaction using reserved bump fees +func (b *CPFPFeeBumper) BumpTxFee() (*wire.MsgTx, int64, int64, error) { + // 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") + } + + // 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) + if b.CCTXRate < minBumpRate { + return nil, 0, 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 := 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", + b.LiveRate, + b.CCTXRate, + ) + } + + // 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 + // 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*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", + additionalFees, + minRelayTxFees, + ) + } + + // 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] + } + + // effective fee rate + feeRateNew = int64(math.Ceil(float64(b.TotalFees+additionalFees) / float64(b.TotalVSize))) + + return newTx, additionalFees, feeRateNew, nil +} + +// fetchFeeBumpInfo fetches all necessary information needed to bump the stuck tx +func (b *CPFPFeeBumper) FetchFeeBumpInfo(memplTxsInfoFetcher MempoolTxsInfoFetcher, logger zerolog.Logger) error { + // 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") + } + 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(), time.Minute) + if err != nil { + return errors.Wrap(err, "unable to fetch mempool txs info") + } + totalFeesSats, err := common.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 +} + +// 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 new file mode 100644 index 0000000000..a5c4037a86 --- /dev/null +++ b/zetaclient/chains/bitcoin/signer/fee_bumper_test.go @@ -0,0 +1,371 @@ +package signer_test + +import ( + "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/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 + chain chains.Chain + client *mocks.BTCRPCClient + tx *btcutil.Tx + cctxRate int64 + liveRate float64 + minRelayFee float64 + memplTxsInfoFetcher signer.MempoolTxsInfoFetcher + errMsg string + expected *signer.CPFPFeeBumper + }{ + { + chain: chains.BitcoinMainnet, + 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/vB + "", // no error + ), + expected: &signer.CPFPFeeBumper{ + Chain: chains.BitcoinMainnet, + Tx: btcutil.NewTx(wire.NewMsgTx(wire.TxVersion)), + MinRelayFee: 0.00001, + CCTXRate: 10, + LiveRate: 12, + TotalTxs: 2, + TotalFees: 10000, + TotalVSize: 1000, + AvgFeeRate: 10, + }, + }, + { + chain: chains.BitcoinMainnet, + 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.chain, + 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) + + tests := []struct { + name string + feeBumper *signer.CPFPFeeBumper + additionalFees int64 + expectedNewRate int64 + expectedNewTx *wire.MsgTx + errMsg string + }{ + { + 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, + expectedNewRate: 57, + expectedNewTx: func() *wire.MsgTx { + // deduct additional fees + newTx := signer.CopyMsgTxNoWitness(msgTx) + newTx.TxOut[2].Value -= 5790 + 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 + expectedNewRate: 59, // (27213 + 6789) / 579 ≈ 59 + expectedNewTx: func() *wire.MsgTx { + // give up all reserved bump fees + newTx := signer.CopyMsgTxNoWitness(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 := signer.CopyMsgTxNoWitness(msgTx) + newTx.TxOut[2].Value -= 30687 + 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: 55, // 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", + }, + { + 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 { + t.Run(tt.name, func(t *testing.T) { + 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.expectedNewTx, newTx) + require.Equal(t, tt.additionalFees, additionalFees) + require.Equal(t, tt.expectedNewRate, newRate) + } + }) + } +} + +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/vB + "", // 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) + } + }) + } +} + +func Test_CopyMsgTxNoWitness(t *testing.T) { + 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 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) + }) + + 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 +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, 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 new file mode 100644 index 0000000000..8a4cf5581c --- /dev/null +++ b/zetaclient/chains/bitcoin/signer/outbound_data.go @@ -0,0 +1,137 @@ +package signer + +import ( + "fmt" + "math" + "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 + + // amountSats is the amount in satoshis + amountSats int64 + + // 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 { + return nil, errors.New("can only send gas token to a Bitcoin network") + } + + // initial fee rate + feeRate, err := strconv.ParseInt(params.GasPrice, 10, 64) + if err != nil || feeRate <= 0 { + return nil, fmt.Errorf("invalid fee rate %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 fee 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 { + return nil, errors.Wrapf(err, "cannot decode receiver address %s", params.Receiver) + } + 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 + 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 + amountSats = 0 + } + + return &OutboundData{ + 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, + cancelTx: cancelTx, + }, nil +} 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..55b4eb7349 --- /dev/null +++ b/zetaclient/chains/bitcoin/signer/outbound_data_test.go @@ -0,0 +1,241 @@ +package signer + +import ( + "math" + "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, + amountSats: 10000000, + feeRate: 11, // 10 + 1 (minRelayFee) + txSize: 254, + nonce: 1, + height: 101, + cancelTx: false, + }, + 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, + amountSats: 10000000, + feeRate: 16, // 15 + 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: "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", + 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: "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"), + 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 + amountSats: 0, + 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 + amountSats: 0, + 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.go b/zetaclient/chains/bitcoin/signer/sign.go new file mode 100644 index 0000000000..8327ef1e0d --- /dev/null +++ b/zetaclient/chains/bitcoin/signer/sign.go @@ -0,0 +1,256 @@ +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/pkg/constant" + "github.com/zeta-chain/node/zetaclient/chains/bitcoin/common" + "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 + + // 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 +func (signer *Signer) SignWithdrawTx( + ctx context.Context, + txData *OutboundData, + ob *observer.Observer, +) (*wire.MsgTx, error) { + nonceMark := chains.NonceMarkAmount(txData.nonce) + estimateFee := float64(txData.feeRate*common.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, which may trigger unwanted RBF + // + // 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, otherwise it will fail. + 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, + totalAmount, + 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 := common.EstimateOutboundSize(int64(len(prevOuts)), []btcutil.Address{txData.to}) + if err != nil { + return nil, err + } + if txData.txSize < common.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 < common.OutboundBytesMin { // outbound shouldn't be blocked by low sizeLimit + signer.Logger().Std.Warn(). + Msgf("txSize %d is less than outboundBytesMin %d; use outboundBytesMin", txSize, common.OutboundBytesMin) + txSize = common.OutboundBytesMin + } + if txSize > common.OutboundBytesMax { // in case of accident + signer.Logger().Std.Warn(). + Msgf("txSize %d is greater than outboundBytesMax %d; use outboundBytesMax", txSize, common.OutboundBytesMax) + txSize = common.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.amountSats, 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 := common.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, + amountSats int64, + nonceMark int64, + fees int64, + cancelTx bool, +) error { + // convert withdraw amount to BTC + amount := float64(amountSats) / 1e8 + + // calculate remaining btc (the change) to TSS self + remaining := total - amount + remainingSats, err := common.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(amountSats, pkScript) + tx.AddTxOut(txOut2) + } else { + // send the amount to TSS self if tx is cancelled + remainingSats += amountSats + } + + // 3rd output: the remaining btc to TSS self + if remainingSats >= constant.BTCWithdrawalDustAmount { + 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_rbf.go b/zetaclient/chains/bitcoin/signer/sign_rbf.go new file mode 100644 index 0000000000..090a409a42 --- /dev/null +++ b/zetaclient/chains/bitcoin/signer/sign_rbf.go @@ -0,0 +1,103 @@ +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/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" +) + +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 + // See: https://github.com/BlockchainCommons/Learning-Bitcoin-from-the-Command-Line/blob/master/05_2_Resending_a_Transaction_with_RBF.md + 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, + height uint64, + lastTx *btcutil.Tx, + minRelayFee float64, + memplTxsInfoFetcher MempoolTxsInfoFetcher, +) (*wire.MsgTx, error) { + var ( + params = cctx.GetCurrentOutboundParam() + lf = map[string]any{ + logs.FieldMethod: "SignRBFTx", + logs.FieldNonce: params.TssNonce, + logs.FieldTx: lastTx.MsgTx().TxID(), + } + logger = signer.Logger().Std.With().Fields(lf).Logger() + ) + + 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 + recentRate, err := strconv.ParseInt(params.GasPriorityFee, 10, 64) + if err != nil || recentRate <= 0 { + return nil, fmt.Errorf("invalid fee rate %s", params.GasPriorityFee) + } + cctxRate = recentRate + } + + // create fee bumper + fb, err := NewCPFPFeeBumper( + signer.Chain(), + signer.client, + memplTxsInfoFetcher, + lastTx, + cctxRate, + minRelayFee, + logger, + ) + if err != nil { + return nil, errors.Wrap(err, "NewCPFPFeeBumper failed") + } + + // bump tx fees + newTx, additionalFees, newRate, err := fb.BumpTxFee() + if err != nil { + return nil, errors.Wrap(err, "BumpTxFee failed") + } + 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)) + 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, height, params.TssNonce) + if err != nil { + return nil, errors.Wrap(err, "SignTx failed") + } + + return newTx, nil +} 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..325b758b7b --- /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 := signer.CopyMsgTxNoWitness(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_test.go b/zetaclient/chains/bitcoin/signer/sign_test.go new file mode 100644 index 0000000000..f086720b30 --- /dev/null +++ b/zetaclient/chains/bitcoin/signer/sign_test.go @@ -0,0 +1,257 @@ +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 := 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 + 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, + amountSats: 20000000, + 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, + 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, + amountSats: 20000000, + nonceMark: 10000, + fees: 2000, + cancelTx: true, + fail: false, + txout: []*wire.TxOut{ + {Value: 10000, PkScript: tssScript}, + {Value: 100000000, PkScript: tssScript}, + }, + }, + { + 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 < 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 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}, + {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 + 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, + 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.amountSats, + 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)) + } + }) + } +} + +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 34c0f592a7..a0e1ebb2d8 100644 --- a/zetaclient/chains/bitcoin/signer/signer.go +++ b/zetaclient/chains/bitcoin/signer/signer.go @@ -5,41 +5,23 @@ import ( "bytes" "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" "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/common" "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/compliance" - "github.com/zeta-chain/node/zetaclient/config" "github.com/zeta-chain/node/zetaclient/logs" "github.com/zeta-chain/node/zetaclient/outboundprocessor" ) const ( - // the maximum number of inputs per outbound - MaxNoOfInputsPerTx = 20 - - // the rank below (or equal to) which we consolidate UTXOs - consolidationRank = 10 - // broadcastBackoff is the initial backoff duration for retrying broadcast broadcastBackoff = 1000 * time.Millisecond @@ -58,221 +40,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 -} - -// 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 := common.GetSatoshis(amount) - if err != nil { - return err - } - - // calculate remaining btc (the change) to TSS self - remaining := total - amount - remainingSats, err := common.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 - 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()*common.OutboundBytesMax) / 1e8 - nonceMark := chains.NonceMarkAmount(nonce) - - // refresh unspent UTXOs and continue with keysign regardless of error - if err := observer.FetchUTXOs(ctx); err != nil { - signer.Logger().Std.Error().Err(err).Uint64("nonce", nonce).Msg("SignWithdrawTx: FetchUTXOs failed") - } - - // 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, errors.Wrap(err, "unable to select UTXOs") - } - - // 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, errors.Wrap(err, "unable to construct hash") - } - - outpoint := wire.NewOutPoint(hash, prevOut.Vout) - txIn := wire.NewTxIn(outpoint, nil, nil) - tx.AddTxIn(txIn) - } - - // size checking - // #nosec G115 always positive - txSize, err := common.EstimateOutboundSize(uint64(len(prevOuts)), []btcutil.Address{to}) - if err != nil { - return nil, errors.Wrap(err, "unable to estimate tx size") - } - if sizeLimit < common.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 < common.OutboundBytesMin { // outbound shouldn't be blocked a low sizeLimit - signer.Logger().Std.Warn(). - Msgf("txSize %d is less than outboundBytesMin %d; use outboundBytesMin", txSize, common.OutboundBytesMin) - txSize = common.OutboundBytesMin - } - if txSize > common.OutboundBytesMax { // in case of accident - signer.Logger().Std.Warn(). - Msgf("txSize %d is greater than outboundBytesMax %d; use outboundBytesMax", txSize, common.OutboundBytesMax) - txSize = common.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, errors.Wrap(err, "unable to add withdrawal tx outputs") - } - - // sign the tx - sigHashes := txscript.NewTxSigHashes(tx, txscript.NewCannedPrevOutputFetcher([]byte{}, 0)) - witnessHashes := make([][]byte, len(tx.TxIn)) - for ix := range tx.TxIn { - amt, err := common.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, errors.Wrap(err, "unable to batch sign") - } - - 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 } // Broadcast sends the signed transaction to the network @@ -283,7 +61,7 @@ func (signer *Signer) Broadcast(signedTx *wire.MsgTx) error { } signer.Logger().Std.Info(). - Stringer("signer.tx_hash", signedTx.TxHash()). + Str(logs.FieldTx, signedTx.TxHash().String()). Str("signer.tx_payload", hex.EncodeToString(outBuff.Bytes())). Msg("Broadcasting transaction") @@ -295,8 +73,16 @@ func (signer *Signer) Broadcast(signedTx *wire.MsgTx) error { return nil } +// PkScriptTSS returns the TSS pkScript +func (signer *Signer) PkScriptTSS() ([]byte, error) { + tssAddrP2WPKH, err := signer.TSS().PubKey().AddressBTC(signer.Chain().ChainId) + if err != nil { + return nil, err + } + return txscript.PayToAddrScript(tssAddrP2WPKH) +} + // 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, @@ -315,136 +101,131 @@ 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 - } - - chain := observer.Chain() - outboundTssNonce := params.TssNonce signerAddress, err := zetacoreClient.GetKeys().GetAddress() - if err != nil { - logger.Error().Err(err).Msg("cannot get signer address") - return - } - lf["signer"] = signerAddress.String() - - // 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 + if err == nil { + lf["signer"] = signerAddress.String() } + logger := signer.Logger().Std.With().Fields(lf).Logger() - // Check receiver P2WPKH address - to, err := chains.DecodeBtcAddress(params.Receiver, params.ReceiverChainId) + // query network info to get minRelayFee (typically 1000 satoshis) + networkInfo, err := signer.client.GetNetworkInfo() if err != nil { - logger.Error().Err(err).Msgf("cannot decode address %s ", params.Receiver) + logger.Error().Err(err).Msgf("failed get bitcoin network info") return } - if !chains.IsBtcAddressSupported(to) { - logger.Error().Msgf("unsupported address %s", params.Receiver) + minRelayFee := networkInfo.RelayFee + if minRelayFee <= 0 { + logger.Error().Msgf("invalid minimum relay fee: %f", minRelayFee) return } - amount := float64(params.Amount.Uint64()) / 1e8 - // Add 1 satoshi/byte to gasPrice to avoid minRelayTxFee issue - networkInfo, err := signer.client.GetNetworkInfo() - if err != nil { - logger.Error().Err(err).Msgf("cannot get bitcoin network info") - return - } - satPerByte := common.FeeRateToSatPerByte(networkInfo.RelayFee) - gasprice.Add(gasprice, satPerByte) + var ( + rbfTx = false + signedTx *wire.MsgTx + stuckTx = observer.GetLastStuckOutbound() + ) - // 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") - } + // 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 { + logger.Error().Err(err).Msg("SignRBFTx failed") + return + } + 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) + if err != nil { + logger.Error().Err(err).Msg("failed to setup Bitcoin outbound data") + return + } - // check dust amount - dustAmount := params.Amount.Uint64() < constant.BTCWithdrawalDustAmount - if dustAmount { - logger.Warn().Msgf("dust amount %d sats, canceling tx", params.Amount.Uint64()) + // sign withdraw tx + signedTx, err = signer.SignWithdrawTx(ctx, txData, observer) + if err != nil { + logger.Error().Err(err).Msg("SignWithdrawTx failed") + return + } + logger.Info().Str(logs.FieldTx, signedTx.TxID()).Msg("SignWithdrawTx succeed") } - // set the amount to 0 when the tx should be cancelled - cancelTx := restrictedCCTX || dustAmount - if cancelTx { - amount = 0.0 - } + // broadcast signed outbound + signer.BroadcastOutbound(ctx, signedTx, params.TssNonce, rbfTx, cctx, observer, zetacoreClient) +} - // sign withdraw tx - tx, err := signer.SignWithdrawTx( - ctx, - to, - amount, - gasprice, - sizelimit, - observer, - height, - outboundTssNonce, - chain, - cancelTx, - ) - if err != nil { - logger.Warn().Err(err).Msg("SignWithdrawTx failed") - return +// BroadcastOutbound sends the signed transaction to the Bitcoin network +func (signer *Signer) BroadcastOutbound( + ctx context.Context, + tx *wire.MsgTx, + nonce uint64, + rbfTx bool, + 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, } - logger.Info().Msg("Key-sign success") + logger := signer.Logger().Std - // 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") + // 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 + } } - 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") + // 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 - observer.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).Msg("broadcasted Bitcoin outbound successfully") + + // 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).Msg("unable to add Bitcoin outbound tracker") + } else { + lf[logs.FieldZetaTx] = zetaHash + logger.Info().Fields(lf).Msg("add Bitcoin outbound tracker successfully") + } - break // successful broadcast; no need to retry + // try including this outbound as early as possible + _, included := ob.TryIncludeOutbound(ctx, cctx, txHash) + if included { + logger.Info().Fields(lf).Msg("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 f06ad4a9c2..9187213e3b 100644 --- a/zetaclient/chains/bitcoin/signer/signer_test.go +++ b/zetaclient/chains/bitcoin/signer/signer_test.go @@ -1,72 +1,203 @@ -package signer +package signer_test import ( + "context" "encoding/hex" - "fmt" - "math/big" - "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() + + // create logger + testLogger := zerolog.New(zerolog.NewTestWriter(t)) + logger := base.Logger{Std: testLogger, Compliance: testLogger} + + // create signer + signer := signer.NewSigner( + chain, + rpcClient, + tss, + logger, + ) + + 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 Test_BroadcastOutbound(t *testing.T) { + // test cases + tests := []struct { + name string + chain chains.Chain + nonce uint64 + rbfTx bool + skipRBFTx bool + failTracker bool + }{ + { + name: "should successfully broadcast and include outbound", + chain: chains.BitcoinMainnet, + nonce: uint64(148), + }, + { + 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 { + t.Run(tt.name, func(t *testing.T) { + // setup signer 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).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 { + 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, + }) + + // 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, + ) + + // check if outbound is included + gotResult := observer.GetIncludedTx(tt.nonce) + if tt.skipRBFTx { + require.Nil(t, gotResult) + } else { + require.Equal(t, txResult, gotResult) + } + }) + } } -func (s *BTCSignerSuite) TestP2PH(c *C) { +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 @@ -76,8 +207,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) @@ -108,7 +238,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 @@ -119,26 +249,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 @@ -148,7 +276,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() @@ -169,7 +297,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( @@ -182,7 +310,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. @@ -191,10 +319,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) } { @@ -206,8 +334,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 @@ -216,182 +344,68 @@ 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) +func makeCtx(t *testing.T) context.Context { + app := zctx.New(config.New(false), nil, zerolog.Nop()) - // 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) + 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 *big.Int - 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: big.NewInt(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: big.NewInt(2000), - fail: false, - txout: []*wire.TxOut{ - {Value: 10000, PkScript: tssScript}, - {Value: 20000000, PkScript: toScript}, - }, + err := app.Update( + []chains.Chain{chain, chains.ZetaChainMainnet}, + nil, + map[int64]*observertypes.ChainParams{ + chain.ChainId: &btcParams, }, - { - name: "should cancel tx successfully", - tx: wire.NewMsgTx(wire.TxVersion), - to: to, - total: 1.00012000, - amount: 0.2, - nonce: 10000, - fees: big.NewInt(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: big.NewInt(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: big.NewInt(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: big.NewInt(2000), - fail: true, - }, - } + 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) + 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{}) + + // create logger + testLogger := zerolog.New(zerolog.NewTestWriter(t)) + logger := base.Logger{Std: testLogger, Compliance: testLogger} + + 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/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/chains/interfaces/interfaces.go b/zetaclient/chains/interfaces/interfaces.go index a8fe9c71fc..12ca23a717 100644 --- a/zetaclient/chains/interfaces/interfaces.go +++ b/zetaclient/chains/interfaces/interfaces.go @@ -164,6 +164,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..e7714ff5e6 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 = 30 * time.Second ) 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 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 122454db3b..be71824e19 100644 --- a/zetaclient/orchestrator/orchestrator.go +++ b/zetaclient/orchestrator/orchestrator.go @@ -408,12 +408,12 @@ 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(): // Managed by orchestrator V2 continue 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: @@ -428,8 +428,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, @@ -439,7 +439,7 @@ func (oc *Orchestrator) ScheduleCctxEVM( ) { res, err := oc.zetacoreClient.GetAllOutboundTrackerByChain(ctx, chainID, interfaces.Ascending) if err != nil { - oc.logger.Warn().Err(err).Msgf("ScheduleCctxEVM: GetAllOutboundTrackerByChain failed for chain %d", chainID) + oc.logger.Warn().Err(err).Msgf("ScheduleCCTXEVM: GetAllOutboundTrackerByChain failed for chain %d", chainID) return } trackerMap := make(map[uint64]bool) @@ -461,11 +461,11 @@ func (oc *Orchestrator) ScheduleCctxEVM( if params.ReceiverChainId != chainID { oc.logger.Error(). - Msgf("ScheduleCctxEVM: outbound %s chainid mismatch: want %d, got %d", outboundID, chainID, params.ReceiverChainId) + Msgf("ScheduleCCTXEVM: outbound %s chainid mismatch: want %d, got %d", outboundID, chainID, params.ReceiverChainId) continue } if params.TssNonce > cctxList[0].GetCurrentOutboundParam().TssNonce+outboundScheduleLookback { - oc.logger.Error().Msgf("ScheduleCctxEVM: nonce too high: signing %d, earliest pending %d, chain %d", + oc.logger.Error().Msgf("ScheduleCCTXEVM: nonce too high: signing %d, earliest pending %d, chain %d", params.TssNonce, cctxList[0].GetCurrentOutboundParam().TssNonce, chainID) break } @@ -475,12 +475,12 @@ func (oc *Orchestrator) ScheduleCctxEVM( if err != nil { oc.logger.Error(). Err(err). - Msgf("ScheduleCctxEVM: VoteOutboundIfConfirmed failed for chain %d nonce %d", chainID, nonce) + Msgf("ScheduleCCTXEVM: VoteOutboundIfConfirmed failed for chain %d nonce %d", chainID, nonce) continue } if !continueKeysign { oc.logger.Info(). - Msgf("ScheduleCctxEVM: outbound %s already processed; do not schedule keysign", outboundID) + Msgf("ScheduleCCTXEVM: outbound %s already processed; do not schedule keysign", outboundID) continue } @@ -508,7 +508,7 @@ func (oc *Orchestrator) ScheduleCctxEVM( !oc.outboundProc.IsOutboundActive(outboundID) { oc.outboundProc.StartTryProcess(outboundID) oc.logger.Debug(). - Msgf("ScheduleCctxEVM: sign outbound %s with value %d", outboundID, cctx.GetCurrentOutboundParam().Amount) + Msgf("ScheduleCCTXEVM: sign outbound %s with value %d", outboundID, cctx.GetCurrentOutboundParam().Amount) go signer.TryProcessOutbound( ctx, cctx, @@ -527,8 +527,8 @@ func (oc *Orchestrator) ScheduleCctxEVM( } } -// 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, @@ -538,7 +538,7 @@ func (oc *Orchestrator) ScheduleCctxSolana( ) { solObserver, ok := observer.(*solanaobserver.Observer) if !ok { // should never happen - oc.logger.Error().Msgf("ScheduleCctxSolana: chain observer is not a solana observer") + oc.logger.Error().Msgf("ScheduleCCTXSolana: chain observer is not a solana observer") return } @@ -556,11 +556,11 @@ func (oc *Orchestrator) ScheduleCctxSolana( if params.ReceiverChainId != chainID { oc.logger.Error(). - Msgf("ScheduleCctxSolana: outbound %s chainid mismatch: want %d, got %d", outboundID, chainID, params.ReceiverChainId) + Msgf("ScheduleCCTXSolana: outbound %s chainid mismatch: want %d, got %d", outboundID, chainID, params.ReceiverChainId) continue } if params.TssNonce > cctxList[0].GetCurrentOutboundParam().TssNonce+outboundScheduleLookback { - oc.logger.Warn().Msgf("ScheduleCctxSolana: nonce too high: signing %d, earliest pending %d", + oc.logger.Warn().Msgf("ScheduleCCTXSolana: nonce too high: signing %d, earliest pending %d", params.TssNonce, cctxList[0].GetCurrentOutboundParam().TssNonce) break } @@ -570,19 +570,19 @@ func (oc *Orchestrator) ScheduleCctxSolana( if err != nil { oc.logger.Error(). Err(err). - Msgf("ScheduleCctxSolana: VoteOutboundIfConfirmed failed for chain %d nonce %d", chainID, nonce) + Msgf("ScheduleCCTXSolana: VoteOutboundIfConfirmed failed for chain %d nonce %d", chainID, nonce) continue } if !continueKeysign { oc.logger.Info(). - Msgf("ScheduleCctxSolana: outbound %s already processed; do not schedule keysign", outboundID) + Msgf("ScheduleCCTXSolana: outbound %s already processed; do not schedule keysign", outboundID) continue } // schedule a TSS keysign if nonce%interval == zetaHeight%interval && !oc.outboundProc.IsOutboundActive(outboundID) { oc.outboundProc.StartTryProcess(outboundID) - oc.logger.Debug().Msgf("ScheduleCctxSolana: sign outbound %s with value %d", outboundID, params.Amount) + oc.logger.Debug().Msgf("ScheduleCCTXSolana: sign outbound %s with value %d", outboundID, params.Amount) go signer.TryProcessOutbound( ctx, cctx, diff --git a/zetaclient/orchestrator/v2_bootstrap.go b/zetaclient/orchestrator/v2_bootstrap.go index 1962433c1c..90d19a9214 100644 --- a/zetaclient/orchestrator/v2_bootstrap.go +++ b/zetaclient/orchestrator/v2_bootstrap.go @@ -49,7 +49,6 @@ func (oc *V2) bootstrapBitcoin(ctx context.Context, chain zctx.Chain) (*bitcoin. // TODO extract base observer // TODO extract base signer // https://github.com/zeta-chain/node/issues/3331 - observer, err := btcobserver.NewObserver( *rawChain, rpcClient, @@ -64,7 +63,7 @@ func (oc *V2) bootstrapBitcoin(ctx context.Context, chain zctx.Chain) (*bitcoin. return nil, errors.Wrap(err, "unable to create observer") } - signer, err := btcsigner.NewSigner(*rawChain, oc.deps.TSS, oc.logger.base, cfg) + signer := btcsigner.NewSigner(*rawChain, rpcClient, oc.deps.TSS, oc.logger.base) if err != nil { return nil, errors.Wrap(err, "unable to create signer") } 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/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() 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) 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) }