diff --git a/go.mod b/go.mod index 05a7166c93..eff31d76be 100644 --- a/go.mod +++ b/go.mod @@ -126,7 +126,7 @@ require ( github.com/davidlazar/go-crypto v0.0.0-20200604182044-b73af7476f6c // indirect github.com/deckarep/golang-set v1.8.0 // indirect github.com/decred/dcrd/dcrec/edwards/v2 v2.0.0 // indirect - github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0 // indirect + github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0 github.com/desertbit/timer v0.0.0-20180107155436-c41aec40b27f // indirect github.com/dgraph-io/badger/v4 v4.2.0 // indirect github.com/dgraph-io/ristretto v0.1.1 // indirect diff --git a/x/crosschain/keeper/abci.go b/x/crosschain/keeper/abci.go index 03d13fb6d5..d18648e6e4 100644 --- a/x/crosschain/keeper/abci.go +++ b/x/crosschain/keeper/abci.go @@ -52,8 +52,8 @@ func (k Keeper) IterateAndUpdateCctxGasPrice( IterateChains: for _, chain := range chains { - // support only external evm chains - if zetachains.IsEVMChain(chain.ChainId, additionalChains) && !zetachains.IsZetaChain(chain.ChainId, additionalChains) { + // support only external evm chains and bitcoin chain + if IsGasStabilityPoolEnabledChain(chain.ChainId, additionalChains) { res, err := k.ListPendingCctx(sdk.UnwrapSDKContext(ctx), &types.QueryListPendingCctxRequest{ ChainId: chain.ChainId, Limit: gasPriceIncreaseFlags.MaxPendingCctxs, @@ -175,3 +175,15 @@ func CheckAndUpdateCctxGasPrice( return gasPriceIncrease, additionalFees, nil } + +// IsGasStabilityPoolEnabledChain returns true if given chainID is enabled for gas stability pool +func IsGasStabilityPoolEnabledChain(chainID int64, additionalChains []zetachains.Chain) bool { + switch { + case zetachains.IsEVMChain(chainID, additionalChains): + return !zetachains.IsZetaChain(chainID, additionalChains) + case zetachains.IsBitcoinChain(chainID, additionalChains): + return true + default: + return false + } +} diff --git a/x/crosschain/keeper/abci_test.go b/x/crosschain/keeper/abci_test.go index 32499e1827..2e1cbd246b 100644 --- a/x/crosschain/keeper/abci_test.go +++ b/x/crosschain/keeper/abci_test.go @@ -10,6 +10,7 @@ import ( "github.com/stretchr/testify/require" "github.com/zeta-chain/node/pkg/chains" + zetachains "github.com/zeta-chain/node/pkg/chains" testkeeper "github.com/zeta-chain/node/testutil/keeper" "github.com/zeta-chain/node/testutil/sample" "github.com/zeta-chain/node/x/crosschain/keeper" @@ -449,3 +450,48 @@ func TestCheckAndUpdateCctxGasPrice(t *testing.T) { }) } } + +func TestIsGasStabilityPoolEnabledChain(t *testing.T) { + tests := []struct { + name string + chainID int64 + expected bool + }{ + { + name: "Ethereum is enabled", + chainID: chains.Ethereum.ChainId, + expected: true, + }, + { + name: "Binance Smart Chain is enabled", + chainID: chains.BscMainnet.ChainId, + expected: true, + }, + { + name: "Bitcoin is enabled", + chainID: chains.BitcoinMainnet.ChainId, + expected: true, + }, + { + name: "ZetaChain is not enabled", + chainID: chains.ZetaChainMainnet.ChainId, + expected: false, + }, + { + name: "Solana is not enabled", + chainID: chains.SolanaMainnet.ChainId, + expected: false, + }, + { + name: "TON is not enabled", + chainID: chains.TONMainnet.ChainId, + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + require.Equal(t, tt.expected, keeper.IsGasStabilityPoolEnabledChain(tt.chainID, []zetachains.Chain{})) + }) + } +} diff --git a/x/observer/types/crosschain_flags.go b/x/observer/types/crosschain_flags.go index 5637debe0e..0f2c3e4cb0 100644 --- a/x/observer/types/crosschain_flags.go +++ b/x/observer/types/crosschain_flags.go @@ -4,8 +4,8 @@ import "time" var DefaultGasPriceIncreaseFlags = GasPriceIncreaseFlags{ // EpochLength is the number of blocks in an epoch before triggering a gas price increase - EpochLength: 100, + // RetryInterval is the number of blocks to wait before incrementing the gas price again RetryInterval: time.Minute * 10, diff --git a/zetaclient/chains/bitcoin/fee.go b/zetaclient/chains/bitcoin/fee.go index 7ce483a5bf..47f93280cf 100644 --- a/zetaclient/chains/bitcoin/fee.go +++ b/zetaclient/chains/bitcoin/fee.go @@ -4,7 +4,6 @@ import ( "encoding/hex" "fmt" "math" - "math/big" "github.com/btcsuite/btcd/blockchain" "github.com/btcsuite/btcd/btcjson" @@ -20,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,27 @@ var ( // DepositorFeeCalculator is a function type to calculate the Bitcoin depositor fee type DepositorFeeCalculator func(interfaces.BTCRPCClient, *btcjson.TxRawResult, *chaincfg.Params) (float64, error) -// FeeRateToSatPerByte converts a fee rate in BTC/KB to sat/byte. -func FeeRateToSatPerByte(rate float64) *big.Int { - // #nosec G115 always in range - satPerKB := new(big.Int).SetInt64(int64(rate * btcutil.SatoshiPerBitcoin)) - return new(big.Int).Div(satPerKB, big.NewInt(bytesPerKB)) -} - // WiredTxSize calculates the wired tx size in bytes -func WiredTxSize(numInputs uint64, numOutputs uint64) uint64 { +func WiredTxSize(numInputs uint64, numOutputs uint64) int64 { // Version 4 bytes + LockTime 4 bytes + Serialized varint size for the // number of transaction inputs and outputs. // #nosec G115 always positive - return uint64(8 + wire.VarIntSerializeSize(numInputs) + wire.VarIntSerializeSize(numOutputs)) + return int64(8 + wire.VarIntSerializeSize(numInputs) + wire.VarIntSerializeSize(numOutputs)) } // EstimateOutboundSize estimates the size of an outbound in vBytes -func EstimateOutboundSize(numInputs uint64, payees []btcutil.Address) (uint64, error) { +func EstimateOutboundSize(numInputs int64, payees []btcutil.Address) (int64, error) { if numInputs == 0 { return 0, nil } // #nosec G115 always positive numOutputs := 2 + uint64(len(payees)) - bytesWiredTx := WiredTxSize(numInputs, numOutputs) + bytesWiredTx := WiredTxSize(uint64(numInputs), numOutputs) bytesInput := numInputs * bytesPerInput - bytesOutput := uint64(2) * bytesPerOutputP2WPKH // new nonce mark, change + bytesOutput := int64(2) * bytesPerOutputP2WPKH // new nonce mark, change // calculate the size of the outputs to payees - bytesToPayees := uint64(0) + bytesToPayees := int64(0) for _, to := range payees { sizeOutput, err := GetOutputSizeByAddress(to) if err != nil { @@ -104,7 +95,7 @@ func EstimateOutboundSize(numInputs uint64, payees []btcutil.Address) (uint64, e } // GetOutputSizeByAddress returns the size of a tx output in bytes by the given address -func GetOutputSizeByAddress(to btcutil.Address) (uint64, error) { +func GetOutputSizeByAddress(to btcutil.Address) (int64, error) { switch addr := to.(type) { case *btcutil.AddressTaproot: if addr == nil { @@ -137,16 +128,16 @@ func GetOutputSizeByAddress(to btcutil.Address) (uint64, error) { } // OutboundSizeDepositor returns outbound size (68vB) incurred by the depositor -func OutboundSizeDepositor() uint64 { +func OutboundSizeDepositor() int64 { return bytesPerInput + bytesPerWitness/blockchain.WitnessScaleFactor } // OutboundSizeWithdrawer returns outbound size (177vB) incurred by the withdrawer (1 input, 3 outputs) -func OutboundSizeWithdrawer() uint64 { +func OutboundSizeWithdrawer() int64 { bytesWiredTx := WiredTxSize(1, 3) - bytesInput := uint64(1) * bytesPerInput // nonce mark - bytesOutput := uint64(2) * bytesPerOutputP2WPKH // 2 P2WPKH outputs: new nonce mark, change - bytesOutput += bytesPerOutputAvg // 1 output to withdrawer's address + bytesInput := int64(1) * bytesPerInput // nonce mark + bytesOutput := int64(2) * bytesPerOutputP2WPKH // 2 P2WPKH outputs: new nonce mark, change + bytesOutput += bytesPerOutputAvg // 1 output to withdrawer's address return bytesWiredTx + bytesInput + bytesOutput + bytes1stWitness/blockchain.WitnessScaleFactor } @@ -246,7 +237,7 @@ func CalcDepositorFee( // GetRecentFeeRate gets the highest fee rate from recent blocks // Note: this method should be used for testnet ONLY -func GetRecentFeeRate(rpcClient interfaces.BTCRPCClient, netParams *chaincfg.Params) (uint64, error) { +func GetRecentFeeRate(rpcClient interfaces.BTCRPCClient, netParams *chaincfg.Params) (int64, error) { // should avoid using this method for mainnet if netParams.Name == chaincfg.MainNetParams.Name { return 0, errors.New("GetRecentFeeRate should not be used for mainnet") @@ -286,6 +277,5 @@ func GetRecentFeeRate(rpcClient interfaces.BTCRPCClient, netParams *chaincfg.Par highestRate = defaultTestnetFeeRate } - // #nosec G115 always in range - return uint64(highestRate), nil + return highestRate, nil } diff --git a/zetaclient/chains/bitcoin/fee_test.go b/zetaclient/chains/bitcoin/fee_test.go index 82f60ff0ef..e12c427733 100644 --- a/zetaclient/chains/bitcoin/fee_test.go +++ b/zetaclient/chains/bitcoin/fee_test.go @@ -195,9 +195,9 @@ func TestOutboundSize2In3Out(t *testing.T) { // Estimate the tx size in vByte // #nosec G115 always positive - vError := uint64(1) // 1 vByte error tolerance - vBytes := uint64(blockchain.GetTransactionWeight(btcutil.NewTx(tx)) / blockchain.WitnessScaleFactor) - vBytesEstimated, err := EstimateOutboundSize(uint64(len(utxosTxids)), []btcutil.Address{payee}) + vError := int64(1) // 1 vByte error tolerance + vBytes := blockchain.GetTransactionWeight(btcutil.NewTx(tx)) / blockchain.WitnessScaleFactor + vBytesEstimated, err := EstimateOutboundSize(int64(len(utxosTxids)), []btcutil.Address{payee}) require.NoError(t, err) if vBytes > vBytesEstimated { require.True(t, vBytes-vBytesEstimated <= vError) @@ -219,9 +219,9 @@ func TestOutboundSize21In3Out(t *testing.T) { // Estimate the tx size in vByte // #nosec G115 always positive - vError := uint64(21 / 4) // 5 vBytes error tolerance - vBytes := uint64(blockchain.GetTransactionWeight(btcutil.NewTx(tx)) / blockchain.WitnessScaleFactor) - vBytesEstimated, err := EstimateOutboundSize(uint64(len(exampleTxids)), []btcutil.Address{payee}) + vError := int64(21 / 4) // 5 vBytes error tolerance + vBytes := blockchain.GetTransactionWeight(btcutil.NewTx(tx)) / blockchain.WitnessScaleFactor + vBytesEstimated, err := EstimateOutboundSize(int64(len(exampleTxids)), []btcutil.Address{payee}) require.NoError(t, err) if vBytes > vBytesEstimated { require.True(t, vBytes-vBytesEstimated <= vError) @@ -243,11 +243,11 @@ func TestOutboundSizeXIn3Out(t *testing.T) { // Estimate the tx size // #nosec G115 always positive - vError := uint64( + vError := int64( 0.25 + float64(x)/4, ) // 1st witness incurs 0.25 more vByte error than others (which incurs 1/4 vByte per witness) - vBytes := uint64(blockchain.GetTransactionWeight(btcutil.NewTx(tx)) / blockchain.WitnessScaleFactor) - vBytesEstimated, err := EstimateOutboundSize(uint64(len(exampleTxids[:x])), []btcutil.Address{payee}) + vBytes := blockchain.GetTransactionWeight(btcutil.NewTx(tx)) / blockchain.WitnessScaleFactor + vBytesEstimated, err := EstimateOutboundSize(int64(len(exampleTxids[:x])), []btcutil.Address{payee}) require.NoError(t, err) if vBytes > vBytesEstimated { require.True(t, vBytes-vBytesEstimated <= vError) @@ -413,14 +413,14 @@ 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) @@ -433,7 +433,6 @@ func TestOutboundSizeBreakdown(t *testing.T) { require.Equal(t, uint64(177), txSizeWithdrawer) // total outbound size == (deposit fee + withdrawer fee), 245 = 68 + 177 - require.Equal(t, OutboundBytesAvg, txSizeAverage) require.Equal(t, txSizeAverage, txSizeDepositor+txSizeWithdrawer) // check default depositor fee diff --git a/zetaclient/chains/bitcoin/observer/db.go b/zetaclient/chains/bitcoin/observer/db.go new file mode 100644 index 0000000000..a8c76d88c2 --- /dev/null +++ b/zetaclient/chains/bitcoin/observer/db.go @@ -0,0 +1,65 @@ +package observer + +import ( + "github.com/pkg/errors" + + "github.com/zeta-chain/node/pkg/chains" + clienttypes "github.com/zeta-chain/node/zetaclient/types" +) + +// SaveBroadcastedTx saves successfully broadcasted transaction +func (ob *Observer) SaveBroadcastedTx(txHash string, nonce uint64) { + outboundID := ob.OutboundID(nonce) + ob.Mu().Lock() + ob.broadcastedTx[outboundID] = txHash + ob.Mu().Unlock() + + broadcastEntry := clienttypes.ToOutboundHashSQLType(txHash, outboundID) + if err := ob.DB().Client().Save(&broadcastEntry).Error; err != nil { + ob.logger.Outbound.Error(). + Err(err). + Msgf("SaveBroadcastedTx: error saving broadcasted txHash %s for outbound %s", txHash, outboundID) + } + ob.logger.Outbound.Info().Msgf("SaveBroadcastedTx: saved broadcasted txHash %s for outbound %s", txHash, outboundID) +} + +// LoadLastBlockScanned loads the last scanned block from the database +func (ob *Observer) LoadLastBlockScanned() error { + err := ob.Observer.LoadLastBlockScanned(ob.Logger().Chain) + if err != nil { + return errors.Wrapf(err, "error LoadLastBlockScanned for chain %d", ob.Chain().ChainId) + } + + // observer will scan from the last block when 'lastBlockScanned == 0', this happens when: + // 1. environment variable is set explicitly to "latest" + // 2. environment variable is empty and last scanned block is not found in DB + if ob.LastBlockScanned() == 0 { + blockNumber, err := ob.btcClient.GetBlockCount() + if err != nil { + return errors.Wrapf(err, "error GetBlockCount for chain %d", ob.Chain().ChainId) + } + // #nosec G115 always positive + ob.WithLastBlockScanned(uint64(blockNumber)) + } + + // bitcoin regtest starts from hardcoded block 100 + if chains.IsBitcoinRegnet(ob.Chain().ChainId) { + ob.WithLastBlockScanned(RegnetStartBlock) + } + ob.Logger().Chain.Info().Msgf("chain %d starts scanning from block %d", ob.Chain().ChainId, ob.LastBlockScanned()) + + return nil +} + +// LoadBroadcastedTxMap loads broadcasted transactions from the database +func (ob *Observer) LoadBroadcastedTxMap() error { + var broadcastedTransactions []clienttypes.OutboundHashSQLType + if err := ob.DB().Client().Find(&broadcastedTransactions).Error; err != nil { + ob.logger.Chain.Error().Err(err).Msgf("error iterating over db for chain %d", ob.Chain().ChainId) + return err + } + for _, entry := range broadcastedTransactions { + ob.broadcastedTx[entry.Key] = entry.Hash + } + return nil +} diff --git a/zetaclient/chains/bitcoin/observer/gas_price.go b/zetaclient/chains/bitcoin/observer/gas_price.go new file mode 100644 index 0000000000..aff3b1b5f5 --- /dev/null +++ b/zetaclient/chains/bitcoin/observer/gas_price.go @@ -0,0 +1,106 @@ +package observer + +import ( + "context" + "fmt" + + "github.com/pkg/errors" + + "github.com/zeta-chain/node/pkg/chains" + "github.com/zeta-chain/node/zetaclient/chains/bitcoin" + "github.com/zeta-chain/node/zetaclient/chains/bitcoin/rpc" + clienttypes "github.com/zeta-chain/node/zetaclient/types" +) + +// WatchGasPrice watches Bitcoin chain for gas rate and post to zetacore +func (ob *Observer) WatchGasPrice(ctx context.Context) error { + // report gas price right away as the ticker takes time to kick in + err := ob.PostGasPrice(ctx) + if err != nil { + ob.logger.GasPrice.Error().Err(err).Msgf("PostGasPrice error for chain %d", ob.Chain().ChainId) + } + + // start gas price ticker + ticker, err := clienttypes.NewDynamicTicker("Bitcoin_WatchGasPrice", ob.ChainParams().GasPriceTicker) + if err != nil { + return errors.Wrapf(err, "NewDynamicTicker error") + } + ob.logger.GasPrice.Info().Msgf("WatchGasPrice started for chain %d with interval %d", + ob.Chain().ChainId, ob.ChainParams().GasPriceTicker) + + defer ticker.Stop() + for { + select { + case <-ticker.C(): + if !ob.ChainParams().IsSupported { + continue + } + err := ob.PostGasPrice(ctx) + if err != nil { + ob.logger.GasPrice.Error().Err(err).Msgf("PostGasPrice error for chain %d", ob.Chain().ChainId) + } + ticker.UpdateInterval(ob.ChainParams().GasPriceTicker, ob.logger.GasPrice) + case <-ob.StopChannel(): + ob.logger.GasPrice.Info().Msgf("WatchGasPrice stopped for chain %d", ob.Chain().ChainId) + return nil + } + } +} + +// PostGasPrice posts gas price to zetacore +func (ob *Observer) PostGasPrice(ctx context.Context) error { + var ( + err error + feeRateEstimated int64 + ) + + // special handle regnet and testnet gas rate + // regnet: RPC 'EstimateSmartFee' is not available + // testnet: RPC 'EstimateSmartFee' returns unreasonable high gas rate + if ob.Chain().NetworkType != chains.NetworkType_mainnet { + feeRateEstimated, err = ob.specialHandleFeeRate() + if err != nil { + return errors.Wrap(err, "unable to execute specialHandleFeeRate") + } + } else { + feeRateEstimated, err = rpc.GetEstimatedFeeRate(ob.btcClient, 1) + if err != nil { + return errors.Wrap(err, "unable to get estimated fee rate") + } + } + + // query the current block number + blockNumber, err := ob.btcClient.GetBlockCount() + if err != nil { + return errors.Wrap(err, "GetBlockCount error") + } + + // Bitcoin has no concept of priority fee (like eth) + const priorityFee = 0 + + // #nosec G115 always positive + _, err = ob.ZetacoreClient(). + PostVoteGasPrice(ctx, ob.Chain(), uint64(feeRateEstimated), priorityFee, uint64(blockNumber)) + if err != nil { + return errors.Wrap(err, "PostVoteGasPrice error") + } + + return nil +} + +// specialHandleFeeRate handles the fee rate for regnet and testnet +func (ob *Observer) specialHandleFeeRate() (int64, error) { + switch ob.Chain().NetworkType { + case chains.NetworkType_privnet: + // hardcode gas price for regnet + return 1, nil + case chains.NetworkType_testnet: + feeRateEstimated, err := bitcoin.GetRecentFeeRate(ob.btcClient, ob.netParams) + if err != nil { + return 0, errors.Wrapf(err, "error GetRecentFeeRate") + } + return feeRateEstimated, nil + default: + return 0, fmt.Errorf(" unsupported bitcoin network type %d", ob.Chain().NetworkType) + } +} diff --git a/zetaclient/chains/bitcoin/observer/mempool.go b/zetaclient/chains/bitcoin/observer/mempool.go new file mode 100644 index 0000000000..4777dca7ef --- /dev/null +++ b/zetaclient/chains/bitcoin/observer/mempool.go @@ -0,0 +1,149 @@ +package observer + +import ( + "context" + + "github.com/btcsuite/btcd/btcutil" + "github.com/pkg/errors" + + "github.com/zeta-chain/node/pkg/ticker" + "github.com/zeta-chain/node/zetaclient/chains/bitcoin/rpc" + "github.com/zeta-chain/node/zetaclient/common" + "github.com/zeta-chain/node/zetaclient/logs" +) + +// WatchMempoolTxs monitors pending outbound txs in the Bitcoin mempool. +func (ob *Observer) WatchMempoolTxs(ctx context.Context) error { + task := func(ctx context.Context, _ *ticker.Ticker) error { + if err := ob.checkLastStuckTx(ctx); err != nil { + ob.Logger().Chain.Err(err).Msg("checkLastStuckTx error") + } + return nil + } + + return ticker.Run( + ctx, + common.MempoolStuckTxCheckInterval, + task, + ticker.WithStopChan(ob.StopChannel()), + ticker.WithLogger(ob.Logger().Chain, "WatchMempoolTxs"), + ) +} + +// checkLastStuckTx checks the last stuck tx in the Bitcoin mempool. +func (ob *Observer) checkLastStuckTx(ctx context.Context) error { + // log fields + lf := map[string]any{ + logs.FieldMethod: "checkLastStuckTx", + } + + // step 1: get last TSS transaction + lastTx, lastNonce, err := ob.GetLastOutbound(ctx) + if err != nil { + return errors.Wrap(err, "GetLastOutbound failed") + } + txHash := lastTx.MsgTx().TxID() + lf[logs.FieldNonce] = lastNonce + lf[logs.FieldTx] = txHash + ob.logger.Outbound.Info().Fields(lf).Msg("checking last TSS outbound") + + // step 2: is last tx stuck in mempool? + stuck, stuckFor, err := rpc.IsTxStuckInMempool(ob.btcClient, txHash, rpc.PendingTxFeeBumpWaitBlocks) + if err != nil { + return errors.Wrapf(err, "cannot determine if tx %s nonce %d is stuck", txHash, lastNonce) + } + + // step 3: update outbound stuck flag + // + // the key ideas to determine if Bitcoin outbound is stuck/unstuck: + // 1. outbound txs are a sequence of txs chained by nonce-mark UTXOs. + // 2. outbound tx with nonce N+1 MUST spend the nonce-mark UTXO produced by parent tx with nonce N. + // 3. when the last descendant tx is stuck, none of its ancestor txs can go through, so the stuck flag is set. + // 4. then RBF kicks in, it bumps the fee of the last descendant tx and aims to increase the average fee + // rate of the whole tx chain (as a package) to make it attractive to miners. + // 5. after RBF replacement, zetaclient clears the stuck flag immediately, hoping the new tx will be included + // within next 'PendingTxFeeBumpWaitBlocks' blocks. + // 6. the new tx may get stuck again (e.g. surging traffic) after 'PendingTxFeeBumpWaitBlocks' blocks, and + // the stuck flag will be set again to trigger another RBF, and so on. + // 7. all pending txs will be eventually cleared by fee bumping, and the stuck flag will be cleared. + // + // Note: reserved RBF bumping fee might be not enough to clear the stuck txs during extreme traffic surges, two options: + // 1. wait for the gas rate to drop. + // 2. manually clear the stuck txs by using offline accelerator services. + stuckAlready := ob.IsOutboundStuck() + if stuck { + ob.logger.Outbound.Warn().Fields(lf).Msgf("Bitcoin outbound is stuck for %f minutes", stuckFor.Minutes()) + } + if !stuck && stuckAlready { + ob.logger.Outbound.Info().Fields(lf).Msgf("Bitcoin outbound is no longer stuck") + } + ob.setOutboundStuck(stuck) + + return nil +} + +// GetLastOutbound gets the last outbound (with highest nonce) that had been sent to Bitcoin network. +// Bitcoin outbound txs can be found from two sources: +// 1. txs that had been reported to tracker and then checked and included by this observer self. +// 2. txs that had been broadcasted by this observer self. +// +// Once 2/3+ of the observers reach consensus on last outbound, RBF will start. +func (ob *Observer) GetLastOutbound(ctx context.Context) (*btcutil.Tx, uint64, error) { + var ( + lastNonce uint64 + lastHash string + ) + + // wait for pending nonce to refresh + pendingNonce := ob.GetPendingNonce() + if ob.GetPendingNonce() == 0 { + return nil, 0, errors.New("pending nonce is zero") + } + + // source 1: + // pick highest nonce tx from included txs + lastNonce = pendingNonce - 1 + txResult := ob.getIncludedTx(lastNonce) + if txResult == nil { + // should NEVER happen by design + return nil, 0, errors.New("last included tx not found") + } + lastHash = txResult.TxID + + // source 2: + // pick highest nonce tx from broadcasted txs + p, err := ob.ZetacoreClient().GetPendingNoncesByChain(ctx, ob.Chain().ChainId) + if err != nil { + return nil, 0, errors.Wrap(err, "GetPendingNoncesByChain failed") + } + for nonce := uint64(p.NonceLow); nonce < uint64(p.NonceHigh); nonce++ { + if nonce > lastNonce { + txID, found := ob.getBroadcastedTx(nonce) + if found { + lastNonce = nonce + lastHash = txID + } + } + } + + // ensure this nonce is the REAL last transaction + // cross-check the latest UTXO list, the nonce-mark utxo exists ONLY for last nonce + if ob.FetchUTXOs(ctx) != nil { + return nil, 0, errors.New("FetchUTXOs failed") + } + if _, err = ob.findNonceMarkUTXO(lastNonce, lastHash); err != nil { + return nil, 0, errors.Wrapf(err, "findNonceMarkUTXO failed for last tx %s nonce %d", lastHash, lastNonce) + } + + // query last transaction + // 'GetRawTransaction' is preferred over 'GetTransaction' here for three reasons: + // 1. it can fetch both stuck tx and non-stuck tx as far as they are valid txs. + // 2. it never fetch invalid tx (e.g., old tx replaced by RBF), so we can exclude invalid ones. + // 3. zetaclient needs the original tx body of a stuck tx to bump its fee and sign again. + lastTx, err := rpc.GetRawTxByHash(ob.btcClient, lastHash) + if err != nil { + return nil, 0, errors.Wrapf(err, "GetRawTxByHash failed for last tx %s nonce %d", lastHash, lastNonce) + } + + return lastTx, lastNonce, nil +} diff --git a/zetaclient/chains/bitcoin/observer/observer.go b/zetaclient/chains/bitcoin/observer/observer.go index 8a3516f3d0..2a1522242d 100644 --- a/zetaclient/chains/bitcoin/observer/observer.go +++ b/zetaclient/chains/bitcoin/observer/observer.go @@ -3,14 +3,9 @@ package observer import ( "context" - "fmt" - "math" "math/big" - "sort" - "strings" "github.com/btcsuite/btcd/btcjson" - "github.com/btcsuite/btcd/btcutil" "github.com/btcsuite/btcd/chaincfg" "github.com/btcsuite/btcd/wire" "github.com/pkg/errors" @@ -20,11 +15,9 @@ import ( "github.com/zeta-chain/node/pkg/chains" observertypes "github.com/zeta-chain/node/x/observer/types" "github.com/zeta-chain/node/zetaclient/chains/base" - "github.com/zeta-chain/node/zetaclient/chains/bitcoin" "github.com/zeta-chain/node/zetaclient/chains/interfaces" "github.com/zeta-chain/node/zetaclient/db" "github.com/zeta-chain/node/zetaclient/metrics" - clienttypes "github.com/zeta-chain/node/zetaclient/types" ) const ( @@ -72,6 +65,9 @@ type Observer struct { // pendingNonce is the outbound artificial pending nonce pendingNonce uint64 + // outboundStuck is the flag to indicate if the outbound is stuck in the mempool + outboundStuck bool + // utxos contains the UTXOs owned by the TSS address utxos []btcjson.ListUnspentResult @@ -128,7 +124,6 @@ func NewObserver( Observer: *baseObserver, netParams: netParams, btcClient: btcClient, - pendingNonce: 0, utxos: []btcjson.ListUnspentResult{}, includedTxHashes: make(map[string]bool), includedTxResults: make(map[string]*btcjson.GetTransactionResult), @@ -180,6 +175,9 @@ func (ob *Observer) Start(ctx context.Context) { // watch bitcoin chain for UTXOs owned by the TSS address bg.Work(ctx, ob.WatchUTXOs, bg.WithName("WatchUTXOs"), bg.WithLogger(ob.Logger().Outbound)) + // watch bitcoin chain for pending mempool txs + bg.Work(ctx, ob.WatchMempoolTxs, bg.WithName("WatchMempoolTxs"), bg.WithLogger(ob.Logger().Outbound)) + // watch bitcoin chain for gas rate and post to zetacore bg.Work(ctx, ob.WatchGasPrice, bg.WithName("WatchGasPrice"), bg.WithLogger(ob.Logger().GasPrice)) @@ -211,208 +209,6 @@ func (ob *Observer) ConfirmationsThreshold(amount *big.Int) int64 { return int64(ob.ChainParams().ConfirmationCount) } -// WatchGasPrice watches Bitcoin chain for gas rate and post to zetacore -// TODO(revamp): move ticker related functions to a specific file -// TODO(revamp): move inner logic in a separate function -func (ob *Observer) WatchGasPrice(ctx context.Context) error { - // report gas price right away as the ticker takes time to kick in - err := ob.PostGasPrice(ctx) - if err != nil { - ob.logger.GasPrice.Error().Err(err).Msgf("PostGasPrice error for chain %d", ob.Chain().ChainId) - } - - // start gas price ticker - ticker, err := clienttypes.NewDynamicTicker("Bitcoin_WatchGasPrice", ob.ChainParams().GasPriceTicker) - if err != nil { - return errors.Wrapf(err, "NewDynamicTicker error") - } - ob.logger.GasPrice.Info().Msgf("WatchGasPrice started for chain %d with interval %d", - ob.Chain().ChainId, ob.ChainParams().GasPriceTicker) - - defer ticker.Stop() - for { - select { - case <-ticker.C(): - if !ob.ChainParams().IsSupported { - continue - } - err := ob.PostGasPrice(ctx) - if err != nil { - ob.logger.GasPrice.Error().Err(err).Msgf("PostGasPrice error for chain %d", ob.Chain().ChainId) - } - ticker.UpdateInterval(ob.ChainParams().GasPriceTicker, ob.logger.GasPrice) - case <-ob.StopChannel(): - ob.logger.GasPrice.Info().Msgf("WatchGasPrice stopped for chain %d", ob.Chain().ChainId) - return nil - } - } -} - -// PostGasPrice posts gas price to zetacore -// TODO(revamp): move to gas price file -func (ob *Observer) PostGasPrice(ctx context.Context) error { - var ( - err error - feeRateEstimated uint64 - ) - - // special handle regnet and testnet gas rate - // regnet: RPC 'EstimateSmartFee' is not available - // testnet: RPC 'EstimateSmartFee' returns unreasonable high gas rate - if ob.Chain().NetworkType != chains.NetworkType_mainnet { - feeRateEstimated, err = ob.specialHandleFeeRate() - if err != nil { - return errors.Wrap(err, "unable to execute specialHandleFeeRate") - } - } else { - // EstimateSmartFee returns the fees per kilobyte (BTC/kb) targeting given block confirmation - feeResult, err := ob.btcClient.EstimateSmartFee(1, &btcjson.EstimateModeEconomical) - if err != nil { - return errors.Wrap(err, "unable to estimate smart fee") - } - if feeResult.Errors != nil || feeResult.FeeRate == nil { - return fmt.Errorf("error getting gas price: %s", feeResult.Errors) - } - if *feeResult.FeeRate > math.MaxInt64 { - return fmt.Errorf("gas price is too large: %f", *feeResult.FeeRate) - } - feeRateEstimated = bitcoin.FeeRateToSatPerByte(*feeResult.FeeRate).Uint64() - } - - // query the current block number - blockNumber, err := ob.btcClient.GetBlockCount() - if err != nil { - return errors.Wrap(err, "GetBlockCount error") - } - - // 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 -} - -// WatchUTXOs watches bitcoin chain for UTXOs owned by the TSS address -// TODO(revamp): move ticker related functions to a specific file -func (ob *Observer) WatchUTXOs(ctx context.Context) error { - ticker, err := clienttypes.NewDynamicTicker("Bitcoin_WatchUTXOs", ob.ChainParams().WatchUtxoTicker) - if err != nil { - ob.logger.UTXOs.Error().Err(err).Msg("error creating ticker") - return err - } - - defer ticker.Stop() - for { - select { - case <-ticker.C(): - if !ob.ChainParams().IsSupported { - continue - } - err := ob.FetchUTXOs(ctx) - if err != nil { - // log debug log if the error if no wallet is loaded - // this is to prevent extensive logging in localnet when the wallet is not loaded for non-Bitcoin test - // TODO: prevent this routine from running if Bitcoin node is not enabled - // https://github.com/zeta-chain/node/issues/2790 - if !strings.Contains(err.Error(), "No wallet is loaded") { - ob.logger.UTXOs.Error().Err(err).Msg("error fetching btc utxos") - } else { - ob.logger.UTXOs.Debug().Err(err).Msg("No wallet is loaded") - } - } - ticker.UpdateInterval(ob.ChainParams().WatchUtxoTicker, ob.logger.UTXOs) - case <-ob.StopChannel(): - ob.logger.UTXOs.Info().Msgf("WatchUTXOs stopped for chain %d", ob.Chain().ChainId) - return nil - } - } -} - -// FetchUTXOs fetches TSS-owned UTXOs from the Bitcoin node -// TODO(revamp): move to UTXO file -func (ob *Observer) FetchUTXOs(ctx context.Context) error { - defer func() { - if err := recover(); err != nil { - ob.logger.UTXOs.Error().Msgf("BTC FetchUTXOs: caught panic error: %v", err) - } - }() - - // This is useful when a zetaclient's pending nonce lagged behind for whatever reason. - ob.refreshPendingNonce(ctx) - - // get the current block height. - bh, err := ob.btcClient.GetBlockCount() - if err != nil { - return fmt.Errorf("btc: error getting block height : %v", err) - } - maxConfirmations := int(bh) - - // List all unspent UTXOs (160ms) - tssAddr, err := ob.TSS().PubKey().AddressBTC(ob.Chain().ChainId) - if err != nil { - return fmt.Errorf("error getting bitcoin tss address") - } - utxos, err := ob.btcClient.ListUnspentMinMaxAddresses(0, maxConfirmations, []btcutil.Address{tssAddr}) - if err != nil { - return err - } - - // rigid sort to make utxo list deterministic - sort.SliceStable(utxos, func(i, j int) bool { - if utxos[i].Amount == utxos[j].Amount { - if utxos[i].TxID == utxos[j].TxID { - return utxos[i].Vout < utxos[j].Vout - } - return utxos[i].TxID < utxos[j].TxID - } - return utxos[i].Amount < utxos[j].Amount - }) - - // filter UTXOs good to spend for next TSS transaction - utxosFiltered := make([]btcjson.ListUnspentResult, 0) - for _, utxo := range utxos { - // UTXOs big enough to cover the cost of spending themselves - if utxo.Amount < bitcoin.DefaultDepositorFee { - continue - } - // we don't want to spend other people's unconfirmed UTXOs as they may not be safe to spend - if utxo.Confirmations == 0 { - if !ob.isTssTransaction(utxo.TxID) { - continue - } - } - utxosFiltered = append(utxosFiltered, utxo) - } - - ob.Mu().Lock() - ob.TelemetryServer().SetNumberOfUTXOs(len(utxosFiltered)) - ob.utxos = utxosFiltered - ob.Mu().Unlock() - return nil -} - -// SaveBroadcastedTx saves successfully broadcasted transaction -// TODO(revamp): move to db file -func (ob *Observer) SaveBroadcastedTx(txHash string, nonce uint64) { - outboundID := ob.OutboundID(nonce) - ob.Mu().Lock() - ob.broadcastedTx[outboundID] = txHash - ob.Mu().Unlock() - - broadcastEntry := clienttypes.ToOutboundHashSQLType(txHash, outboundID) - if err := ob.DB().Client().Save(&broadcastEntry).Error; err != nil { - ob.logger.Outbound.Error(). - Err(err). - Msgf("SaveBroadcastedTx: error saving broadcasted txHash %s for outbound %s", txHash, outboundID) - } - ob.logger.Outbound.Info().Msgf("SaveBroadcastedTx: saved broadcasted txHash %s for outbound %s", txHash, outboundID) -} - // GetBlockByNumberCached gets cached block (and header) by block number func (ob *Observer) GetBlockByNumberCached(blockNumber int64) (*BTCBlockNHeader, error) { if result, ok := ob.BlockCache().Get(blockNumber); ok { @@ -446,67 +242,40 @@ func (ob *Observer) GetBlockByNumberCached(blockNumber int64) (*BTCBlockNHeader, return blockNheader, nil } -// LoadLastBlockScanned loads the last scanned block from the database -func (ob *Observer) LoadLastBlockScanned() error { - err := ob.Observer.LoadLastBlockScanned(ob.Logger().Chain) - if err != nil { - return errors.Wrapf(err, "error LoadLastBlockScanned for chain %d", ob.Chain().ChainId) - } - - // observer will scan from the last block when 'lastBlockScanned == 0', this happens when: - // 1. environment variable is set explicitly to "latest" - // 2. environment variable is empty and last scanned block is not found in DB - if ob.LastBlockScanned() == 0 { - blockNumber, err := ob.btcClient.GetBlockCount() - if err != nil { - return errors.Wrapf(err, "error GetBlockCount for chain %d", ob.Chain().ChainId) - } - // #nosec G115 always positive - ob.WithLastBlockScanned(uint64(blockNumber)) - } - - // bitcoin regtest starts from hardcoded block 100 - if chains.IsBitcoinRegnet(ob.Chain().ChainId) { - ob.WithLastBlockScanned(RegnetStartBlock) - } - ob.Logger().Chain.Info().Msgf("chain %d starts scanning from block %d", ob.Chain().ChainId, ob.LastBlockScanned()) +// IsOutboundStuck returns true if the outbound is stuck in the mempool +func (ob *Observer) IsOutboundStuck() bool { + ob.Mu().Lock() + defer ob.Mu().Unlock() + return ob.outboundStuck +} - return nil +// isTSSTransaction checks if a given transaction was sent by TSS itself. +// An unconfirmed transaction is safe to spend only if it was sent by TSS and verified by ourselves. +func (ob *Observer) isTSSTransaction(txid string) bool { + _, found := ob.includedTxHashes[txid] + return found } -// LoadBroadcastedTxMap loads broadcasted transactions from the database -func (ob *Observer) LoadBroadcastedTxMap() error { - var broadcastedTransactions []clienttypes.OutboundHashSQLType - if err := ob.DB().Client().Find(&broadcastedTransactions).Error; err != nil { - ob.logger.Chain.Error().Err(err).Msgf("error iterating over db for chain %d", ob.Chain().ChainId) - return err - } - for _, entry := range broadcastedTransactions { - ob.broadcastedTx[entry.Key] = entry.Hash - } - return nil +// setPendingNonce sets the artificial pending nonce +func (ob *Observer) setPendingNonce(nonce uint64) { + ob.Mu().Lock() + defer ob.Mu().Unlock() + ob.pendingNonce = nonce } -// specialHandleFeeRate handles the fee rate for regnet and testnet -func (ob *Observer) specialHandleFeeRate() (uint64, error) { - switch ob.Chain().NetworkType { - case chains.NetworkType_privnet: - // hardcode gas price for regnet - return 1, nil - case chains.NetworkType_testnet: - feeRateEstimated, err := bitcoin.GetRecentFeeRate(ob.btcClient, ob.netParams) - if err != nil { - return 0, errors.Wrapf(err, "error GetRecentFeeRate") - } - return feeRateEstimated, nil - default: - return 0, fmt.Errorf(" unsupported bitcoin network type %d", ob.Chain().NetworkType) - } +// setOutboundStuck sets the outbound stuck flag +func (ob *Observer) setOutboundStuck(stuck bool) { + ob.Mu().Lock() + defer ob.Mu().Unlock() + ob.outboundStuck = stuck } -// isTssTransaction checks if a given transaction was sent by TSS itself. -// An unconfirmed transaction is safe to spend only if it was sent by TSS and verified by ourselves. -func (ob *Observer) isTssTransaction(txid string) bool { - _, found := ob.includedTxHashes[txid] - return found +// getBroadcastedTx gets successfully broadcasted transaction by nonce +func (ob *Observer) getBroadcastedTx(nonce uint64) (string, bool) { + ob.Mu().Lock() + defer ob.Mu().Unlock() + + outboundID := ob.OutboundID(nonce) + txHash, found := ob.broadcastedTx[outboundID] + return txHash, found } diff --git a/zetaclient/chains/bitcoin/observer/outbound.go b/zetaclient/chains/bitcoin/observer/outbound.go index 2e0f3dd9b1..ab56c4acd0 100644 --- a/zetaclient/chains/bitcoin/observer/outbound.go +++ b/zetaclient/chains/bitcoin/observer/outbound.go @@ -20,6 +20,7 @@ import ( "github.com/zeta-chain/node/zetaclient/chains/interfaces" "github.com/zeta-chain/node/zetaclient/compliance" zctx "github.com/zeta-chain/node/zetaclient/context" + "github.com/zeta-chain/node/zetaclient/logs" "github.com/zeta-chain/node/zetaclient/types" "github.com/zeta-chain/node/zetaclient/zetacore" ) @@ -28,98 +29,116 @@ import ( // TODO(revamp): move ticker functions to a specific file // TODO(revamp): move into a separate package func (ob *Observer) WatchOutbound(ctx context.Context) error { + // get app context app, err := zctx.FromContext(ctx) if err != nil { return errors.Wrap(err, "unable to get app from context") } + // create outbound ticker ticker, err := types.NewDynamicTicker("Bitcoin_WatchOutbound", ob.ChainParams().OutboundTicker) if err != nil { return errors.Wrap(err, "unable to create dynamic ticker") } - defer ticker.Stop() - chainID := ob.Chain().ChainId - ob.logger.Outbound.Info().Msgf("WatchOutbound started for chain %d", chainID) + ob.logger.Outbound.Info().Msg("WatchOutbound: started") sampledLogger := ob.logger.Outbound.Sample(&zerolog.BasicSampler{N: 10}) for { select { case <-ticker.C(): if !app.IsOutboundObservationEnabled() { - sampledLogger.Info(). - Msgf("WatchOutbound: outbound observation is disabled for chain %d", chainID) + sampledLogger.Info().Msg("WatchOutbound: outbound observation is disabled") continue } - trackers, err := ob.ZetacoreClient().GetAllOutboundTrackerByChain(ctx, chainID, interfaces.Ascending) + + // process outbound trackers + err := ob.ProcessOutboundTrackers(ctx) if err != nil { - ob.logger.Outbound.Error(). - Err(err). - Msgf("WatchOutbound: error GetAllOutboundTrackerByChain for chain %d", chainID) - continue - } - for _, tracker := range trackers { - // get original cctx parameters - outboundID := ob.OutboundID(tracker.Nonce) - cctx, err := ob.ZetacoreClient().GetCctxByNonce(ctx, chainID, tracker.Nonce) - if err != nil { - ob.logger.Outbound.Info(). - Err(err). - Msgf("WatchOutbound: can't find cctx for chain %d nonce %d", chainID, tracker.Nonce) - break - } - - nonce := cctx.GetCurrentOutboundParam().TssNonce - if tracker.Nonce != nonce { // Tanmay: it doesn't hurt to check - ob.logger.Outbound.Error(). - Msgf("WatchOutbound: tracker nonce %d not match cctx nonce %d", tracker.Nonce, nonce) - break - } - - if len(tracker.HashList) > 1 { - ob.logger.Outbound.Warn(). - Msgf("WatchOutbound: oops, outboundID %s got multiple (%d) outbound hashes", outboundID, len(tracker.HashList)) - } - - // iterate over all txHashes to find the truly included one. - // we do it this (inefficient) way because we don't rely on the first one as it may be a false positive (for unknown reason). - txCount := 0 - var txResult *btcjson.GetTransactionResult - for _, txHash := range tracker.HashList { - result, inMempool := ob.checkIncludedTx(ctx, cctx, txHash.TxHash) - if result != nil && !inMempool { // included - txCount++ - txResult = result - ob.logger.Outbound.Info(). - Msgf("WatchOutbound: included outbound %s for chain %d nonce %d", txHash.TxHash, chainID, tracker.Nonce) - if txCount > 1 { - ob.logger.Outbound.Error().Msgf( - "WatchOutbound: checkIncludedTx passed, txCount %d chain %d nonce %d result %v", txCount, chainID, tracker.Nonce, result) - } - } - } - - if txCount == 1 { // should be only one txHash included for each nonce - ob.setIncludedTx(tracker.Nonce, txResult) - } else if txCount > 1 { - ob.removeIncludedTx(tracker.Nonce) // we can't tell which txHash is true, so we remove all (if any) to be safe - ob.logger.Outbound.Error().Msgf("WatchOutbound: included multiple (%d) outbound for chain %d nonce %d", txCount, chainID, tracker.Nonce) - } + ob.Logger().Outbound.Error().Err(err).Msg("WatchOutbound: ProcessOutboundTrackers failed") } + ticker.UpdateInterval(ob.ChainParams().OutboundTicker, ob.logger.Outbound) case <-ob.StopChannel(): - ob.logger.Outbound.Info().Msgf("WatchOutbound stopped for chain %d", chainID) + ob.logger.Outbound.Info().Msg("WatchOutbound: stopped") return nil } } } -// VoteOutboundIfConfirmed checks outbound status and returns (continueKeysign, error) -func (ob *Observer) VoteOutboundIfConfirmed( +// ProcessOutboundTrackers processes outbound trackers +func (ob *Observer) ProcessOutboundTrackers(ctx context.Context) error { + chainID := ob.Chain().ChainId + trackers, err := ob.ZetacoreClient().GetAllOutboundTrackerByChain(ctx, chainID, interfaces.Ascending) + if err != nil { + return errors.Wrap(err, "GetAllOutboundTrackerByChain failed") + } + + // logger fields + lf := map[string]any{ + logs.FieldMethod: "ProcessOutboundTrackers", + } + + // process outbound trackers + for _, tracker := range trackers { + // set logger fields + lf[logs.FieldNonce] = tracker.Nonce + + // get the CCTX + cctx, err := ob.ZetacoreClient().GetCctxByNonce(ctx, chainID, tracker.Nonce) + if err != nil { + ob.logger.Outbound.Err(err).Fields(lf).Msg("cannot find cctx") + break + } + if len(tracker.HashList) > 1 { + ob.logger.Outbound.Warn().Msgf("oops, got multiple (%d) outbound hashes", len(tracker.HashList)) + } + + // Iterate over all txHashes to find the truly included outbound. + // At any time, there is guarantee that only one single txHash will be considered valid and included for each nonce. + // The reasons are: + // 1. CCTX with nonce 'N = 0' is the past and well-controlled. + // 2. Given any CCTX with nonce 'N > 0', its outbound MUST spend the previous nonce-mark UTXO (nonce N-1) to be considered valid. + // 3. Bitcoin prevents double spending of the same UTXO except for RBF. + // 4. When RBF happens, the original tx will be removed from Bitcoin core, and only the new tx will be valid. + for _, txHash := range tracker.HashList { + _, included := ob.TryIncludeOutbound(ctx, cctx, txHash.TxHash) + if included { + break + } + } + } + + return nil +} + +// TryIncludeOutbound tries to include an outbound for the given cctx and txHash. +// +// Due to 10-min block time, zetaclient observes outbounds both in mempool and in blocks. +// An outbound is considered included if it satisfies one of the following two cases: +// 1. a valid tx pending in mempool with confirmation == 0 +// 2. a valid tx included in a block with confirmation > 0 +// +// Returns: (txResult, included) +func (ob *Observer) TryIncludeOutbound( ctx context.Context, cctx *crosschaintypes.CrossChainTx, -) (bool, error) { + txHash string, +) (*btcjson.GetTransactionResult, bool) { + nonce := cctx.GetCurrentOutboundParam().TssNonce + + // check tx inclusion and save tx result + txResult, included := ob.checkTxInclusion(ctx, cctx, txHash) + if included { + ob.setIncludedTx(nonce, txResult) + } + + return txResult, included +} + +// VoteOutboundIfConfirmed checks outbound status and returns (continueKeysign, error) +func (ob *Observer) VoteOutboundIfConfirmed(ctx context.Context, cctx *crosschaintypes.CrossChainTx) (bool, error) { const ( // not used with Bitcoin outboundGasUsed = 0 @@ -142,6 +161,9 @@ func (ob *Observer) VoteOutboundIfConfirmed( res, included := ob.includedTxResults[outboundID] ob.Mu().Unlock() + // Short-circuit in following two cases: + // 1. Outbound neither broadcasted nor included. It requires a keysign. + // 2. Outbound was broadcasted for nonce 0. It's an edge case (happened before) to avoid duplicate payments. if !included { if !broadcasted { return true, nil @@ -156,26 +178,15 @@ func (ob *Observer) VoteOutboundIfConfirmed( return false, nil } - // Try including this outbound broadcasted by myself - txResult, inMempool := ob.checkIncludedTx(ctx, cctx, txnHash) - if txResult == nil { // check failed, try again next time - return true, nil - } else if inMempool { // still in mempool (should avoid unnecessary Tss keysign) - ob.logger.Outbound.Info().Msgf("VoteOutboundIfConfirmed: outbound %s is still in mempool", outboundID) - return false, nil - } - // included - ob.setIncludedTx(nonce, txResult) - - // Get tx result again in case it is just included - res = ob.getIncludedTx(nonce) - if res == nil { + // Try including this outbound broadcasted by myself to supplement outbound trackers. + // Note: each Bitcoin outbound usually gets included right after broadcasting. + res, included = ob.TryIncludeOutbound(ctx, cctx, txnHash) + if !included { return true, nil } - ob.logger.Outbound.Info().Msgf("VoteOutboundIfConfirmed: setIncludedTx succeeded for outbound %s", outboundID) } - // It's safe to use cctx's amount to post confirmation because it has already been verified in observeOutbound() + // It's safe to use cctx's amount to post confirmation because it has already been verified in checkTxInclusion(). amountInSat := params.Amount.BigInt() if res.Confirmations < ob.ConfirmationsThreshold(amountInSat) { ob.logger.Outbound.Debug(). @@ -244,105 +255,6 @@ func (ob *Observer) VoteOutboundIfConfirmed( return false, nil } -// SelectUTXOs selects a sublist of utxos to be used as inputs. -// -// Parameters: -// - amount: The desired minimum total value of the selected UTXOs. -// - utxos2Spend: The maximum number of UTXOs to spend. -// - nonce: The nonce of the outbound transaction. -// - consolidateRank: The rank below which UTXOs will be consolidated. -// - test: true for unit test only. -// -// Returns: -// - a sublist (includes previous nonce-mark) of UTXOs or an error if the qualifying sublist cannot be found. -// - the total value of the selected UTXOs. -// - the number of consolidated UTXOs. -// - the total value of the consolidated UTXOs. -// -// TODO(revamp): move to utxo file -func (ob *Observer) SelectUTXOs( - ctx context.Context, - amount float64, - utxosToSpend uint16, - nonce uint64, - consolidateRank uint16, - test bool, -) ([]btcjson.ListUnspentResult, float64, uint16, float64, error) { - idx := -1 - if nonce == 0 { - // for nonce = 0; make exception; no need to include nonce-mark utxo - ob.Mu().Lock() - defer ob.Mu().Unlock() - } else { - // for nonce > 0; we proceed only when we see the nonce-mark utxo - preTxid, err := ob.getOutboundIDByNonce(ctx, nonce-1, test) - if err != nil { - return nil, 0, 0, 0, err - } - ob.Mu().Lock() - defer ob.Mu().Unlock() - idx, err = ob.findNonceMarkUTXO(nonce-1, preTxid) - if err != nil { - return nil, 0, 0, 0, err - } - } - - // select smallest possible UTXOs to make payment - total := 0.0 - left, right := 0, 0 - for total < amount && right < len(ob.utxos) { - if utxosToSpend > 0 { // expand sublist - total += ob.utxos[right].Amount - right++ - utxosToSpend-- - } else { // pop the smallest utxo and append the current one - total -= ob.utxos[left].Amount - total += ob.utxos[right].Amount - left++ - right++ - } - } - results := make([]btcjson.ListUnspentResult, right-left) - copy(results, ob.utxos[left:right]) - - // include nonce-mark as the 1st input - if idx >= 0 { // for nonce > 0 - if idx < left || idx >= right { - total += ob.utxos[idx].Amount - results = append([]btcjson.ListUnspentResult{ob.utxos[idx]}, results...) - } else { // move nonce-mark to left - for i := idx - left; i > 0; i-- { - results[i], results[i-1] = results[i-1], results[i] - } - } - } - if total < amount { - return nil, 0, 0, 0, fmt.Errorf( - "SelectUTXOs: not enough btc in reserve - available : %v , tx amount : %v", - total, - amount, - ) - } - - // consolidate biggest possible UTXOs to maximize consolidated value - // consolidation happens only when there are more than (or equal to) consolidateRank (10) UTXOs - utxoRank, consolidatedUtxo, consolidatedValue := uint16(0), uint16(0), 0.0 - for i := len(ob.utxos) - 1; i >= 0 && utxosToSpend > 0; i-- { // iterate over UTXOs big-to-small - if i != idx && (i < left || i >= right) { // exclude nonce-mark and already selected UTXOs - utxoRank++ - if utxoRank >= consolidateRank { // consolication starts from the 10-ranked UTXO based on value - utxosToSpend-- - consolidatedUtxo++ - total += ob.utxos[i].Amount - consolidatedValue += ob.utxos[i].Amount - results = append(results, ob.utxos[i]) - } - } - } - - return results, total, consolidatedUtxo, consolidatedValue, nil -} - // refreshPendingNonce tries increasing the artificial pending nonce of outbound (if lagged behind). // There could be many (unpredictable) reasons for a pending nonce lagging behind, for example: // 1. The zetaclient gets restarted. @@ -355,31 +267,25 @@ func (ob *Observer) refreshPendingNonce(ctx context.Context) { } // increase pending nonce if lagged behind - ob.Mu().Lock() - pendingNonce := ob.pendingNonce - ob.Mu().Unlock() - // #nosec G115 always non-negative nonceLow := uint64(p.NonceLow) - if nonceLow > pendingNonce { + if nonceLow > ob.GetPendingNonce() { // get the last included outbound hash - txid, err := ob.getOutboundIDByNonce(ctx, nonceLow-1, false) + txid, err := ob.getOutboundHashByNonce(ctx, nonceLow-1, false) if err != nil { ob.logger.Chain.Error().Err(err).Msg("refreshPendingNonce: error getting last outbound txid") } // set 'NonceLow' as the new pending nonce - ob.Mu().Lock() - defer ob.Mu().Unlock() - ob.pendingNonce = nonceLow + ob.setPendingNonce(nonceLow) ob.logger.Chain.Info(). - Msgf("refreshPendingNonce: increase pending nonce to %d with txid %s", ob.pendingNonce, txid) + Msgf("refreshPendingNonce: increase pending nonce to %d with txid %s", nonceLow, txid) } } -// getOutboundIDByNonce gets the outbound ID from the nonce of the outbound transaction +// getOutboundHashByNonce gets the outbound hash for given nonce. // test is true for unit test only -func (ob *Observer) getOutboundIDByNonce(ctx context.Context, nonce uint64, test bool) (string, error) { +func (ob *Observer) getOutboundHashByNonce(ctx context.Context, nonce uint64, test bool) (string, error) { // There are 2 types of txids an observer can trust // 1. The ones had been verified and saved by observer self. // 2. The ones had been finalized in zetacore based on majority vote. @@ -413,82 +319,85 @@ func (ob *Observer) getOutboundIDByNonce(ctx context.Context, nonce uint64, test return "", fmt.Errorf("getOutboundIDByNonce: cannot find outbound txid for nonce %d", nonce) } -// findNonceMarkUTXO finds the nonce-mark UTXO in the list of UTXOs. -func (ob *Observer) findNonceMarkUTXO(nonce uint64, txid string) (int, error) { - tssAddress := ob.TSSAddressString() - amount := chains.NonceMarkAmount(nonce) - for i, utxo := range ob.utxos { - sats, err := bitcoin.GetSatoshis(utxo.Amount) - if err != nil { - ob.logger.Outbound.Error().Err(err).Msgf("findNonceMarkUTXO: error getting satoshis for utxo %v", utxo) - } - if utxo.Address == tssAddress && sats == amount && utxo.TxID == txid && utxo.Vout == 0 { - ob.logger.Outbound.Info(). - Msgf("findNonceMarkUTXO: found nonce-mark utxo with txid %s, amount %d satoshi", utxo.TxID, sats) - return i, nil - } - } - return -1, fmt.Errorf("findNonceMarkUTXO: cannot find nonce-mark utxo with nonce %d", nonce) -} - -// checkIncludedTx checks if a txHash is included and returns (txResult, inMempool) -// Note: if txResult is nil, then inMempool flag should be ignored. -func (ob *Observer) checkIncludedTx( +// checkTxInclusion checks if a txHash is included and returns (txResult, included) +// +// Note: a 'included' tx may still be considered stuck if it's in mempool for too long. +func (ob *Observer) checkTxInclusion( ctx context.Context, cctx *crosschaintypes.CrossChainTx, txHash string, ) (*btcjson.GetTransactionResult, bool) { - outboundID := ob.OutboundID(cctx.GetCurrentOutboundParam().TssNonce) - hash, getTxResult, err := rpc.GetTxResultByHash(ob.btcClient, txHash) + // logger fields + lf := map[string]any{ + logs.FieldMethod: "checkTxInclusion", + logs.FieldNonce: cctx.GetCurrentOutboundParam().TssNonce, + logs.FieldTx: txHash, + } + + // fetch tx result + hash, txResult, err := rpc.GetTxResultByHash(ob.btcClient, txHash) if err != nil { - ob.logger.Outbound.Error().Err(err).Msgf("checkIncludedTx: error GetTxResultByHash: %s", txHash) + ob.logger.Outbound.Warn().Err(err).Fields(lf).Msg("GetTxResultByHash failed") return nil, false } - if txHash != getTxResult.TxID { // just in case, we'll use getTxResult.TxID later - ob.logger.Outbound.Error(). - Msgf("checkIncludedTx: inconsistent txHash %s and getTxResult.TxID %s", txHash, getTxResult.TxID) + // validate tx result + err = ob.checkTssOutboundResult(ctx, cctx, hash, txResult) + if err != nil { + ob.logger.Outbound.Error().Err(err).Fields(lf).Msg("checkTssOutboundResult failed") return nil, false } - if getTxResult.Confirmations >= 0 { // check included tx only - err = ob.checkTssOutboundResult(ctx, cctx, hash, getTxResult) - if err != nil { - ob.logger.Outbound.Error(). - Err(err). - Msgf("checkIncludedTx: error verify bitcoin outbound %s outboundID %s", txHash, outboundID) - return nil, false - } - return getTxResult, false // included - } - return getTxResult, true // in mempool + // tx is valid and included + return txResult, true } -// setIncludedTx saves included tx result in memory +// setIncludedTx saves included tx result in memory. +// - the outbounds are chained (by nonce) txs sequentially included. +// - tx results may still be set in arbitrary order as the method is called across goroutines, and it doesn't matter. func (ob *Observer) setIncludedTx(nonce uint64, getTxResult *btcjson.GetTransactionResult) { - txHash := getTxResult.TxID - outboundID := ob.OutboundID(nonce) + var ( + txHash = getTxResult.TxID + outboundID = ob.OutboundID(nonce) + lf = map[string]any{ + logs.FieldMethod: "setIncludedTx", + logs.FieldNonce: nonce, + logs.FieldTx: txHash, + logs.FieldOutboundID: outboundID, + } + ) ob.Mu().Lock() defer ob.Mu().Unlock() res, found := ob.includedTxResults[outboundID] - if !found { // not found. + if !found { + // for new hash: + // - include new outbound and enforce rigid 1-to-1 mapping: nonce <===> txHash + // - try increasing pending nonce on every newly included outbound ob.includedTxHashes[txHash] = true - ob.includedTxResults[outboundID] = getTxResult // include new outbound and enforce rigid 1-to-1 mapping: nonce <===> txHash - if nonce >= ob.pendingNonce { // try increasing pending nonce on every newly included outbound + ob.includedTxResults[outboundID] = getTxResult + if nonce >= ob.pendingNonce { ob.pendingNonce = nonce + 1 } - ob.logger.Outbound.Info(). - Msgf("setIncludedTx: included new bitcoin outbound %s outboundID %s pending nonce %d", txHash, outboundID, ob.pendingNonce) - } else if txHash == res.TxID { // found same hash - ob.includedTxResults[outboundID] = getTxResult // update tx result as confirmations may increase + ob.logger.Outbound.Info().Fields(lf).Msgf("included new bitcoin outbound, pending nonce %d", ob.pendingNonce) + } else if txHash == res.TxID { + // for existing hash: + // - update tx result because confirmations may increase + ob.includedTxResults[outboundID] = getTxResult if getTxResult.Confirmations > res.Confirmations { - ob.logger.Outbound.Info().Msgf("setIncludedTx: bitcoin outbound %s got confirmations %d", txHash, getTxResult.Confirmations) + ob.logger.Outbound.Info().Msgf("bitcoin outbound got %d confirmations", getTxResult.Confirmations) } - } else { // found other hash. - // be alert for duplicate payment!!! As we got a new hash paying same cctx (for whatever reason). - delete(ob.includedTxResults, outboundID) // we can't tell which txHash is true, so we remove all to be safe - ob.logger.Outbound.Error().Msgf("setIncludedTx: duplicate payment by bitcoin outbound %s outboundID %s, prior outbound %s", txHash, outboundID, res.TxID) + } else { + // got multiple hashes for same nonce. RBF happened. + ob.logger.Outbound.Info().Fields(lf).Msgf("replaced bitcoin outbound %s", res.TxID) + + // remove prior txHash and txResult + delete(ob.includedTxHashes, res.TxID) + delete(ob.includedTxResults, outboundID) + + // add new txHash and txResult + ob.includedTxHashes[txHash] = true + ob.includedTxResults[outboundID] = getTxResult } } @@ -499,18 +408,8 @@ func (ob *Observer) getIncludedTx(nonce uint64) *btcjson.GetTransactionResult { return ob.includedTxResults[ob.OutboundID(nonce)] } -// removeIncludedTx removes included tx from memory -func (ob *Observer) removeIncludedTx(nonce uint64) { - ob.Mu().Lock() - defer ob.Mu().Unlock() - txResult, found := ob.includedTxResults[ob.OutboundID(nonce)] - if found { - delete(ob.includedTxHashes, txResult.TxID) - delete(ob.includedTxResults, ob.OutboundID(nonce)) - } -} - // Basic TSS outbound checks: +// - confirmations >= 0 // - should be able to query the raw tx // - check if all inputs are segwit && TSS inputs // @@ -521,6 +420,11 @@ func (ob *Observer) checkTssOutboundResult( hash *chainhash.Hash, res *btcjson.GetTransactionResult, ) error { + // negative confirmation means invalid tx, return error + if res.Confirmations < 0 { + return fmt.Errorf("checkTssOutboundResult: negative confirmations %d", res.Confirmations) + } + params := cctx.GetCurrentOutboundParam() nonce := params.TssNonce rawResult, err := rpc.GetRawTxResult(ob.btcClient, hash, res) @@ -571,9 +475,9 @@ func (ob *Observer) checkTSSVin(ctx context.Context, vins []btcjson.Vin, nonce u } // 1st vin: nonce-mark MUST come from prior TSS outbound if nonce > 0 && i == 0 { - preTxid, err := ob.getOutboundIDByNonce(ctx, nonce-1, false) + preTxid, err := ob.getOutboundHashByNonce(ctx, nonce-1, false) if err != nil { - return fmt.Errorf("checkTSSVin: error findTxIDByNonce %d", nonce-1) + return fmt.Errorf("checkTSSVin: error getOutboundHashByNonce %d", nonce-1) } // nonce-mark MUST the 1st output that comes from prior TSS outbound if vin.Txid != preTxid || vin.Vout != 0 { diff --git a/zetaclient/chains/bitcoin/observer/utxos.go b/zetaclient/chains/bitcoin/observer/utxos.go new file mode 100644 index 0000000000..fa9f65e915 --- /dev/null +++ b/zetaclient/chains/bitcoin/observer/utxos.go @@ -0,0 +1,230 @@ +package observer + +import ( + "context" + "fmt" + "sort" + "strings" + + "github.com/btcsuite/btcd/btcjson" + "github.com/btcsuite/btcd/btcutil" + + "github.com/zeta-chain/node/pkg/chains" + "github.com/zeta-chain/node/zetaclient/chains/bitcoin" + clienttypes "github.com/zeta-chain/node/zetaclient/types" +) + +// WatchUTXOs watches bitcoin chain for UTXOs owned by the TSS address +func (ob *Observer) WatchUTXOs(ctx context.Context) error { + ticker, err := clienttypes.NewDynamicTicker("Bitcoin_WatchUTXOs", ob.ChainParams().WatchUtxoTicker) + if err != nil { + ob.logger.UTXOs.Error().Err(err).Msg("error creating ticker") + return err + } + + defer ticker.Stop() + for { + select { + case <-ticker.C(): + if !ob.ChainParams().IsSupported { + continue + } + err := ob.FetchUTXOs(ctx) + if err != nil { + // log debug log if the error if no wallet is loaded + // this is to prevent extensive logging in localnet when the wallet is not loaded for non-Bitcoin test + // TODO: prevent this routine from running if Bitcoin node is not enabled + // https://github.com/zeta-chain/node/issues/2790 + if !strings.Contains(err.Error(), "No wallet is loaded") { + ob.logger.UTXOs.Error().Err(err).Msg("error fetching btc utxos") + } else { + ob.logger.UTXOs.Debug().Err(err).Msg("No wallet is loaded") + } + } + ticker.UpdateInterval(ob.ChainParams().WatchUtxoTicker, ob.logger.UTXOs) + case <-ob.StopChannel(): + ob.logger.UTXOs.Info().Msgf("WatchUTXOs stopped for chain %d", ob.Chain().ChainId) + return nil + } + } +} + +// FetchUTXOs fetches TSS-owned UTXOs from the Bitcoin node +func (ob *Observer) FetchUTXOs(ctx context.Context) error { + defer func() { + if err := recover(); err != nil { + ob.logger.UTXOs.Error().Msgf("BTC FetchUTXOs: caught panic error: %v", err) + } + }() + + // this is useful when a zetaclient's pending nonce lagged behind for whatever reason. + ob.refreshPendingNonce(ctx) + + // refresh the last block height. + lastBlock, err := ob.btcClient.GetBlockCount() + if err != nil { + return fmt.Errorf("btc: error getting block height : %v", err) + } + if ob.LastBlock() < uint64(lastBlock) { + ob.WithLastBlock(uint64(lastBlock)) + } + + // list all unspent UTXOs (160ms) + maxConfirmations := int(lastBlock) + tssAddr, err := ob.TSS().PubKey().AddressBTC(ob.Chain().ChainId) + if err != nil { + return fmt.Errorf("error getting bitcoin tss address") + } + utxos, err := ob.btcClient.ListUnspentMinMaxAddresses(0, maxConfirmations, []btcutil.Address{tssAddr}) + if err != nil { + return err + } + + // rigid sort to make utxo list deterministic + sort.SliceStable(utxos, func(i, j int) bool { + if utxos[i].Amount == utxos[j].Amount { + if utxos[i].TxID == utxos[j].TxID { + return utxos[i].Vout < utxos[j].Vout + } + return utxos[i].TxID < utxos[j].TxID + } + return utxos[i].Amount < utxos[j].Amount + }) + + // filter UTXOs good to spend for next TSS transaction + utxosFiltered := make([]btcjson.ListUnspentResult, 0) + for _, utxo := range utxos { + // UTXOs big enough to cover the cost of spending themselves + if utxo.Amount < bitcoin.DefaultDepositorFee { + continue + } + // we don't want to spend other people's unconfirmed UTXOs as they may not be safe to spend + if utxo.Confirmations == 0 { + if !ob.isTSSTransaction(utxo.TxID) { + continue + } + } + utxosFiltered = append(utxosFiltered, utxo) + } + + ob.Mu().Lock() + ob.TelemetryServer().SetNumberOfUTXOs(len(utxosFiltered)) + ob.utxos = utxosFiltered + ob.Mu().Unlock() + return nil +} + +// SelectUTXOs selects a sublist of utxos to be used as inputs. +// +// Parameters: +// - amount: The desired minimum total value of the selected UTXOs. +// - utxos2Spend: The maximum number of UTXOs to spend. +// - nonce: The nonce of the outbound transaction. +// - consolidateRank: The rank below which UTXOs will be consolidated. +// - test: true for unit test only. +// +// Returns: +// - a sublist (includes previous nonce-mark) of UTXOs or an error if the qualifying sublist cannot be found. +// - the total value of the selected UTXOs. +// - the number of consolidated UTXOs. +// - the total value of the consolidated UTXOs. +func (ob *Observer) SelectUTXOs( + ctx context.Context, + amount float64, + utxosToSpend uint16, + nonce uint64, + consolidateRank uint16, + test bool, +) ([]btcjson.ListUnspentResult, float64, uint16, float64, error) { + idx := -1 + if nonce == 0 { + // for nonce = 0; make exception; no need to include nonce-mark utxo + ob.Mu().Lock() + defer ob.Mu().Unlock() + } else { + // for nonce > 0; we proceed only when we see the nonce-mark utxo + preTxid, err := ob.getOutboundHashByNonce(ctx, nonce-1, test) + if err != nil { + return nil, 0, 0, 0, err + } + ob.Mu().Lock() + defer ob.Mu().Unlock() + idx, err = ob.findNonceMarkUTXO(nonce-1, preTxid) + if err != nil { + return nil, 0, 0, 0, err + } + } + + // select smallest possible UTXOs to make payment + total := 0.0 + left, right := 0, 0 + for total < amount && right < len(ob.utxos) { + if utxosToSpend > 0 { // expand sublist + total += ob.utxos[right].Amount + right++ + utxosToSpend-- + } else { // pop the smallest utxo and append the current one + total -= ob.utxos[left].Amount + total += ob.utxos[right].Amount + left++ + right++ + } + } + results := make([]btcjson.ListUnspentResult, right-left) + copy(results, ob.utxos[left:right]) + + // include nonce-mark as the 1st input + if idx >= 0 { // for nonce > 0 + if idx < left || idx >= right { + total += ob.utxos[idx].Amount + results = append([]btcjson.ListUnspentResult{ob.utxos[idx]}, results...) + } else { // move nonce-mark to left + for i := idx - left; i > 0; i-- { + results[i], results[i-1] = results[i-1], results[i] + } + } + } + if total < amount { + return nil, 0, 0, 0, fmt.Errorf( + "SelectUTXOs: not enough btc in reserve - available : %v , tx amount : %v", + total, + amount, + ) + } + + // consolidate biggest possible UTXOs to maximize consolidated value + // consolidation happens only when there are more than (or equal to) consolidateRank (10) UTXOs + utxoRank, consolidatedUtxo, consolidatedValue := uint16(0), uint16(0), 0.0 + for i := len(ob.utxos) - 1; i >= 0 && utxosToSpend > 0; i-- { // iterate over UTXOs big-to-small + if i != idx && (i < left || i >= right) { // exclude nonce-mark and already selected UTXOs + utxoRank++ + if utxoRank >= consolidateRank { // consolication starts from the 10-ranked UTXO based on value + utxosToSpend-- + consolidatedUtxo++ + total += ob.utxos[i].Amount + consolidatedValue += ob.utxos[i].Amount + results = append(results, ob.utxos[i]) + } + } + } + + return results, total, consolidatedUtxo, consolidatedValue, nil +} + +// findNonceMarkUTXO finds the nonce-mark UTXO in the list of UTXOs. +func (ob *Observer) findNonceMarkUTXO(nonce uint64, txid string) (int, error) { + tssAddress := ob.TSSAddressString() + amount := chains.NonceMarkAmount(nonce) + for i, utxo := range ob.utxos { + sats, err := bitcoin.GetSatoshis(utxo.Amount) + if err != nil { + ob.logger.Outbound.Error().Err(err).Msgf("FindNonceMarkUTXO: error getting satoshis for utxo %v", utxo) + } + if utxo.Address == tssAddress && sats == amount && utxo.TxID == txid && utxo.Vout == 0 { + ob.logger.Outbound.Info(). + Msgf("FindNonceMarkUTXO: found nonce-mark utxo with txid %s, amount %d satoshi", utxo.TxID, sats) + return i, nil + } + } + return -1, fmt.Errorf("FindNonceMarkUTXO: cannot find nonce-mark utxo with nonce %d", nonce) +} diff --git a/zetaclient/chains/bitcoin/rpc/rpc.go b/zetaclient/chains/bitcoin/rpc/rpc.go index 48182c8726..2e19eedfe7 100644 --- a/zetaclient/chains/bitcoin/rpc/rpc.go +++ b/zetaclient/chains/bitcoin/rpc/rpc.go @@ -2,6 +2,9 @@ package rpc import ( "fmt" + "math" + "math/big" + "strings" "time" "github.com/btcsuite/btcd/btcjson" @@ -18,6 +21,18 @@ const ( // RPCAlertLatency is the default threshold for RPC latency to be considered unhealthy and trigger an alert. // Bitcoin block time is 10 minutes, 1200s (20 minutes) is a reasonable threshold for Bitcoin RPCAlertLatency = time.Duration(1200) * time.Second + + // PendingTxFeeBumpWaitBlocks is the number of blocks to await before considering a tx stuck in mempool + PendingTxFeeBumpWaitBlocks = 3 + + // blockTimeBTC represents the average time to mine a block in Bitcoin + blockTimeBTC = 10 * time.Minute + + // BTCMaxSupply is the maximum supply of Bitcoin + maxBTCSupply = 21000000.0 + + // bytesPerKB is the number of bytes in a KB + bytesPerKB = 1000 ) // NewRPCClient creates a new RPC client by the given config. @@ -130,6 +145,32 @@ func GetRawTxResult( return btcjson.TxRawResult{}, fmt.Errorf("GetRawTxResult: tx %s not included yet", hash) } +// FeeRateToSatPerByte converts a fee rate from BTC/KB to sat/byte. +func FeeRateToSatPerByte(rate float64) *big.Int { + // #nosec G115 always in range + satPerKB := new(big.Int).SetInt64(int64(rate * btcutil.SatoshiPerBitcoin)) + return new(big.Int).Div(satPerKB, big.NewInt(bytesPerKB)) +} + +// GetEstimatedFeeRate gets estimated smart fee rate (BTC/Kb) targeting given block confirmation +func GetEstimatedFeeRate(rpcClient interfaces.BTCRPCClient, confTarget int64) (int64, error) { + feeResult, err := rpcClient.EstimateSmartFee(confTarget, &btcjson.EstimateModeEconomical) + if err != nil { + return 0, errors.Wrap(err, "unable to estimate smart fee") + } + if feeResult.Errors != nil { + return 0, fmt.Errorf("fee result contains errors: %s", feeResult.Errors) + } + if feeResult.FeeRate == nil { + return 0, fmt.Errorf("fee rate is nil") + } + if *feeResult.FeeRate <= 0 || *feeResult.FeeRate >= maxBTCSupply { + return 0, fmt.Errorf("fee rate is invalid: %f", *feeResult.FeeRate) + } + + return FeeRateToSatPerByte(*feeResult.FeeRate).Int64(), nil +} + // GetTransactionFeeAndRate gets the transaction fee and rate for a given tx result func GetTransactionFeeAndRate(rpcClient interfaces.BTCRPCClient, rawResult *btcjson.TxRawResult) (int64, int64, error) { var ( @@ -181,6 +222,97 @@ func GetTransactionFeeAndRate(rpcClient interfaces.BTCRPCClient, rawResult *btcj return fee, feeRate, nil } +// IsTxStuckInMempool checks if the transaction is stuck in the mempool. +// +// A pending tx with 'confirmations == 0' will be considered stuck due to excessive pending time. +func IsTxStuckInMempool( + client interfaces.BTCRPCClient, + txHash string, + maxWaitBlocks int64, +) (bool, time.Duration, error) { + lastBlock, err := client.GetBlockCount() + if err != nil { + return false, 0, errors.Wrap(err, "GetBlockCount failed") + } + + memplEntry, err := client.GetMempoolEntry(txHash) + if err != nil { + if strings.Contains(err.Error(), "Transaction not in mempool") { + return false, 0, nil // not a mempool tx, of course not stuck + } + return false, 0, errors.Wrap(err, "GetMempoolEntry failed") + } + + // is the tx pending for too long? + pendingTime := time.Since(time.Unix(memplEntry.Time, 0)) + pendingTimeAllowed := blockTimeBTC * time.Duration(maxWaitBlocks) + pendingDeadline := memplEntry.Height + maxWaitBlocks + if pendingTime > pendingTimeAllowed && lastBlock > pendingDeadline { + return true, pendingTime, nil + } + + return false, pendingTime, nil +} + +// GetTotalMempoolParentsSizeNFees returns the total fee and vsize of all pending parents of a given pending child tx (inclusive) +// +// A parent is defined as: +// - a tx that is also pending in the mempool +// - a tx that has its first output spent by the child as first input +// +// Returns: (totalTxs, totalFees, totalVSize, error) +func GetTotalMempoolParentsSizeNFees( + client interfaces.BTCRPCClient, + childHash string, +) (int64, float64, int64, int64, error) { + var ( + totalTxs int64 + totalFees float64 + totalVSize int64 + avgFeeRate int64 + ) + + // loop through all parents + parentHash := childHash + for { + memplEntry, err := client.GetMempoolEntry(parentHash) + if err != nil { + if strings.Contains(err.Error(), "Transaction not in mempool") { + // not a mempool tx, stop looking for parents + break + } + return 0, 0, 0, 0, errors.Wrapf(err, "unable to get mempool entry for tx %s", parentHash) + } + + // sum up the total fees and vsize + totalTxs++ + totalFees += memplEntry.Fee + totalVSize += int64(memplEntry.VSize) + + // find the parent tx + tx, err := GetRawTxByHash(client, parentHash) + if err != nil { + return 0, 0, 0, 0, errors.Wrapf(err, "unable to get tx %s", parentHash) + } + parentHash = tx.MsgTx().TxIn[0].PreviousOutPoint.Hash.String() + } + + // sanity check, should never happen + if totalFees <= 0 || totalVSize <= 0 { + return 0, 0, 0, 0, errors.Errorf("invalid result: totalFees %f, totalVSize %d", totalFees, totalVSize) + } + + // no pending tx found + if totalTxs == 0 { + return 0, 0, 0, 0, errors.Errorf("no pending tx found for given child %s", childHash) + } + + // calculate the average fee rate + avgFeeRate = int64(math.Ceil(totalFees / float64(totalVSize))) + + return totalTxs, totalFees, totalVSize, avgFeeRate, nil +} + // CheckRPCStatus checks the RPC status of the bitcoin chain func CheckRPCStatus(client interfaces.BTCRPCClient, tssAddress btcutil.Address) (time.Time, error) { // query latest block number diff --git a/zetaclient/chains/bitcoin/rpc/rpc_live_test.go b/zetaclient/chains/bitcoin/rpc/rpc_live_test.go index f3fdf5f12d..452b2953df 100644 --- a/zetaclient/chains/bitcoin/rpc/rpc_live_test.go +++ b/zetaclient/chains/bitcoin/rpc/rpc_live_test.go @@ -33,9 +33,10 @@ func createRPCClient(chainID int64) (*rpcclient.Client, error) { var connCfg *rpcclient.ConnConfig rpcMainnet := os.Getenv(common.EnvBtcRPCMainnet) rpcTestnet := os.Getenv(common.EnvBtcRPCTestnet) + rpcTestnet4 := "localhost:48332" // os.Getenv(common.EnvBtcRPCTestnet4) - // mainnet - if chainID == chains.BitcoinMainnet.ChainId { + switch chainID { + case chains.BitcoinMainnet.ChainId: connCfg = &rpcclient.ConnConfig{ Host: rpcMainnet, // mainnet endpoint goes here User: "user", @@ -44,9 +45,7 @@ func createRPCClient(chainID int64) (*rpcclient.Client, error) { HTTPPostMode: true, DisableTLS: true, } - } - // testnet3 - if chainID == chains.BitcoinTestnet.ChainId { + case chains.BitcoinTestnet.ChainId: connCfg = &rpcclient.ConnConfig{ Host: rpcTestnet, // testnet endpoint goes here User: "user", @@ -55,7 +54,19 @@ func createRPCClient(chainID int64) (*rpcclient.Client, error) { HTTPPostMode: true, DisableTLS: true, } + case chains.BitcoinTestnet4.ChainId: + connCfg = &rpcclient.ConnConfig{ + Host: rpcTestnet4, // testnet endpoint goes here + User: "admin", + Pass: "admin", + Params: "testnet3", // testnet4 uses testnet3 network name + HTTPPostMode: true, + DisableTLS: true, + } + default: + return nil, errors.New("unsupported chain") } + return rpcclient.New(connCfg, nil) } @@ -101,19 +112,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..e6a765cf98 --- /dev/null +++ b/zetaclient/chains/bitcoin/rpc/rpc_rbf_live_test.go @@ -0,0 +1,604 @@ +package rpc_test + +import ( + "encoding/hex" + "fmt" + "os" + "sort" + "testing" + "time" + + "github.com/btcsuite/btcd/btcec/v2/ecdsa" + "github.com/btcsuite/btcd/btcjson" + "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/mempool" + "github.com/btcsuite/btcd/rpcclient" + "github.com/btcsuite/btcd/txscript" + "github.com/btcsuite/btcd/wire" + "github.com/decred/dcrd/dcrec/secp256k1/v4" + "github.com/pkg/errors" + "github.com/stretchr/testify/require" + "github.com/zeta-chain/node/pkg/chains" + "github.com/zeta-chain/node/zetaclient/chains/bitcoin" + "github.com/zeta-chain/node/zetaclient/chains/bitcoin/rpc" + "github.com/zeta-chain/node/zetaclient/common" +) + +// setupTest initializes the privateKey, sender, receiver and RPC client +func setupTest(t *testing.T) (*rpcclient.Client, *secp256k1.PrivateKey, btcutil.Address, btcutil.Address) { + // network to use + chain := chains.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_PendingMempoolTx(t) +} + +func Test_RBFTransaction(t *testing.T) { + // setup test + client, privKey, sender, to := setupTest(t) + + // try querying tx result + _, getTxResult, err := rpc.GetTxResultByHash( + client, + "329d9204b906adc5f220954d53d9d990ebe92404c19297233aacb4a2ae799b69", + ) + if err == nil { + fmt.Printf("tx confirmations: %d\n", getTxResult.Confirmations) + } else { + fmt.Printf("GetTxResultByHash failed: %s\n", err) + } + + // define amount, fee rate and bump fee reserved + amount := 0.00001 + nonceMark := chains.NonceMarkAmount(1) + feeRate := int64(6) + bumpFeeReserved := int64(0) + + // 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, 600*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 Test_RBFTransaction_Chained_CPFP(t *testing.T) { + // setup test + client, privKey, sender, to := setupTest(t) + + // define amount, fee rate and bump fee reserved + amount := 0.00001 + nonceMark := int64(0) + feeRate := int64(20) + bumpFeeReserved := int64(0) + + //// + txid := "a5028b27a82aaea7f1bc6da41cb42e5f69478ef2b2e2cca7335db62f689f7e18" + oldHash, err := chainhash.NewHashFromStr(txid) + require.NoError(t, err) + rawTx2, err := client.GetRawTransaction(oldHash) + + // STEP 5 + // bump gas fee for tx3 (the child/grandchild of tx1/tx2) + // we assume that tx3 has same vBytes as the fee-bump tx (tx4) for simplicity + // two rules to satisfy: + // - feeTx4 >= feeTx3 + // - additionalFees >= vSizeTx4 * minRelayFeeRate + // see: https://github.com/bitcoin/bitcoin/blob/master/src/policy/rbf.cpp#L166-L183 + minRelayFeeRate := int64(1) + feeRateIncrease := minRelayFeeRate + feeRate - 1 + additionalFees := (110) * feeRateIncrease + fmt.Printf("additional fee: %d sats\n", additionalFees) + tx3, err := bumpRBFTxFee(rawTx2.MsgTx(), additionalFees) + require.NoError(t, err) + + // STEP 6 + // sign and send tx3, which replaces tx2 + signTx(t, client, privKey, tx3) + txHash, err := client.SendRawTransaction(tx3, true) + require.NoError(t, err) + fmt.Printf("sent tx3: %s\n", txHash) + + // STEP 1 + // build and send tx1 + nonceMark = 0 + txHash1 := buildAndSendRBFTx(t, client, privKey, nil, sender, to, amount, nonceMark, feeRate, bumpFeeReserved) + fmt.Printf("sent tx1: %s\n", txHash1) + + // STEP 2 + // build and send tx2 (child of tx1) + //nonceMark += 1 + 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 + rawTx2, confirmed := waitForTxConfirmation(client, sender, txHash3, 10*time.Second) + if confirmed { + fmt.Println("Opps: tx3 confirmed, no chance to bump fee; please start over") + return + } + + // 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(rawTx2) + 1) * feeRateIncrease + fmt.Printf("additional fee: %d sats\n", additionalFees) + tx4, err := bumpRBFTxFee(rawTx2.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 Test_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) + + // use hardcoded utxos if none found + if len(utxos) == 0 { + utxos = []btcjson.ListUnspentResult{ + { + TxID: "329d9204b906adc5f220954d53d9d990ebe92404c19297233aacb4a2ae799b69", + Vout: 0, + Amount: 0.00014399, + }, + } + } + + // ensure all inputs are from the parent tx + if parent != nil { + for _, out := range utxos { + require.Equal(t, parent.String(), out.TxID) + } + } + + // build tx opt-in RBF + tx := buildRBFTx(t, utxos, sender, to, amount, nonceMark, feeRate, bumpFeeReserved) + + // sign tx + signTx(t, client, privKey, tx) + + // broadcast tx + txHash, err := client.SendRawTransaction(tx, true) + require.NoError(t, err) + + return txHash +} + +func listUTXOs(client *rpcclient.Client, address btcutil.Address) []btcjson.ListUnspentResult { + utxos, err := client.ListUnspentMinMaxAddresses(0, 9999999, []btcutil.Address{address}) + if err != nil { + fmt.Printf("ListUnspent failed: %s\n", err) + return nil + } + + // sort utxos by amount, txid, vout + sort.SliceStable(utxos, func(i, j int) bool { + if utxos[i].Amount == utxos[j].Amount { + if utxos[i].TxID == utxos[j].TxID { + return utxos[i].Vout < utxos[j].Vout + } + return utxos[i].TxID < utxos[j].TxID + } + return utxos[i].Amount < utxos[j].Amount + }) + + // print utxos + fmt.Println("utxos:") + for _, out := range utxos { + fmt.Printf( + " txid: %s, vout: %d, amount: %f, confirmation: %d\n", + out.TxID, + out.Vout, + out.Amount, + out.Confirmations, + ) + } + + return utxos +} + +func buildRBFTx( + t *testing.T, + utxos []btcjson.ListUnspentResult, + sender, to btcutil.Address, + amount float64, + nonceMark int64, + feeRate int64, + bumpFeeReserved int64, +) *wire.MsgTx { + // build tx with all unspents + total := 0.0 + tx := wire.NewMsgTx(wire.TxVersion) + for _, output := range utxos { + hash, err := chainhash.NewHashFromStr(output.TxID) + require.NoError(t, err) + + // add input + outpoint := wire.NewOutPoint(hash, output.Vout) + txIn := wire.NewTxIn(outpoint, nil, nil) + txIn.Sequence = 1 // opt-in for RBF + tx.AddTxIn(txIn) + total += output.Amount + } + totalSats, err := bitcoin.GetSatoshis(total) + require.NoError(t, err) + + // amount to send in satoshis + //amountSats, err := bitcoin.GetSatoshis(amount) + //require.NoError(t, err) + + // calculate tx fee + txSize, err := bitcoin.EstimateOutboundSize(int64(len(utxos)), []btcutil.Address{to}) + require.NoError(t, err) + require.Greater(t, txSize, uint64(62)) + //txSize = 125 // remove the size of the nonce-mark and payee outputs + txSize -= 62 // remove the size of the nonce-mark and payee outputs + fees := int64(txSize) * feeRate + + // adjust amount + amountSats := totalSats - fees + + // make sure total is greater than amount + fees + //require.GreaterOrEqual(t, totalSats, nonceMark+amountSats+fees+bumpFeeReserved) + + // 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 := amountSats //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[0].Value <= additionalFee { + return nil, errors.New("change amount is not enough to cover the additional fee") + } + + // bump fee by reducing the change amount + newTx.TxOut[0].Value = newTx.TxOut[0].Value - additionalFee + + return newTx, nil +} + +func ensureTxDropped(t *testing.T, client *rpcclient.Client, txHash *chainhash.Hash) { + // dropped tx must has negative confirmations (if returned) + _, getTxResult, err := rpc.GetTxResultByHash(client, txHash.String()) + if err == nil { + require.Negative(t, getTxResult.Confirmations) + } + + // dropped tx should be removed from mempool + entry, err := client.GetMempoolEntry(txHash.String()) + require.Error(t, err) + require.Nil(t, entry) + + // dropped tx should not be found + // -5: No such mempool or blockchain transaction + rawTx, err := client.GetRawTransaction(txHash) + require.Error(t, err) + require.Nil(t, rawTx) +} diff --git a/zetaclient/chains/bitcoin/signer/fee_bumper.go b/zetaclient/chains/bitcoin/signer/fee_bumper.go new file mode 100644 index 0000000000..c4665b0280 --- /dev/null +++ b/zetaclient/chains/bitcoin/signer/fee_bumper.go @@ -0,0 +1,166 @@ +package signer + +import ( + "fmt" + "math" + + "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/mempool" + "github.com/btcsuite/btcd/wire" + "github.com/pkg/errors" + "github.com/rs/zerolog" + + "github.com/zeta-chain/node/pkg/constant" + "github.com/zeta-chain/node/zetaclient/chains/bitcoin" + "github.com/zeta-chain/node/zetaclient/chains/bitcoin/rpc" + "github.com/zeta-chain/node/zetaclient/chains/interfaces" +) + +const ( + // minCPFPFeeBumpFactor is the minimum factor by which the CPFP average fee rate should be bumped. + // This value 20% is a heuristic, not mandated by the Bitcoin protocol, designed to balance effectiveness + // in replacing stuck transactions while avoiding excessive sensitivity to fee market fluctuations. + minCPFPFeeBumpFactor = 1.2 +) + +// MempoolTxsInfoFetcher is a function type to fetch mempool txs information +type MempoolTxsInfoFetcher func(interfaces.BTCRPCClient, string) (int64, float64, int64, int64, error) + +// CPFPFeeBumper is a helper struct to contain CPFP (child-pays-for-parent) fee bumping logic +type CPFPFeeBumper struct { + // client is the RPC client to interact with the Bitcoin chain + client interfaces.BTCRPCClient + + // tx is the stuck transaction to bump + tx *btcutil.Tx + + // minRelayFee is the minimum relay fee in BTC + minRelayFee float64 + + // cctxRate is the most recent fee rate of the CCTX + cctxRate int64 + + // liveRate is the most recent market fee rate + liveRate int64 + + // totalTxs is the total number of stuck TSS txs + totalTxs int64 + + // totalFees is the total fees of all stuck TSS txs + totalFees int64 + + // totalVSize is the total vsize of all stuck TSS txs + totalVSize int64 + + // avgFeeRate is the average fee rate of all stuck TSS txs + avgFeeRate int64 +} + +// NewCPFPFeeBumper creates a new CPFPFeeBumper +func NewCPFPFeeBumper( + client interfaces.BTCRPCClient, + tx *btcutil.Tx, + cctxRate int64, + minRelayFee float64, +) *CPFPFeeBumper { + return &CPFPFeeBumper{ + client: client, + tx: tx, + minRelayFee: minRelayFee, + cctxRate: cctxRate, + } +} + +// BumpTxFee bumps the fee of the stuck transactions +func (b *CPFPFeeBumper) BumpTxFee() (*wire.MsgTx, int64, error) { + // tx replacement is triggered only when market fee rate goes 20% higher than current paid fee rate. + // zetacore updates the cctx fee rate evey 10 minutes, we could hold on and retry later. + minBumpRate := int64(math.Ceil(float64(b.avgFeeRate) * minCPFPFeeBumpFactor)) + if b.cctxRate < minBumpRate { + return nil, 0, fmt.Errorf( + "hold on RBF: cctx rate %d is lower than the min bumped rate %d", + b.cctxRate, + minBumpRate, + ) + } + + // the live rate may continue increasing during network congestion, we should wait until it stabilizes a bit. + // this is to ensure the live rate is not 20%+ higher than the cctx rate, otherwise, the replacement tx may + // also get stuck and need another replacement. + bumpedRate := int64(math.Ceil(float64(b.cctxRate) * minCPFPFeeBumpFactor)) + if b.liveRate > bumpedRate { + return nil, 0, fmt.Errorf( + "hold on RBF: live rate %d is much higher than the cctx rate %d", + b.liveRate, + b.cctxRate, + ) + } + + // calculate minmimum relay fees of the new replacement tx + // the new tx will have almost same size as the old one because the tx body stays the same + txVSize := mempool.GetTxVirtualSize(b.tx) + minRelayFeeRate := rpc.FeeRateToSatPerByte(b.minRelayFee) + minRelayTxFees := txVSize * minRelayFeeRate.Int64() + + // calculate the RBF additional fees required by Bitcoin protocol + // two conditions to satisfy: + // 1. new txFees >= old txFees (already handled above) + // 2. additionalFees >= minRelayTxFees + // + // see: https://github.com/bitcoin/bitcoin/blob/master/src/policy/rbf.cpp#L166-L183 + additionalFees := b.totalVSize*b.cctxRate - b.totalFees + if additionalFees < minRelayTxFees { + additionalFees = minRelayTxFees + } + + // copy the old tx and clear witness data (e.g., signatures) + newTx := b.tx.MsgTx().Copy() + for idx := range newTx.TxIn { + newTx.TxIn[idx].Witness = wire.TxWitness{} + } + + // check reserved bump fees amount in the original tx + if len(newTx.TxOut) < 3 { + return nil, 0, errors.New("original tx has no reserved bump fees") + } + + // bump fees in two ways: + // 1. deduct additional fees from the change amount + // 2. give up the whole change amount if it's not enough + if newTx.TxOut[2].Value >= additionalFees+constant.BTCWithdrawalDustAmount { + newTx.TxOut[2].Value -= additionalFees + } else { + newTx.TxOut = newTx.TxOut[:2] + } + + return newTx, additionalFees, nil +} + +// fetchFeeBumpInfo fetches all necessary information needed to bump the stuck tx +func (b *CPFPFeeBumper) FetchFeeBumpInfo(memplTxsInfoFetcher MempoolTxsInfoFetcher, logger zerolog.Logger) error { + // query live network fee rate + liveRate, err := rpc.GetEstimatedFeeRate(b.client, 1) + if err != nil { + return errors.Wrap(err, "GetEstimatedFeeRate failed") + } + b.liveRate = liveRate + + // query total fees and sizes of all pending parent TSS txs + totalTxs, totalFees, totalVSize, avgFeeRate, err := memplTxsInfoFetcher(b.client, b.tx.MsgTx().TxID()) + if err != nil { + return errors.Wrap(err, "GetTotalMempoolParentsSizeNFees failed") + } + totalFeesSats, err := bitcoin.GetSatoshis(totalFees) + if err != nil { + return errors.Wrapf(err, "cannot convert total fees %f", totalFees) + } + + b.totalTxs = totalTxs + b.totalFees = totalFeesSats + b.totalVSize = totalVSize + b.avgFeeRate = avgFeeRate + logger.Info(). + Msgf("totalTxs %d, totalFees %f, totalVSize %d, avgFeeRate %d", totalTxs, totalFees, totalVSize, avgFeeRate) + + return nil +} diff --git a/zetaclient/chains/bitcoin/signer/outbound_data.go b/zetaclient/chains/bitcoin/signer/outbound_data.go new file mode 100644 index 0000000000..6eea1d866d --- /dev/null +++ b/zetaclient/chains/bitcoin/signer/outbound_data.go @@ -0,0 +1,116 @@ +package signer + +import ( + "fmt" + "strconv" + + "github.com/btcsuite/btcd/btcutil" + "github.com/pkg/errors" + "github.com/rs/zerolog" + + "github.com/zeta-chain/node/pkg/chains" + "github.com/zeta-chain/node/pkg/coin" + "github.com/zeta-chain/node/pkg/constant" + "github.com/zeta-chain/node/x/crosschain/types" + "github.com/zeta-chain/node/zetaclient/chains/bitcoin/rpc" + "github.com/zeta-chain/node/zetaclient/compliance" +) + +// OutboundData is a data structure containing necessary data to construct a BTC outbound transaction +type OutboundData struct { + // chainID is the external chain ID + chainID int64 + + // to is the recipient address + to btcutil.Address + + // amount is the amount in BTC + amount float64 + + // feeRate is the fee rate in satoshis/vByte + feeRate int64 + + // txSize is the average size of a BTC outbound transaction + // user is charged (in ZRC20 contract) at a static txSize on each withdrawal + txSize int64 + + // nonce is the nonce of the outbound + nonce uint64 + + // height is the ZetaChain block height + height uint64 + + // cancelTx is a flag to indicate if this outbound should be cancelled + cancelTx bool +} + +// NewOutboundData creates OutboundData from the given CCTX. +func NewOutboundData( + cctx *types.CrossChainTx, + chainID int64, + height uint64, + minRelayFee float64, + logger, loggerCompliance zerolog.Logger, +) (*OutboundData, error) { + if cctx == nil { + return nil, errors.New("cctx is nil") + } + params := cctx.GetCurrentOutboundParam() + + // support gas token only for Bitcoin outbound + if cctx.InboundParams.CoinType != coin.CoinType_Gas { + logger.Error().Msg("can only send gas token to a Bitcoin network") + return nil, nil + } + + // fee rate + feeRate, err := strconv.ParseInt(params.GasPrice, 10, 64) + if err != nil || feeRate < 0 { + return nil, fmt.Errorf("cannot convert gas price %s", params.GasPrice) + } + + // check receiver address + to, err := chains.DecodeBtcAddress(params.Receiver, params.ReceiverChainId) + if err != nil { + return nil, errors.Wrapf(err, "cannot decode address %s", params.Receiver) + } + if !chains.IsBtcAddressSupported(to) { + return nil, fmt.Errorf("unsupported address %s", params.Receiver) + } + amount := float64(params.Amount.Uint64()) / 1e8 + + // add minimum relay fee (1000 satoshis/KB by default) to gasPrice to avoid minRelayTxFee error + // see: https://github.com/bitcoin/bitcoin/blob/master/src/policy/policy.h#L35 + satPerByte := rpc.FeeRateToSatPerByte(minRelayFee) + feeRate += satPerByte.Int64() + + // compliance check + restrictedCCTX := compliance.IsCctxRestricted(cctx) + if restrictedCCTX { + compliance.PrintComplianceLog(logger, loggerCompliance, + true, chainID, cctx.Index, cctx.InboundParams.Sender, params.Receiver, "BTC") + } + + // check dust amount + dustAmount := params.Amount.Uint64() < constant.BTCWithdrawalDustAmount + if dustAmount { + logger.Warn().Msgf("dust amount %d sats, canceling tx", params.Amount.Uint64()) + } + + // set the amount to 0 when the tx should be cancelled + cancelTx := restrictedCCTX || dustAmount + if cancelTx { + amount = 0.0 + } + + return &OutboundData{ + chainID: chainID, + to: to, + amount: amount, + feeRate: feeRate, + txSize: int64(params.CallOptions.GasLimit), + nonce: params.TssNonce, + height: height, + cancelTx: cancelTx, + }, nil +} diff --git a/zetaclient/chains/bitcoin/signer/sign_withdraw.go b/zetaclient/chains/bitcoin/signer/sign_withdraw.go new file mode 100644 index 0000000000..2fd617a7c8 --- /dev/null +++ b/zetaclient/chains/bitcoin/signer/sign_withdraw.go @@ -0,0 +1,253 @@ +package signer + +import ( + "context" + "fmt" + + "github.com/btcsuite/btcd/btcec/v2" + btcecdsa "github.com/btcsuite/btcd/btcec/v2/ecdsa" + "github.com/btcsuite/btcd/btcjson" + "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/txscript" + "github.com/btcsuite/btcd/wire" + "github.com/pkg/errors" + + "github.com/zeta-chain/node/pkg/chains" + "github.com/zeta-chain/node/zetaclient/chains/bitcoin" + "github.com/zeta-chain/node/zetaclient/chains/bitcoin/observer" +) + +const ( + // the maximum number of inputs per outbound + MaxNoOfInputsPerTx = 20 + + // the rank below (or equal to) which we consolidate UTXOs + consolidationRank = 10 +) + +// SignWithdrawTx signs a BTC withdrawal tx and returns the signed tx +func (signer *Signer) SignWithdrawTx( + ctx context.Context, + txData *OutboundData, + ob *observer.Observer, +) (*wire.MsgTx, error) { + nonceMark := chains.NonceMarkAmount(txData.nonce) + estimateFee := float64(txData.feeRate*bitcoin.OutboundBytesMax) / 1e8 + + // refreshing UTXO list before TSS keysign is important: + // 1. all TSS outbounds have opted-in for RBF to be replaceable + // 2. using old UTXOs may lead to accidental double-spending + // 3. double-spending may trigger unexpected tx replacement (RBF) + // + // Note: unwanted RBF will rarely happen for two reasons: + // 1. it requires 2/3 TSS signers to accidentally sign the same tx using same outdated UTXOs. + // 2. RBF requires a higher fee rate than the original tx. + err := ob.FetchUTXOs(ctx) + if err != nil { + return nil, errors.Wrap(err, "FetchUTXOs failed") + } + + // select N UTXOs to cover the total expense + prevOuts, total, consolidatedUtxo, consolidatedValue, err := ob.SelectUTXOs( + ctx, + txData.amount+estimateFee+float64(nonceMark)*1e-8, + MaxNoOfInputsPerTx, + txData.nonce, + consolidationRank, + false, + ) + if err != nil { + return nil, err + } + + // build tx and add inputs + tx := wire.NewMsgTx(wire.TxVersion) + inAmounts, err := signer.AddTxInputs(tx, prevOuts) + if err != nil { + return nil, err + } + + // size checking + // #nosec G115 always positive + txSize, err := bitcoin.EstimateOutboundSize(int64(len(prevOuts)), []btcutil.Address{txData.to}) + if err != nil { + return nil, err + } + if txData.txSize < bitcoin.BtcOutboundBytesWithdrawer { // ZRC20 'withdraw' charged less fee from end user + signer.Logger().Std.Info(). + Msgf("txSize %d is less than BtcOutboundBytesWithdrawer %d for nonce %d", txData.txSize, txSize, txData.nonce) + } + if txSize < bitcoin.OutboundBytesMin { // outbound shouldn't be blocked a low sizeLimit + signer.Logger().Std.Warn(). + Msgf("txSize %d is less than outboundBytesMin %d; use outboundBytesMin", txSize, bitcoin.OutboundBytesMin) + txSize = bitcoin.OutboundBytesMin + } + if txSize > bitcoin.OutboundBytesMax { // in case of accident + signer.Logger().Std.Warn(). + Msgf("txSize %d is greater than outboundBytesMax %d; use outboundBytesMax", txSize, bitcoin.OutboundBytesMax) + txSize = bitcoin.OutboundBytesMax + } + + // fee calculation + // #nosec G115 always in range (checked above) + fees := txSize * txData.feeRate + signer.Logger(). + Std.Info(). + Msgf("bitcoin outbound nonce %d feeRate %d size %d fees %d consolidated %d utxos of value %v", + txData.nonce, txData.feeRate, txSize, fees, consolidatedUtxo, consolidatedValue) + + // add tx outputs + err = signer.AddWithdrawTxOutputs(tx, txData.to, total, txData.amount, nonceMark, fees, txData.cancelTx) + if err != nil { + return nil, err + } + + // sign the tx + err = signer.SignTx(ctx, tx, inAmounts, txData.height, txData.nonce) + if err != nil { + return nil, errors.Wrap(err, "SignTx failed") + } + + return tx, nil +} + +// AddTxInputs adds the inputs to the tx and returns input amounts +func (signer *Signer) AddTxInputs(tx *wire.MsgTx, utxos []btcjson.ListUnspentResult) ([]int64, error) { + amounts := make([]int64, len(utxos)) + for i, utxo := range utxos { + hash, err := chainhash.NewHashFromStr(utxo.TxID) + if err != nil { + return nil, err + } + + // add input and set 'nSequence' to opt-in for RBF + // it doesn't matter on which input we set the RBF sequence + outpoint := wire.NewOutPoint(hash, utxo.Vout) + txIn := wire.NewTxIn(outpoint, nil, nil) + if i == 0 { + txIn.Sequence = rbfTxInSequenceNum + } + tx.AddTxIn(txIn) + + // store the amount for later signing use + amount, err := bitcoin.GetSatoshis(utxos[i].Amount) + if err != nil { + return nil, err + } + amounts[i] = amount + } + + return amounts, nil +} + +// AddWithdrawTxOutputs adds the 3 outputs to the withdraw tx +// 1st output: the nonce-mark btc to TSS itself +// 2nd output: the payment to the recipient +// 3rd output: the remaining btc to TSS itself +func (signer *Signer) AddWithdrawTxOutputs( + tx *wire.MsgTx, + to btcutil.Address, + total float64, + amount float64, + nonceMark int64, + fees int64, + cancelTx bool, +) error { + // convert withdraw amount to satoshis + amountSatoshis, err := bitcoin.GetSatoshis(amount) + if err != nil { + return err + } + + // calculate remaining btc (the change) to TSS self + remaining := total - amount + remainingSats, err := bitcoin.GetSatoshis(remaining) + if err != nil { + return err + } + remainingSats -= fees + remainingSats -= nonceMark + if remainingSats < 0 { + return fmt.Errorf("remainder value is negative: %d", remainingSats) + } else if remainingSats == nonceMark { + signer.Logger().Std.Info().Msgf("adjust remainder value to avoid duplicate nonce-mark: %d", remainingSats) + remainingSats-- + } + + // 1st output: the nonce-mark btc to TSS self + payToSelfScript, err := signer.PkScriptTSS() + if err != nil { + return err + } + txOut1 := wire.NewTxOut(nonceMark, payToSelfScript) + tx.AddTxOut(txOut1) + + // 2nd output: the payment to the recipient + if !cancelTx { + pkScript, err := txscript.PayToAddrScript(to) + if err != nil { + return err + } + txOut2 := wire.NewTxOut(amountSatoshis, pkScript) + tx.AddTxOut(txOut2) + } else { + // send the amount to TSS self if tx is cancelled + remainingSats += amountSatoshis + } + + // 3rd output: the remaining btc to TSS self + if remainingSats > 0 { + txOut3 := wire.NewTxOut(remainingSats, payToSelfScript) + tx.AddTxOut(txOut3) + } + return nil +} + +// SignTx signs the tx with the given witnessHashes +func (signer *Signer) SignTx( + ctx context.Context, + tx *wire.MsgTx, + inputAmounts []int64, + height uint64, + nonce uint64, +) error { + // get the TSS pkScript + pkScript, err := signer.PkScriptTSS() + if err != nil { + return err + } + + // calculate sighashes to sign + sigHashes := txscript.NewTxSigHashes(tx, txscript.NewCannedPrevOutputFetcher([]byte{}, 0)) + witnessHashes := make([][]byte, len(tx.TxIn)) + for ix := range tx.TxIn { + amount := inputAmounts[ix] + witnessHashes[ix], err = txscript.CalcWitnessSigHash(pkScript, sigHashes, txscript.SigHashAll, tx, ix, amount) + if err != nil { + return err + } + } + + // sign the tx with TSS + sig65Bs, err := signer.TSS().SignBatch(ctx, witnessHashes, height, nonce, signer.Chain().ChainId) + if err != nil { + return fmt.Errorf("SignBatch failed: %v", err) + } + + for ix := range tx.TxIn { + sig65B := sig65Bs[ix] + R := &btcec.ModNScalar{} + R.SetBytes((*[32]byte)(sig65B[:32])) + S := &btcec.ModNScalar{} + S.SetBytes((*[32]byte)(sig65B[32:64])) + sig := btcecdsa.NewSignature(R, S) + + pkCompressed := signer.TSS().PubKey().Bytes(true) + hashType := txscript.SigHashAll + txWitness := wire.TxWitness{append(sig.Serialize(), byte(hashType)), pkCompressed} + tx.TxIn[ix].Witness = txWitness + } + + return nil +} diff --git a/zetaclient/chains/bitcoin/signer/sign_withdraw_rbf.go b/zetaclient/chains/bitcoin/signer/sign_withdraw_rbf.go new file mode 100644 index 0000000000..19a3d53600 --- /dev/null +++ b/zetaclient/chains/bitcoin/signer/sign_withdraw_rbf.go @@ -0,0 +1,79 @@ +package signer + +import ( + "context" + "fmt" + "strconv" + + "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/wire" + "github.com/pkg/errors" + + "github.com/zeta-chain/node/x/crosschain/types" + "github.com/zeta-chain/node/zetaclient/chains/bitcoin/rpc" + "github.com/zeta-chain/node/zetaclient/logs" +) + +const ( + // rbfTxInSequenceNum is the sequence number used to signal an opt-in full-RBF (Replace-By-Fee) transaction + // Setting sequenceNum to "1" effectively makes the transaction timelocks irrelevant. + // See: https://github.com/bitcoin/bips/blob/master/bip-0125.mediawiki + // Also see: https://github.com/BlockchainCommons/Learning-Bitcoin-from-the-Command-Line/blob/master/05_2_Resending_a_Transaction_with_RBF.md + rbfTxInSequenceNum uint32 = 1 +) + +func (signer *Signer) SignRBFTx( + ctx context.Context, + cctx *types.CrossChainTx, + oldTx *btcutil.Tx, + minRelayFee float64, +) (*wire.MsgTx, error) { + var ( + params = cctx.GetCurrentOutboundParam() + lf = map[string]any{ + logs.FieldMethod: "SignRBFTx", + logs.FieldNonce: params.TssNonce, + logs.FieldTx: oldTx.MsgTx().TxID(), + } + logger = signer.Logger().Std.With().Fields(lf).Logger() + ) + + // parse recent fee rate from CCTX + cctxRate, err := strconv.ParseInt(params.GasPrice, 10, 64) + if err != nil || cctxRate <= 0 { + return nil, fmt.Errorf("cannot convert fee rate %s", params.GasPrice) + } + + // initiate fee bumper + fb := NewCPFPFeeBumper(signer.client, oldTx, cctxRate, minRelayFee) + err = fb.FetchFeeBumpInfo(rpc.GetTotalMempoolParentsSizeNFees, logger) + if err != nil { + return nil, errors.Wrap(err, "FetchFeeBumpInfo failed") + } + + // bump tx fees + newTx, additionalFees, err := fb.BumpTxFee() + if err != nil { + return nil, errors.Wrap(err, "BumpTxFee failed") + } + logger.Info().Msgf("BumpTxFee success, additional fees: %d satoshis", additionalFees) + + // collect input amounts for later signing + inAmounts := make([]int64, len(newTx.TxIn)) + for i, input := range newTx.TxIn { + preOut := input.PreviousOutPoint + preTx, err := signer.client.GetRawTransaction(&preOut.Hash) + if err != nil { + return nil, errors.Wrapf(err, "unable to get previous tx %s", preOut.Hash) + } + inAmounts[i] = preTx.MsgTx().TxOut[preOut.Index].Value + } + + // sign the RBF tx + err = signer.SignTx(ctx, newTx, inAmounts, 0, params.TssNonce) + if err != nil { + return nil, errors.Wrap(err, "SignTx failed") + } + + return newTx, nil +} diff --git a/zetaclient/chains/bitcoin/signer/signer.go b/zetaclient/chains/bitcoin/signer/signer.go index 1321b0c14f..499af8c94d 100644 --- a/zetaclient/chains/bitcoin/signer/signer.go +++ b/zetaclient/chains/bitcoin/signer/signer.go @@ -6,13 +6,8 @@ import ( "context" "encoding/hex" "fmt" - "math/big" "time" - "github.com/btcsuite/btcd/btcec/v2" - btcecdsa "github.com/btcsuite/btcd/btcec/v2/ecdsa" - "github.com/btcsuite/btcd/btcutil" - "github.com/btcsuite/btcd/chaincfg/chainhash" "github.com/btcsuite/btcd/rpcclient" "github.com/btcsuite/btcd/txscript" "github.com/btcsuite/btcd/wire" @@ -20,15 +15,10 @@ import ( "github.com/pkg/errors" "github.com/zeta-chain/node/pkg/chains" - "github.com/zeta-chain/node/pkg/coin" - "github.com/zeta-chain/node/pkg/constant" "github.com/zeta-chain/node/x/crosschain/types" - observertypes "github.com/zeta-chain/node/x/observer/types" "github.com/zeta-chain/node/zetaclient/chains/base" - "github.com/zeta-chain/node/zetaclient/chains/bitcoin" "github.com/zeta-chain/node/zetaclient/chains/bitcoin/observer" "github.com/zeta-chain/node/zetaclient/chains/interfaces" - "github.com/zeta-chain/node/zetaclient/compliance" "github.com/zeta-chain/node/zetaclient/config" "github.com/zeta-chain/node/zetaclient/logs" "github.com/zeta-chain/node/zetaclient/metrics" @@ -36,12 +26,6 @@ import ( ) const ( - // the maximum number of inputs per outbound - MaxNoOfInputsPerTx = 20 - - // the rank below (or equal to) which we consolidate UTXOs - consolidationRank = 10 - // broadcastBackoff is the initial backoff duration for retrying broadcast broadcastBackoff = 1000 * time.Millisecond @@ -121,197 +105,13 @@ func (signer *Signer) GetGatewayAddress() string { return "" } -// AddWithdrawTxOutputs adds the 3 outputs to the withdraw tx -// 1st output: the nonce-mark btc to TSS itself -// 2nd output: the payment to the recipient -// 3rd output: the remaining btc to TSS itself -func (signer *Signer) AddWithdrawTxOutputs( - tx *wire.MsgTx, - to btcutil.Address, - total float64, - amount float64, - nonceMark int64, - fees *big.Int, - cancelTx bool, -) error { - // convert withdraw amount to satoshis - amountSatoshis, err := bitcoin.GetSatoshis(amount) - if err != nil { - return err - } - - // calculate remaining btc (the change) to TSS self - remaining := total - amount - remainingSats, err := bitcoin.GetSatoshis(remaining) - if err != nil { - return err - } - remainingSats -= fees.Int64() - remainingSats -= nonceMark - if remainingSats < 0 { - return fmt.Errorf("remainder value is negative: %d", remainingSats) - } else if remainingSats == nonceMark { - signer.Logger().Std.Info().Msgf("adjust remainder value to avoid duplicate nonce-mark: %d", remainingSats) - remainingSats-- - } - - // 1st output: the nonce-mark btc to TSS self +// PkScriptTSS returns the TSS pkScript +func (signer *Signer) PkScriptTSS() ([]byte, error) { tssAddrP2WPKH, err := signer.TSS().PubKey().AddressBTC(signer.Chain().ChainId) - if err != nil { - return err - } - payToSelfScript, err := txscript.PayToAddrScript(tssAddrP2WPKH) - if err != nil { - return err - } - txOut1 := wire.NewTxOut(nonceMark, payToSelfScript) - tx.AddTxOut(txOut1) - - // 2nd output: the payment to the recipient - if !cancelTx { - pkScript, err := txscript.PayToAddrScript(to) - if err != nil { - return err - } - txOut2 := wire.NewTxOut(amountSatoshis, pkScript) - tx.AddTxOut(txOut2) - } else { - // send the amount to TSS self if tx is cancelled - remainingSats += amountSatoshis - } - - // 3rd output: the remaining btc to TSS self - if remainingSats > 0 { - txOut3 := wire.NewTxOut(remainingSats, payToSelfScript) - tx.AddTxOut(txOut3) - } - return nil -} - -// SignWithdrawTx receives utxos sorted by value, amount in BTC, feeRate in BTC per Kb -// TODO(revamp): simplify the function -func (signer *Signer) SignWithdrawTx( - ctx context.Context, - to btcutil.Address, - amount float64, - gasPrice *big.Int, - sizeLimit uint64, - observer *observer.Observer, - height uint64, - nonce uint64, - chain chains.Chain, - cancelTx bool, -) (*wire.MsgTx, error) { - estimateFee := float64(gasPrice.Uint64()*bitcoin.OutboundBytesMax) / 1e8 - nonceMark := chains.NonceMarkAmount(nonce) - - // refresh unspent UTXOs and continue with keysign regardless of error - err := observer.FetchUTXOs(ctx) - if err != nil { - signer.Logger(). - Std.Error(). - Err(err). - Msgf("SignGasWithdraw: FetchUTXOs error: nonce %d chain %d", nonce, chain.ChainId) - } - - // select N UTXOs to cover the total expense - prevOuts, total, consolidatedUtxo, consolidatedValue, err := observer.SelectUTXOs( - ctx, - amount+estimateFee+float64(nonceMark)*1e-8, - MaxNoOfInputsPerTx, - nonce, - consolidationRank, - false, - ) if err != nil { return nil, err } - - // build tx with selected unspents - tx := wire.NewMsgTx(wire.TxVersion) - for _, prevOut := range prevOuts { - hash, err := chainhash.NewHashFromStr(prevOut.TxID) - if err != nil { - return nil, err - } - outpoint := wire.NewOutPoint(hash, prevOut.Vout) - txIn := wire.NewTxIn(outpoint, nil, nil) - tx.AddTxIn(txIn) - } - - // size checking - // #nosec G115 always positive - txSize, err := bitcoin.EstimateOutboundSize(uint64(len(prevOuts)), []btcutil.Address{to}) - if err != nil { - return nil, err - } - if sizeLimit < bitcoin.BtcOutboundBytesWithdrawer { // ZRC20 'withdraw' charged less fee from end user - signer.Logger().Std.Info(). - Msgf("sizeLimit %d is less than BtcOutboundBytesWithdrawer %d for nonce %d", sizeLimit, txSize, nonce) - } - if txSize < bitcoin.OutboundBytesMin { // outbound shouldn't be blocked a low sizeLimit - signer.Logger().Std.Warn(). - Msgf("txSize %d is less than outboundBytesMin %d; use outboundBytesMin", txSize, bitcoin.OutboundBytesMin) - txSize = bitcoin.OutboundBytesMin - } - if txSize > bitcoin.OutboundBytesMax { // in case of accident - signer.Logger().Std.Warn(). - Msgf("txSize %d is greater than outboundBytesMax %d; use outboundBytesMax", txSize, bitcoin.OutboundBytesMax) - txSize = bitcoin.OutboundBytesMax - } - - // fee calculation - // #nosec G115 always in range (checked above) - fees := new(big.Int).Mul(big.NewInt(int64(txSize)), gasPrice) - signer.Logger(). - Std.Info(). - Msgf("bitcoin outbound nonce %d gasPrice %s size %d fees %s consolidated %d utxos of value %v", - nonce, gasPrice.String(), txSize, fees.String(), consolidatedUtxo, consolidatedValue) - - // add tx outputs - err = signer.AddWithdrawTxOutputs(tx, to, total, amount, nonceMark, fees, cancelTx) - if err != nil { - return nil, err - } - - // sign the tx - sigHashes := txscript.NewTxSigHashes(tx, txscript.NewCannedPrevOutputFetcher([]byte{}, 0)) - witnessHashes := make([][]byte, len(tx.TxIn)) - for ix := range tx.TxIn { - amt, err := bitcoin.GetSatoshis(prevOuts[ix].Amount) - if err != nil { - return nil, err - } - pkScript, err := hex.DecodeString(prevOuts[ix].ScriptPubKey) - if err != nil { - return nil, err - } - witnessHashes[ix], err = txscript.CalcWitnessSigHash(pkScript, sigHashes, txscript.SigHashAll, tx, ix, amt) - if err != nil { - return nil, err - } - } - - sig65Bs, err := signer.TSS().SignBatch(ctx, witnessHashes, height, nonce, chain.ChainId) - if err != nil { - return nil, fmt.Errorf("SignBatch error: %v", err) - } - - for ix := range tx.TxIn { - sig65B := sig65Bs[ix] - R := &btcec.ModNScalar{} - R.SetBytes((*[32]byte)(sig65B[:32])) - S := &btcec.ModNScalar{} - S.SetBytes((*[32]byte)(sig65B[32:64])) - sig := btcecdsa.NewSignature(R, S) - - pkCompressed := signer.TSS().PubKey().Bytes(true) - hashType := txscript.SigHashAll - txWitness := wire.TxWitness{append(sig.Serialize(), byte(hashType)), pkCompressed} - tx.TxIn[ix].Witness = txWitness - } - - return tx, nil + return txscript.PayToAddrScript(tssAddrP2WPKH) } // Broadcast sends the signed transaction to the network @@ -336,7 +136,6 @@ func (signer *Signer) Broadcast(signedTx *wire.MsgTx) error { } // TryProcessOutbound signs and broadcasts a BTC transaction from a new outbound -// TODO(revamp): simplify the function func (signer *Signer) TryProcessOutbound( ctx context.Context, cctx *types.CrossChainTx, @@ -355,21 +154,18 @@ func (signer *Signer) TryProcessOutbound( }() // prepare logger + chain := signer.Chain() params := cctx.GetCurrentOutboundParam() - // prepare logger fields lf := map[string]any{ logs.FieldMethod: "TryProcessOutbound", logs.FieldCctx: cctx.Index, logs.FieldNonce: params.TssNonce, } - logger := signer.Logger().Std.With().Fields(lf).Logger() - - // support gas token only for Bitcoin outbound - coinType := cctx.InboundParams.CoinType - if coinType == coin.CoinType_Zeta || coinType == coin.CoinType_ERC20 { - logger.Error().Msg("can only send BTC to a BTC network") - return + signerAddress, err := zetacoreClient.GetKeys().GetAddress() + if err == nil { + lf["signer"] = signerAddress.String() } + logger := signer.Logger().Std.With().Fields(lf).Logger() // convert chain observer to BTC observer btcObserver, ok := chainObserver.(*observer.Observer) @@ -378,120 +174,105 @@ func (signer *Signer) TryProcessOutbound( return } - chain := btcObserver.Chain() - outboundTssNonce := params.TssNonce - signerAddress, err := zetacoreClient.GetKeys().GetAddress() + // query network info to get minRelayFee (typically 1000 satoshis) + networkInfo, err := signer.client.GetNetworkInfo() if err != nil { - logger.Error().Err(err).Msg("cannot get signer address") + logger.Error().Err(err).Msgf("failed get bitcoin network info") return } - lf["signer"] = signerAddress.String() + minRelayFee := networkInfo.RelayFee - // get size limit and gas price - sizelimit := params.CallOptions.GasLimit - gasprice, ok := new(big.Int).SetString(params.GasPrice, 10) - if !ok || gasprice.Cmp(big.NewInt(0)) < 0 { - logger.Error().Msgf("cannot convert gas price %s ", params.GasPrice) - return - } + // sign RBF replacement tx if outbound is stuck + if btcObserver.IsOutboundStuck() { + lastTx, nonce, err := btcObserver.GetLastOutbound(ctx) + if err != nil { + logger.Error().Err(err).Msg("GetLastOutbound failed") + return + } + if params.TssNonce == nonce { + tx, err := signer.SignRBFTx(ctx, cctx, lastTx, minRelayFee) + if err != nil { + logger.Error().Err(err).Msg("SignRBFTx failed") + return + } + logger.Info().Msg("SignRBFTx success") - // Check receiver P2WPKH address - to, err := chains.DecodeBtcAddress(params.Receiver, params.ReceiverChainId) - if err != nil { - logger.Error().Err(err).Msgf("cannot decode address %s ", params.Receiver) - return - } - if !chains.IsBtcAddressSupported(to) { - logger.Error().Msgf("unsupported address %s", params.Receiver) - return + // broadcast tx + signer.broadcastOutbound(ctx, tx, params.TssNonce, cctx, btcObserver, zetacoreClient) + } } - amount := float64(params.Amount.Uint64()) / 1e8 - // Add 1 satoshi/byte to gasPrice to avoid minRelayTxFee issue - networkInfo, err := signer.client.GetNetworkInfo() + // setup transaction data + txData, err := NewOutboundData(cctx, chain.ChainId, height, minRelayFee, logger, signer.Logger().Compliance) if err != nil { - logger.Error().Err(err).Msgf("cannot get bitcoin network info") + logger.Error().Err(err).Msg("failed to setup Bitcoin outbound data") return } - satPerByte := bitcoin.FeeRateToSatPerByte(networkInfo.RelayFee) - gasprice.Add(gasprice, satPerByte) - - // compliance check - restrictedCCTX := compliance.IsCctxRestricted(cctx) - if restrictedCCTX { - compliance.PrintComplianceLog(logger, signer.Logger().Compliance, - true, chain.ChainId, cctx.Index, cctx.InboundParams.Sender, params.Receiver, "BTC") - } - - // check dust amount - dustAmount := params.Amount.Uint64() < constant.BTCWithdrawalDustAmount - if dustAmount { - logger.Warn().Msgf("dust amount %d sats, canceling tx", params.Amount.Uint64()) - } - - // set the amount to 0 when the tx should be cancelled - cancelTx := restrictedCCTX || dustAmount - if cancelTx { - amount = 0.0 - } // sign withdraw tx - tx, err := signer.SignWithdrawTx( - ctx, - to, - amount, - gasprice, - sizelimit, - btcObserver, - height, - outboundTssNonce, - chain, - cancelTx, - ) + tx, err := signer.SignWithdrawTx(ctx, txData, btcObserver) if err != nil { logger.Warn().Err(err).Msg("SignWithdrawTx failed") return } - logger.Info().Msg("Key-sign success") + logger.Info().Msg("SignWithdrawTx success") - // FIXME: add prometheus metrics - _, err = zetacoreClient.GetObserverList(ctx) - if err != nil { - logger.Warn(). - Err(err).Stringer("observation_type", observertypes.ObservationType_OutboundTx). - Msg("unable to get observer list, observation") + // broadcast tx + signer.broadcastOutbound(ctx, tx, params.TssNonce, cctx, btcObserver, zetacoreClient) +} + +// broadcastOutbound sends the signed transaction to the Bitcoin network +func (signer *Signer) broadcastOutbound( + ctx context.Context, + tx *wire.MsgTx, + nonce uint64, + cctx *types.CrossChainTx, + ob *observer.Observer, + zetacoreClient interfaces.ZetacoreClient, +) { + txHash := tx.TxID() + + // prepare logger fields + lf := map[string]any{ + logs.FieldMethod: "broadcastOutbound", + logs.FieldNonce: nonce, + logs.FieldTx: txHash, + logs.FieldCctx: cctx.Index, } - if tx != nil { - outboundHash := tx.TxHash().String() - lf[logs.FieldTx] = outboundHash - - // try broacasting tx with increasing backoff (1s, 2s, 4s, 8s, 16s) in case of RPC error - backOff := broadcastBackoff - for i := 0; i < broadcastRetries; i++ { - time.Sleep(backOff) - err := signer.Broadcast(tx) - if err != nil { - logger.Warn().Err(err).Fields(lf).Msgf("Broadcasting Bitcoin tx, retry %d", i) - backOff *= 2 - continue - } - logger.Info().Fields(lf).Msgf("Broadcast Bitcoin tx successfully") - zetaHash, err := zetacoreClient.PostOutboundTracker( - ctx, - chain.ChainId, - outboundTssNonce, - outboundHash, - ) - if err != nil { - logger.Err(err).Fields(lf).Msgf("Unable to add Bitcoin outbound tracker") - } - lf[logs.FieldZetaTx] = zetaHash - logger.Info().Fields(lf).Msgf("Add Bitcoin outbound tracker successfully") + logger := signer.Logger().Std + + // try broacasting tx with increasing backoff (1s, 2s, 4s, 8s, 16s) in case of RPC error + backOff := broadcastBackoff + for i := 0; i < broadcastRetries; i++ { + time.Sleep(backOff) - // Save successfully broadcasted transaction to btc chain observer - btcObserver.SaveBroadcastedTx(outboundHash, outboundTssNonce) + // broadcast tx + err := signer.Broadcast(tx) + if err != nil { + logger.Warn().Err(err).Fields(lf).Msgf("broadcasting Bitcoin outbound, retry %d", i) + backOff *= 2 + continue + } + logger.Info().Fields(lf).Msgf("broadcasted Bitcoin outbound successfully") - break // successful broadcast; no need to retry + // save tx local db + ob.SaveBroadcastedTx(txHash, nonce) + + // add tx to outbound tracker so that all observers know about it + zetaHash, err := zetacoreClient.PostOutboundTracker(ctx, ob.Chain().ChainId, nonce, txHash) + if err != nil { + logger.Err(err).Fields(lf).Msgf("unable to add Bitcoin outbound tracker") } + lf[logs.FieldZetaTx] = zetaHash + logger.Info().Fields(lf).Msgf("add Bitcoin outbound tracker successfully") + + // try including this outbound as early as possible + _, included := ob.TryIncludeOutbound(ctx, cctx, txHash) + if included { + logger.Info().Fields(lf).Msgf("included newly broadcasted Bitcoin outbound") + } + + // successful broadcast; no need to retry + break } } diff --git a/zetaclient/chains/bitcoin/signer/signer_test.go b/zetaclient/chains/bitcoin/signer/signer_test.go index 131fbe963f..b739989f13 100644 --- a/zetaclient/chains/bitcoin/signer/signer_test.go +++ b/zetaclient/chains/bitcoin/signer/signer_test.go @@ -3,7 +3,6 @@ package signer import ( "encoding/hex" "fmt" - "math/big" "reflect" "testing" @@ -259,7 +258,7 @@ func TestAddWithdrawTxOutputs(t *testing.T) { total float64 amount float64 nonce int64 - fees *big.Int + fees int64 cancelTx bool fail bool message string @@ -272,7 +271,7 @@ func TestAddWithdrawTxOutputs(t *testing.T) { total: 1.00012000, amount: 0.2, nonce: 10000, - fees: big.NewInt(2000), + fees: 2000, fail: false, txout: []*wire.TxOut{ {Value: 10000, PkScript: tssScript}, @@ -287,7 +286,7 @@ func TestAddWithdrawTxOutputs(t *testing.T) { total: 0.20012000, amount: 0.2, nonce: 10000, - fees: big.NewInt(2000), + fees: 2000, fail: false, txout: []*wire.TxOut{ {Value: 10000, PkScript: tssScript}, @@ -301,7 +300,7 @@ func TestAddWithdrawTxOutputs(t *testing.T) { total: 1.00012000, amount: 0.2, nonce: 10000, - fees: big.NewInt(2000), + fees: 2000, cancelTx: true, fail: false, txout: []*wire.TxOut{ @@ -332,7 +331,7 @@ func TestAddWithdrawTxOutputs(t *testing.T) { total: 0.20011000, amount: 0.2, nonce: 10000, - fees: big.NewInt(2000), + fees: 2000, fail: true, message: "remainder value is negative", }, @@ -343,7 +342,7 @@ func TestAddWithdrawTxOutputs(t *testing.T) { total: 0.20022000, // 0.2 + fee + nonceMark * 2 amount: 0.2, nonce: 10000, - fees: big.NewInt(2000), + fees: 2000, fail: false, txout: []*wire.TxOut{ {Value: 10000, PkScript: tssScript}, @@ -358,7 +357,7 @@ func TestAddWithdrawTxOutputs(t *testing.T) { total: 1.00012000, amount: 0.2, nonce: 10000, - fees: big.NewInt(2000), + fees: 2000, fail: true, }, } diff --git a/zetaclient/chains/interfaces/interfaces.go b/zetaclient/chains/interfaces/interfaces.go index 8e2b8e2a3a..5fcb0ad0d5 100644 --- a/zetaclient/chains/interfaces/interfaces.go +++ b/zetaclient/chains/interfaces/interfaces.go @@ -163,6 +163,7 @@ type BTCRPCClient interface { GetTransaction(txHash *chainhash.Hash) (*btcjson.GetTransactionResult, error) GetRawTransaction(txHash *chainhash.Hash) (*btcutil.Tx, error) GetRawTransactionVerbose(txHash *chainhash.Hash) (*btcjson.TxRawResult, error) + GetMempoolEntry(txHash string) (*btcjson.GetMempoolEntryResult, error) GetBlockCount() (int64, error) GetBlockHash(blockHeight int64) (*chainhash.Hash, error) GetBlockVerbose(blockHash *chainhash.Hash) (*btcjson.GetBlockVerboseResult, error) diff --git a/zetaclient/common/constant.go b/zetaclient/common/constant.go index c54acade92..acf80a6e24 100644 --- a/zetaclient/common/constant.go +++ b/zetaclient/common/constant.go @@ -14,4 +14,7 @@ const ( // RPCStatusCheckInterval is the interval to check RPC status, 1 minute RPCStatusCheckInterval = time.Minute + + // MempoolStuckTxCheckInterval is the interval to check for stuck transactions in the mempool + MempoolStuckTxCheckInterval = time.Minute ) diff --git a/zetaclient/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 c29b41466a..016bd560f5 100644 --- a/zetaclient/orchestrator/orchestrator.go +++ b/zetaclient/orchestrator/orchestrator.go @@ -387,11 +387,11 @@ func (oc *Orchestrator) runScheduler(ctx context.Context) error { switch { case chain.IsEVM(): - oc.ScheduleCctxEVM(ctx, zetaHeight, chainID, cctxList, ob, signer) + oc.ScheduleCCTXEVM(ctx, zetaHeight, chainID, cctxList, ob, signer) case chain.IsBitcoin(): - oc.ScheduleCctxBTC(ctx, zetaHeight, chainID, cctxList, ob, signer) + oc.ScheduleCCTXBTC(ctx, zetaHeight, chainID, cctxList, ob, signer) case chain.IsSolana(): - oc.ScheduleCctxSolana(ctx, zetaHeight, chainID, cctxList, ob, signer) + oc.ScheduleCCTXSolana(ctx, zetaHeight, chainID, cctxList, ob, signer) case chain.IsTON(): oc.ScheduleCCTXTON(ctx, zetaHeight, chainID, cctxList, ob, signer) default: @@ -409,8 +409,8 @@ func (oc *Orchestrator) runScheduler(ctx context.Context) error { } } -// ScheduleCctxEVM schedules evm outbound keysign on each ZetaChain block (the ticker) -func (oc *Orchestrator) ScheduleCctxEVM( +// ScheduleCCTXEVM schedules evm outbound keysign on each ZetaChain block (the ticker) +func (oc *Orchestrator) ScheduleCCTXEVM( ctx context.Context, zetaHeight uint64, chainID int64, @@ -508,11 +508,11 @@ func (oc *Orchestrator) ScheduleCctxEVM( } } -// ScheduleCctxBTC schedules bitcoin outbound keysign on each ZetaChain block (the ticker) +// ScheduleCCTXBTC schedules bitcoin outbound keysign on each ZetaChain block (the ticker) // 1. schedule at most one keysign per ticker // 2. schedule keysign only when nonce-mark UTXO is available // 3. stop keysign when lookahead is reached -func (oc *Orchestrator) ScheduleCctxBTC( +func (oc *Orchestrator) ScheduleCCTXBTC( ctx context.Context, zetaHeight uint64, chainID int64, @@ -583,8 +583,8 @@ func (oc *Orchestrator) ScheduleCctxBTC( } } -// ScheduleCctxSolana schedules solana outbound keysign on each ZetaChain block (the ticker) -func (oc *Orchestrator) ScheduleCctxSolana( +// ScheduleCCTXSolana schedules solana outbound keysign on each ZetaChain block (the ticker) +func (oc *Orchestrator) ScheduleCCTXSolana( ctx context.Context, zetaHeight uint64, chainID int64, diff --git a/zetaclient/testutils/mocks/btc_rpc.go b/zetaclient/testutils/mocks/btc_rpc.go index 487f4b0632..06d7934a4f 100644 --- a/zetaclient/testutils/mocks/btc_rpc.go +++ b/zetaclient/testutils/mocks/btc_rpc.go @@ -293,6 +293,36 @@ func (_m *BTCRPCClient) GetBlockVerboseTx(blockHash *chainhash.Hash) (*btcjson.G return r0, r1 } +// GetMempoolEntry provides a mock function with given fields: txHash +func (_m *BTCRPCClient) GetMempoolEntry(txHash string) (*btcjson.GetMempoolEntryResult, error) { + ret := _m.Called(txHash) + + if len(ret) == 0 { + panic("no return value specified for GetMempoolEntry") + } + + var r0 *btcjson.GetMempoolEntryResult + var r1 error + if rf, ok := ret.Get(0).(func(string) (*btcjson.GetMempoolEntryResult, error)); ok { + return rf(txHash) + } + if rf, ok := ret.Get(0).(func(string) *btcjson.GetMempoolEntryResult); ok { + r0 = rf(txHash) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*btcjson.GetMempoolEntryResult) + } + } + + if rf, ok := ret.Get(1).(func(string) error); ok { + r1 = rf(txHash) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // GetNetworkInfo provides a mock function with given fields: func (_m *BTCRPCClient) GetNetworkInfo() (*btcjson.GetNetworkInfoResult, error) { ret := _m.Called()