From abf5af89649c4396c5bf1e5c6d21c9233d93995d Mon Sep 17 00:00:00 2001 From: Charlie Chen <34498985+ws4charlie@users.noreply.github.com> Date: Tue, 4 Feb 2025 11:02:05 -0600 Subject: [PATCH] refactor: (BitcoinRBF-Step2): some minimum code refactoring for Bitcoin RBF adoption (#3381) * minimum code refactor for bitcoin RBF * add changelog entry * add unit test for FeeRateToSatPerByte * make changelog descriptive; rename specialHandleFeeRate as GetFeeRateForRegnetAndTestnet; code simplification * renaming sample function; make switch case in PostGasPrice to estimate fee rate according to Bitcoin network type * remove redundant variable description * make AddWithdrawTxOutputs a one-line call * rename sample function BTCAddressP2WPKH * remove unused test and comment * include coin type to error message; make code cleaner * return error if failed to get signer address; add BTCPayToAddrScript as a method of TSS PubKey * Update zetaclient/chains/bitcoin/signer/signer.go Replace 'Msgf' with 'Msg' Co-authored-by: Dmitry S <11892559+swift1337@users.noreply.github.com> * remove redundant test functions; use testlog for unit test * create observer in test suite; use testlog package * add description to fee estimation formula * use structured logs * make AddTxInputs independent method * add comments to explain function arguments; improve error wrapping; code simplification * replace ifs with switch case; return original err without overwriting * seems safe to remove panic recovery in FetchUTXOs * move Telemetry update to the line before acquiring observer lock * use testlog package * use retry package for Bitcoin tx broadcasting; let SaveBroadcastedTx return error * use named return values to make GetEstimatedFeeRate more readable * move utxo unit tests to utxos.go and improved unit tests * wrap RPC error in LoadLastBlockScanned * move last scanned block to log field; use Opt function for test suite * move values to log fields * add unit test for FetchUTXOs * add unit for SignWithdrawTx; use structured log * avoid creating log field map and add log fields right on the logger * fix client.GetEstimatedFeeRate * Fix loadBroadcastedTxMap * Fix SelectedUTXOs * Fix log naming * fix e2e logging * Fix setPendingNonce * don't use GasPriorityFee as it's always empty * update changelog --------- Co-authored-by: Dmitry S <11892559+swift1337@users.noreply.github.com> --- changelog.md | 1 + testutil/sample/crypto.go | 19 +- x/crosschain/types/cctx_test.go | 3 +- x/crosschain/types/revert_options_test.go | 3 +- zetaclient/chains/bitcoin/client/client.go | 8 +- .../chains/bitcoin/client/client_test.go | 4 +- zetaclient/chains/bitcoin/client/helpers.go | 34 ++ zetaclient/chains/bitcoin/client/mockgen.go | 1 + zetaclient/chains/bitcoin/common/fee.go | 69 +-- zetaclient/chains/bitcoin/common/fee_test.go | 107 +++-- zetaclient/chains/bitcoin/observer/db.go | 76 +++ zetaclient/chains/bitcoin/observer/db_test.go | 143 ++++++ .../chains/bitcoin/observer/event_test.go | 9 +- .../chains/bitcoin/observer/gas_price.go | 58 +++ .../chains/bitcoin/observer/inbound_test.go | 4 +- .../chains/bitcoin/observer/observer.go | 233 +--------- .../chains/bitcoin/observer/observer_test.go | 131 +++--- .../chains/bitcoin/observer/outbound.go | 412 +++++++--------- .../chains/bitcoin/observer/outbound_test.go | 260 ----------- zetaclient/chains/bitcoin/observer/utxos.go | 192 ++++++++ .../chains/bitcoin/observer/utxos_test.go | 321 +++++++++++++ .../chains/bitcoin/signer/outbound_data.go | 129 +++++ .../bitcoin/signer/outbound_data_test.go | 205 ++++++++ zetaclient/chains/bitcoin/signer/sign.go | 243 ++++++++++ zetaclient/chains/bitcoin/signer/sign_test.go | 439 ++++++++++++++++++ zetaclient/chains/bitcoin/signer/signer.go | 378 +++------------ .../chains/bitcoin/signer/signer_test.go | 381 ++++++++------- zetaclient/chains/evm/observer/observer.go | 2 +- zetaclient/chains/evm/signer/signer.go | 5 +- zetaclient/logs/fields.go | 1 + zetaclient/metrics/telemetry.go | 7 + ...20c6d8465e4528fc0f3410b599dc0b26753a0.json | 95 ++++ ...20c6d8465e4528fc0f3410b599dc0b26753a0.json | 58 +++ zetaclient/testutils/mocks/bitcoin_client.go | 30 +- .../testutils/mocks/zetacore_client_opts.go | 11 + zetaclient/testutils/testdata.go | 8 + zetaclient/testutils/testdata_naming.go | 5 + zetaclient/tss/crypto.go | 12 +- zetaclient/tss/crypto_test.go | 7 + zetaclient/zetacore/broadcast.go | 10 +- zetaclient/zetacore/broadcast_test.go | 2 +- 41 files changed, 2719 insertions(+), 1397 deletions(-) create mode 100644 zetaclient/chains/bitcoin/observer/db.go create mode 100644 zetaclient/chains/bitcoin/observer/db_test.go create mode 100644 zetaclient/chains/bitcoin/observer/gas_price.go create mode 100644 zetaclient/chains/bitcoin/observer/utxos.go create mode 100644 zetaclient/chains/bitcoin/observer/utxos_test.go create mode 100644 zetaclient/chains/bitcoin/signer/outbound_data.go create mode 100644 zetaclient/chains/bitcoin/signer/outbound_data_test.go create mode 100644 zetaclient/chains/bitcoin/signer/sign.go create mode 100644 zetaclient/chains/bitcoin/signer/sign_test.go create mode 100644 zetaclient/testdata/btc/chain_8332_msgtx_030cd813443f7b70cc6d8a544d320c6d8465e4528fc0f3410b599dc0b26753a0.json create mode 100644 zetaclient/testdata/btc/chain_8332_tx_030cd813443f7b70cc6d8a544d320c6d8465e4528fc0f3410b599dc0b26753a0.json diff --git a/changelog.md b/changelog.md index 5db074482a..ea065379d4 100644 --- a/changelog.md +++ b/changelog.md @@ -21,6 +21,7 @@ * [3332](https://github.com/zeta-chain/node/pull/3332) - implement orchestrator V2. Move BTC observer-signer to V2 * [3360](https://github.com/zeta-chain/node/pull/3360) - update protocol contract imports using consolidated path * [3349](https://github.com/zeta-chain/node/pull/3349) - implement new bitcoin rpc in zetaclient with improved performance and observability +* [3381](https://github.com/zeta-chain/node/pull/3381) - split Bitcoin observer and signer into small files and organize outbound logic into reusable/testable functions; renaming, type unification, etc. * [3390](https://github.com/zeta-chain/node/pull/3390) - orchestrator V2: EVM observer-signer * [3426](https://github.com/zeta-chain/node/pull/3426) - use protocol contracts V2 with Bitcoin deposits * [3326](https://github.com/zeta-chain/node/pull/3326) - improve error messages for cctx status object diff --git a/testutil/sample/crypto.go b/testutil/sample/crypto.go index 783ffa4a8d..2ff635678f 100644 --- a/testutil/sample/crypto.go +++ b/testutil/sample/crypto.go @@ -8,14 +8,15 @@ import ( "strconv" "testing" - "github.com/btcsuite/btcd/btcec/v2" "github.com/btcsuite/btcd/btcutil" "github.com/btcsuite/btcd/chaincfg" "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/txscript" "github.com/cometbft/cometbft/crypto/secp256k1" "github.com/cosmos/cosmos-sdk/crypto/keys/ed25519" cryptotypes "github.com/cosmos/cosmos-sdk/crypto/types" sdk "github.com/cosmos/cosmos-sdk/types" + secp "github.com/decred/dcrd/dcrec/secp256k1/v4" ethcommon "github.com/ethereum/go-ethereum/common" ethtypes "github.com/ethereum/go-ethereum/core/types" ethcrypto "github.com/ethereum/go-ethereum/crypto" @@ -90,16 +91,24 @@ func EthAddressFromRand(r *rand.Rand) ethcommon.Address { return ethcommon.BytesToAddress(sdk.AccAddress(PubKey(r).Address()).Bytes()) } -// BtcAddressP2WPKH returns a sample btc P2WPKH address -func BtcAddressP2WPKH(t *testing.T, net *chaincfg.Params) string { - privateKey, err := btcec.NewPrivateKey() +// BTCAddressP2WPKH returns a sample Bitcoin Pay-to-Witness-Public-Key-Hash (P2WPKH) address +func BTCAddressP2WPKH(t *testing.T, r *rand.Rand, net *chaincfg.Params) *btcutil.AddressWitnessPubKeyHash { + privateKey, err := secp.GeneratePrivateKeyFromRand(r) require.NoError(t, err) pubKeyHash := btcutil.Hash160(privateKey.PubKey().SerializeCompressed()) addr, err := btcutil.NewAddressWitnessPubKeyHash(pubKeyHash, net) require.NoError(t, err) - return addr.String() + return addr +} + +// BtcAddressP2WPKH returns a pkscript for a sample btc P2WPKH address +func BTCAddressP2WPKHScript(t *testing.T, r *rand.Rand, net *chaincfg.Params) []byte { + addr := BTCAddressP2WPKH(t, r, net) + script, err := txscript.PayToAddrScript(addr) + require.NoError(t, err) + return script } // SolanaPrivateKey returns a sample solana private key diff --git a/x/crosschain/types/cctx_test.go b/x/crosschain/types/cctx_test.go index a98f7dc1ca..8b0207c251 100644 --- a/x/crosschain/types/cctx_test.go +++ b/x/crosschain/types/cctx_test.go @@ -138,10 +138,11 @@ func Test_SetRevertOutboundValues(t *testing.T) { }) t.Run("successfully set BTC revert address V1", func(t *testing.T) { + r := sample.Rand() cctx := sample.CrossChainTx(t, "test") cctx.InboundParams.SenderChainId = chains.BitcoinTestnet.ChainId cctx.OutboundParams = cctx.OutboundParams[:1] - cctx.RevertOptions.RevertAddress = sample.BtcAddressP2WPKH(t, &chaincfg.TestNet3Params) + cctx.RevertOptions.RevertAddress = sample.BTCAddressP2WPKH(t, r, &chaincfg.TestNet3Params).String() err := cctx.AddRevertOutbound(100) require.NoError(t, err) diff --git a/x/crosschain/types/revert_options_test.go b/x/crosschain/types/revert_options_test.go index c91927dd86..5c4757892b 100644 --- a/x/crosschain/types/revert_options_test.go +++ b/x/crosschain/types/revert_options_test.go @@ -49,7 +49,8 @@ func TestRevertOptions_GetEVMRevertAddress(t *testing.T) { func TestRevertOptions_GetBTCRevertAddress(t *testing.T) { t.Run("valid Bitcoin revert address", func(t *testing.T) { - addr := sample.BtcAddressP2WPKH(t, &chaincfg.TestNet3Params) + r := sample.Rand() + addr := sample.BTCAddressP2WPKH(t, r, &chaincfg.TestNet3Params).String() actualAddr, valid := types.RevertOptions{ RevertAddress: addr, }.GetBTCRevertAddress(chains.BitcoinTestnet.ChainId) diff --git a/zetaclient/chains/bitcoin/client/client.go b/zetaclient/chains/bitcoin/client/client.go index 44de17171a..f6864464bf 100644 --- a/zetaclient/chains/bitcoin/client/client.go +++ b/zetaclient/chains/bitcoin/client/client.go @@ -38,6 +38,7 @@ import ( "github.com/rs/zerolog" "github.com/tendermint/btcd/chaincfg" + pkgchains "github.com/zeta-chain/node/pkg/chains" "github.com/zeta-chain/node/zetaclient/config" "github.com/zeta-chain/node/zetaclient/logs" "github.com/zeta-chain/node/zetaclient/metrics" @@ -48,6 +49,7 @@ type Client struct { hostURL string client *http.Client clientName string + isRegnet bool config config.BTCConfig params chains.Params logger zerolog.Logger @@ -81,7 +83,10 @@ func New(cfg config.BTCConfig, chainID int64, logger zerolog.Logger, opts ...Opt return nil, errors.Wrap(err, "unable to resolve chain params") } - clientName := fmt.Sprintf("btc:%d", chainID) + var ( + clientName = fmt.Sprintf("btc:%d", chainID) + isRegnet = pkgchains.IsBitcoinRegnet(chainID) + ) c := &Client{ hostURL: normalizeHostURL(cfg.RPCHost, true), @@ -89,6 +94,7 @@ func New(cfg config.BTCConfig, chainID int64, logger zerolog.Logger, opts ...Opt config: cfg, params: params, clientName: clientName, + isRegnet: isRegnet, logger: logger.With(). Str(logs.FieldModule, "btc_client"). Int64(logs.FieldChain, chainID). diff --git a/zetaclient/chains/bitcoin/client/client_test.go b/zetaclient/chains/bitcoin/client/client_test.go index d4b4ffd140..55091ebbef 100644 --- a/zetaclient/chains/bitcoin/client/client_test.go +++ b/zetaclient/chains/bitcoin/client/client_test.go @@ -137,7 +137,7 @@ func TestClientLive(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= @@ -332,7 +332,7 @@ func TestClientLive(t *testing.T) { require.NoError(t, err) // go back whatever blocks as needed - endBlock := startBlock - 100 + endBlock := startBlock - 1 // loop through mempool.space blocks backwards for bn := startBlock; bn >= endBlock; { diff --git a/zetaclient/chains/bitcoin/client/helpers.go b/zetaclient/chains/bitcoin/client/helpers.go index d33050337b..c6128b73e2 100644 --- a/zetaclient/chains/bitcoin/client/helpers.go +++ b/zetaclient/chains/bitcoin/client/helpers.go @@ -9,6 +9,16 @@ import ( "github.com/btcsuite/btcd/btcutil" "github.com/btcsuite/btcd/chaincfg/chainhash" "github.com/pkg/errors" + + "github.com/zeta-chain/node/zetaclient/chains/bitcoin/common" +) + +const ( + // FeeRateRegnet is the hardcoded fee rate for regnet + FeeRateRegnet = 1 + + // maxBTCSupply is the maximum supply of Bitcoin + maxBTCSupply = 21000000.0 ) // GetBlockVerboseByStr alias for GetBlockVerbose @@ -104,6 +114,30 @@ func (c *Client) GetRawTransactionResult(ctx context.Context, } } +// GetEstimatedFeeRate gets estimated smart fee rate (sat/vB) targeting given block confirmation +func (c *Client) GetEstimatedFeeRate(ctx context.Context, confTarget int64) (satsPerByte int64, err error) { + // RPC 'EstimateSmartFee' is not available in regnet + if c.isRegnet { + return FeeRateRegnet, nil + } + + feeResult, err := c.EstimateSmartFee(ctx, confTarget, &types.EstimateModeEconomical) + switch { + case err != nil: + return 0, errors.Wrap(err, "unable to estimate smart fee") + case feeResult.Errors != nil: + return 0, fmt.Errorf("fee result contains errors: %s", feeResult.Errors) + case feeResult.FeeRate == nil: + return 0, errors.New("nil fee rate") + } + + feeRate := *feeResult.FeeRate + if feeRate <= 0 || feeRate >= maxBTCSupply { + return 0, fmt.Errorf("invalid fee rate: %f", feeRate) + } + return common.FeeRateToSatPerByte(feeRate), nil +} + // GetTransactionFeeAndRate gets the transaction fee and rate for a given tx result func (c *Client) GetTransactionFeeAndRate(ctx context.Context, rawResult *types.TxRawResult) (int64, int64, error) { var ( diff --git a/zetaclient/chains/bitcoin/client/mockgen.go b/zetaclient/chains/bitcoin/client/mockgen.go index 8200cf20b3..ca7093aa73 100644 --- a/zetaclient/chains/bitcoin/client/mockgen.go +++ b/zetaclient/chains/bitcoin/client/mockgen.go @@ -45,6 +45,7 @@ type client interface { SendRawTransaction(ctx context.Context, tx *wire.MsgTx, allowHighFees bool) (*hash.Hash, error) + GetEstimatedFeeRate(ctx context.Context, confTarget int64) (int64, error) GetTransactionFeeAndRate(ctx context.Context, tx *types.TxRawResult) (int64, int64, error) EstimateSmartFee( ctx context.Context, diff --git a/zetaclient/chains/bitcoin/common/fee.go b/zetaclient/chains/bitcoin/common/fee.go index e77de1c6b9..ffa457f784 100644 --- a/zetaclient/chains/bitcoin/common/fee.go +++ b/zetaclient/chains/bitcoin/common/fee.go @@ -5,7 +5,6 @@ import ( "encoding/hex" "fmt" "math" - "math/big" "github.com/btcsuite/btcd/blockchain" "github.com/btcsuite/btcd/btcjson" @@ -20,19 +19,20 @@ 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 == EstimateOutboundSize(2, 2, toP2WPKH) + OutboundBytesMax = int64(1543) // 1543v == EstimateOutboundSize(21, 2, toP2TR) + + // bytesPerKB is the number of vB in a KB + bytesPerKB = 1000 // defaultDepositorFeeRate is the default fee rate for depositor fee, 20 sat/vB defaultDepositorFeeRate = 20 @@ -49,6 +49,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) @@ -67,34 +68,35 @@ type RPC interface { // DepositorFeeCalculator is a function type to calculate the Bitcoin depositor fee type DepositorFeeCalculator func(context.Context, RPC, *btcjson.TxRawResult, *chaincfg.Params) (float64, error) -// FeeRateToSatPerByte converts a fee rate in BTC/KB to sat/byte. -func FeeRateToSatPerByte(rate float64) *big.Int { +// FeeRateToSatPerByte converts a fee rate from BTC/KB to sat/vB. +func FeeRateToSatPerByte(rate float64) int64 { + satPerKB := rate * btcutil.SatoshiPerBitcoin // #nosec G115 always in range - satPerKB := new(big.Int).SetInt64(int64(rate * btcutil.SatoshiPerBitcoin)) - return new(big.Int).Div(satPerKB, big.NewInt(bytesPerKB)) + return int64(satPerKB / bytesPerKB) } // WiredTxSize calculates the wired tx size in bytes -func WiredTxSize(numInputs uint64, numOutputs uint64) uint64 { +func WiredTxSize(numInputs uint64, numOutputs uint64) int64 { // Version 4 bytes + LockTime 4 bytes + Serialized varint size for the // number of transaction inputs and outputs. // #nosec G115 always positive - return uint64(8 + wire.VarIntSerializeSize(numInputs) + wire.VarIntSerializeSize(numOutputs)) + return int64(8 + wire.VarIntSerializeSize(numInputs) + wire.VarIntSerializeSize(numOutputs)) } // EstimateOutboundSize estimates the size of an outbound in vBytes -func EstimateOutboundSize(numInputs uint64, payees []btcutil.Address) (uint64, error) { - if numInputs == 0 { +func EstimateOutboundSize(numInputs int64, payees []btcutil.Address) (int64, error) { + if numInputs <= 0 { return 0, nil } // #nosec G115 always positive numOutputs := 2 + uint64(len(payees)) - bytesWiredTx := WiredTxSize(numInputs, numOutputs) + // #nosec G115 checked positive + bytesWiredTx := WiredTxSize(uint64(numInputs), numOutputs) bytesInput := numInputs * bytesPerInput - bytesOutput := uint64(2) * bytesPerOutputP2WPKH // new nonce mark, change + bytesOutput := int64(2) * bytesPerOutputP2WPKH // new nonce mark, change // calculate the size of the outputs to payees - bytesToPayees := uint64(0) + bytesToPayees := int64(0) for _, to := range payees { sizeOutput, err := GetOutputSizeByAddress(to) if err != nil { @@ -112,7 +114,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 { @@ -145,16 +147,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 } @@ -255,7 +257,7 @@ func CalcDepositorFee( // GetRecentFeeRate gets the highest fee rate from recent blocks // Note: this method should be used for testnet ONLY -func GetRecentFeeRate(ctx context.Context, rpc RPC, netParams *chaincfg.Params) (uint64, error) { +func GetRecentFeeRate(ctx context.Context, rpc RPC, 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") @@ -295,6 +297,5 @@ func GetRecentFeeRate(ctx context.Context, rpc RPC, netParams *chaincfg.Params) highestRate = defaultTestnetFeeRate } - // #nosec G115 always in range - return uint64(highestRate), nil + return highestRate, nil } diff --git a/zetaclient/chains/bitcoin/common/fee_test.go b/zetaclient/chains/bitcoin/common/fee_test.go index 8967c86cfc..4465ed6645 100644 --- a/zetaclient/chains/bitcoin/common/fee_test.go +++ b/zetaclient/chains/bitcoin/common/fee_test.go @@ -178,11 +178,52 @@ func signTx(t *testing.T, tx *wire.MsgTx, payerScript []byte, privateKey *btcec. } } +func Test_FeeRateToSatPerByte(t *testing.T) { + tests := []struct { + name string + rate float64 + expected int64 + }{ + { + name: "0 sat/vByte", + rate: 0.00000999, + expected: 0, + }, + { + name: "1 sat/vByte", + rate: 0.00001, + expected: 1, + }, + { + name: "5 sat/vByte", + rate: 0.00005999, + expected: 5, + }, + { + name: "10 sat/vByte", + rate: 0.0001, + expected: 10, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + rate := FeeRateToSatPerByte(tt.rate) + require.Equal(t, tt.expected, rate) + }) + } +} + func TestOutboundSize2In3Out(t *testing.T) { // Generate payer/payee private keys and P2WPKH addresss privateKey, _, payerScript := generateKeyPair(t, &chaincfg.TestNet3Params) _, payee, payeeScript := generateKeyPair(t, &chaincfg.TestNet3Params) + // return 0 vByte if no UTXO + vBytesEstimated, err := EstimateOutboundSize(0, []btcutil.Address{payee}) + require.NoError(t, err) + require.Zero(t, vBytesEstimated) + // 2 example UTXO txids to use in the test. utxosTxids := exampleTxids[:2] @@ -193,10 +234,9 @@ func TestOutboundSize2In3Out(t *testing.T) { addTxInputsOutputsAndSignTx(t, tx, privateKey, payerScript, utxosTxids, [][]byte{payeeScript}) // Estimate the tx size in vByte - // #nosec G115 always positive - vError := uint64(1) // 1 vByte error tolerance - vBytes := uint64(blockchain.GetTransactionWeight(btcutil.NewTx(tx)) / blockchain.WitnessScaleFactor) - vBytesEstimated, err := EstimateOutboundSize(uint64(len(utxosTxids)), []btcutil.Address{payee}) + vError := int64(1) // 1 vByte error tolerance + vBytes := blockchain.GetTransactionWeight(btcutil.NewTx(tx)) / blockchain.WitnessScaleFactor + vBytesEstimated, err = EstimateOutboundSize(int64(len(utxosTxids)), []btcutil.Address{payee}) require.NoError(t, err) if vBytes > vBytesEstimated { require.True(t, vBytes-vBytesEstimated <= vError) @@ -218,9 +258,9 @@ func TestOutboundSize21In3Out(t *testing.T) { // Estimate the tx size in vByte // #nosec G115 always positive - vError := uint64(21 / 4) // 5 vBytes error tolerance - vBytes := uint64(blockchain.GetTransactionWeight(btcutil.NewTx(tx)) / blockchain.WitnessScaleFactor) - vBytesEstimated, err := EstimateOutboundSize(uint64(len(exampleTxids)), []btcutil.Address{payee}) + vError := int64(21 / 4) // 5 vBytes error tolerance + vBytes := blockchain.GetTransactionWeight(btcutil.NewTx(tx)) / blockchain.WitnessScaleFactor + vBytesEstimated, err := EstimateOutboundSize(int64(len(exampleTxids)), []btcutil.Address{payee}) require.NoError(t, err) if vBytes > vBytesEstimated { require.True(t, vBytes-vBytesEstimated <= vError) @@ -242,11 +282,11 @@ func TestOutboundSizeXIn3Out(t *testing.T) { // Estimate the tx size // #nosec G115 always positive - vError := uint64( + vError := int64( 0.25 + float64(x)/4, ) // 1st witness incurs 0.25 more vByte error than others (which incurs 1/4 vByte per witness) - vBytes := uint64(blockchain.GetTransactionWeight(btcutil.NewTx(tx)) / blockchain.WitnessScaleFactor) - vBytesEstimated, err := EstimateOutboundSize(uint64(len(exampleTxids[:x])), []btcutil.Address{payee}) + vBytes := blockchain.GetTransactionWeight(btcutil.NewTx(tx)) / blockchain.WitnessScaleFactor + vBytesEstimated, err := EstimateOutboundSize(int64(len(exampleTxids[:x])), []btcutil.Address{payee}) require.NoError(t, err) if vBytes > vBytesEstimated { require.True(t, vBytes-vBytesEstimated <= vError) @@ -263,62 +303,62 @@ func TestGetOutputSizeByAddress(t *testing.T) { nilP2TR := (*btcutil.AddressTaproot)(nil) sizeNilP2TR, err := GetOutputSizeByAddress(nilP2TR) require.NoError(t, err) - require.Equal(t, uint64(0), sizeNilP2TR) + require.Zero(t, sizeNilP2TR) addrP2TR := getTestAddrScript(t, ScriptTypeP2TR) sizeP2TR, err := GetOutputSizeByAddress(addrP2TR) require.NoError(t, err) - require.Equal(t, uint64(bytesPerOutputP2TR), sizeP2TR) + require.Equal(t, int64(bytesPerOutputP2TR), sizeP2TR) // test nil P2WSH address and non-nil P2WSH address nilP2WSH := (*btcutil.AddressWitnessScriptHash)(nil) sizeNilP2WSH, err := GetOutputSizeByAddress(nilP2WSH) require.NoError(t, err) - require.Equal(t, uint64(0), sizeNilP2WSH) + require.Zero(t, sizeNilP2WSH) addrP2WSH := getTestAddrScript(t, ScriptTypeP2WSH) sizeP2WSH, err := GetOutputSizeByAddress(addrP2WSH) require.NoError(t, err) - require.Equal(t, uint64(bytesPerOutputP2WSH), sizeP2WSH) + require.Equal(t, int64(bytesPerOutputP2WSH), sizeP2WSH) // test nil P2WPKH address and non-nil P2WPKH address nilP2WPKH := (*btcutil.AddressWitnessPubKeyHash)(nil) sizeNilP2WPKH, err := GetOutputSizeByAddress(nilP2WPKH) require.NoError(t, err) - require.Equal(t, uint64(0), sizeNilP2WPKH) + require.Zero(t, sizeNilP2WPKH) addrP2WPKH := getTestAddrScript(t, ScriptTypeP2WPKH) sizeP2WPKH, err := GetOutputSizeByAddress(addrP2WPKH) require.NoError(t, err) - require.Equal(t, uint64(bytesPerOutputP2WPKH), sizeP2WPKH) + require.Equal(t, int64(bytesPerOutputP2WPKH), sizeP2WPKH) // test nil P2SH address and non-nil P2SH address nilP2SH := (*btcutil.AddressScriptHash)(nil) sizeNilP2SH, err := GetOutputSizeByAddress(nilP2SH) require.NoError(t, err) - require.Equal(t, uint64(0), sizeNilP2SH) + require.Zero(t, sizeNilP2SH) addrP2SH := getTestAddrScript(t, ScriptTypeP2SH) sizeP2SH, err := GetOutputSizeByAddress(addrP2SH) require.NoError(t, err) - require.Equal(t, uint64(bytesPerOutputP2SH), sizeP2SH) + require.Equal(t, int64(bytesPerOutputP2SH), sizeP2SH) // test nil P2PKH address and non-nil P2PKH address nilP2PKH := (*btcutil.AddressPubKeyHash)(nil) sizeNilP2PKH, err := GetOutputSizeByAddress(nilP2PKH) require.NoError(t, err) - require.Equal(t, uint64(0), sizeNilP2PKH) + require.Zero(t, sizeNilP2PKH) addrP2PKH := getTestAddrScript(t, ScriptTypeP2PKH) sizeP2PKH, err := GetOutputSizeByAddress(addrP2PKH) require.NoError(t, err) - require.Equal(t, uint64(bytesPerOutputP2PKH), sizeP2PKH) + require.Equal(t, int64(bytesPerOutputP2PKH), sizeP2PKH) // test unsupported address type nilP2PK := (*btcutil.AddressPubKey)(nil) sizeP2PK, err := GetOutputSizeByAddress(nilP2PK) require.ErrorContains(t, err, "cannot get output size for address type") - require.Equal(t, uint64(0), sizeP2PK) + require.Zero(t, sizeP2PK) } func TestOutputSizeP2TR(t *testing.T) { @@ -334,8 +374,7 @@ func TestOutputSizeP2TR(t *testing.T) { addTxInputsOutputsAndSignTx(t, tx, privateKey, payerScript, exampleTxids[:2], payeeScripts) // Estimate the tx size in vByte - // #nosec G115 always positive - vBytes := uint64(blockchain.GetTransactionWeight(btcutil.NewTx(tx)) / blockchain.WitnessScaleFactor) + vBytes := blockchain.GetTransactionWeight(btcutil.NewTx(tx)) / blockchain.WitnessScaleFactor vBytesEstimated, err := EstimateOutboundSize(2, payees) require.NoError(t, err) require.Equal(t, vBytes, vBytesEstimated) @@ -354,8 +393,7 @@ func TestOutputSizeP2WSH(t *testing.T) { addTxInputsOutputsAndSignTx(t, tx, privateKey, payerScript, exampleTxids[:2], payeeScripts) // Estimate the tx size in vByte - // #nosec G115 always positive - vBytes := uint64(blockchain.GetTransactionWeight(btcutil.NewTx(tx)) / blockchain.WitnessScaleFactor) + vBytes := blockchain.GetTransactionWeight(btcutil.NewTx(tx)) / blockchain.WitnessScaleFactor vBytesEstimated, err := EstimateOutboundSize(2, payees) require.NoError(t, err) require.Equal(t, vBytes, vBytesEstimated) @@ -374,8 +412,7 @@ func TestOutputSizeP2SH(t *testing.T) { addTxInputsOutputsAndSignTx(t, tx, privateKey, payerScript, exampleTxids[:2], payeeScripts) // Estimate the tx size in vByte - // #nosec G115 always positive - vBytes := uint64(blockchain.GetTransactionWeight(btcutil.NewTx(tx)) / blockchain.WitnessScaleFactor) + vBytes := blockchain.GetTransactionWeight(btcutil.NewTx(tx)) / blockchain.WitnessScaleFactor vBytesEstimated, err := EstimateOutboundSize(2, payees) require.NoError(t, err) require.Equal(t, vBytes, vBytesEstimated) @@ -394,8 +431,7 @@ func TestOutputSizeP2PKH(t *testing.T) { addTxInputsOutputsAndSignTx(t, tx, privateKey, payerScript, exampleTxids[:2], payeeScripts) // Estimate the tx size in vByte - // #nosec G115 always positive - vBytes := uint64(blockchain.GetTransactionWeight(btcutil.NewTx(tx)) / blockchain.WitnessScaleFactor) + vBytes := blockchain.GetTransactionWeight(btcutil.NewTx(tx)) / blockchain.WitnessScaleFactor vBytesEstimated, err := EstimateOutboundSize(2, payees) require.NoError(t, err) require.Equal(t, vBytes, vBytesEstimated) @@ -412,27 +448,26 @@ func TestOutboundSizeBreakdown(t *testing.T) { } // add all outbound sizes paying to each address - txSizeTotal := uint64(0) + txSizeTotal := int64(0) for _, payee := range payees { sizeOutput, err := EstimateOutboundSize(2, []btcutil.Address{payee}) require.NoError(t, err) txSizeTotal += sizeOutput } - // calculate the average outbound size + // calculate the average outbound size (245 vByte) // #nosec G115 always in range - txSizeAverage := uint64((float64(txSizeTotal))/float64(len(payees)) + 0.5) + txSizeAverage := int64((float64(txSizeTotal))/float64(len(payees)) + 0.5) // get deposit fee txSizeDepositor := OutboundSizeDepositor() - require.Equal(t, uint64(68), txSizeDepositor) + require.Equal(t, int64(68), txSizeDepositor) // get withdrawer fee txSizeWithdrawer := OutboundSizeWithdrawer() - require.Equal(t, uint64(177), txSizeWithdrawer) + require.Equal(t, int64(177), txSizeWithdrawer) // total outbound size == (deposit fee + withdrawer fee), 245 = 68 + 177 - require.Equal(t, OutboundBytesAvg, txSizeAverage) require.Equal(t, txSizeAverage, txSizeDepositor+txSizeWithdrawer) // check default depositor fee @@ -459,5 +494,5 @@ func TestOutboundSizeMinMaxError(t *testing.T) { nilP2PK := (*btcutil.AddressPubKey)(nil) size, err := EstimateOutboundSize(1, []btcutil.Address{nilP2PK}) require.Error(t, err) - require.Equal(t, uint64(0), size) + require.Zero(t, size) } diff --git a/zetaclient/chains/bitcoin/observer/db.go b/zetaclient/chains/bitcoin/observer/db.go new file mode 100644 index 0000000000..0ea2c5fdc4 --- /dev/null +++ b/zetaclient/chains/bitcoin/observer/db.go @@ -0,0 +1,76 @@ +package observer + +import ( + "context" + + "github.com/pkg/errors" + + "github.com/zeta-chain/node/pkg/chains" + "github.com/zeta-chain/node/zetaclient/logs" + clienttypes "github.com/zeta-chain/node/zetaclient/types" +) + +// SaveBroadcastedTx saves successfully broadcasted transaction +func (ob *Observer) SaveBroadcastedTx(txHash string, nonce uint64) error { + outboundID := ob.OutboundID(nonce) + ob.Mu().Lock() + ob.tssOutboundHashes[txHash] = true + ob.broadcastedTx[outboundID] = txHash + ob.Mu().Unlock() + + broadcastEntry := clienttypes.ToOutboundHashSQLType(txHash, outboundID) + if err := ob.DB().Client().Save(&broadcastEntry).Error; err != nil { + return errors.Wrapf(err, "failed to save broadcasted outbound hash %s for %s", txHash, outboundID) + } + ob.logger.Outbound.Info(). + Str(logs.FieldTx, txHash). + Str(logs.FieldOutboundID, outboundID). + Msg("saved broadcasted outbound hash to db") + + return nil +} + +// LoadLastBlockScanned loads the last scanned block from the database +func (ob *Observer) LoadLastBlockScanned(ctx context.Context) 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.rpc.GetBlockCount(ctx) + if err != nil { + return errors.Wrap(err, "unable to get block count") + } + // #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().Uint64("last_block_scanned", ob.LastBlockScanned()).Msg("LoadLastBlockScanned succeed") + + return nil +} + +// loadBroadcastedTxMap loads broadcasted transactions from the database +func (ob *Observer) loadBroadcastedTxMap() error { + var broadcastedTransactions []clienttypes.OutboundHashSQLType + + tx := ob.DB().Client().Find(&broadcastedTransactions) + if tx.Error != nil { + return errors.Wrap(tx.Error, "unable to find broadcasted txs") + } + + for _, entry := range broadcastedTransactions { + ob.tssOutboundHashes[entry.Hash] = true + ob.broadcastedTx[entry.Key] = entry.Hash + } + + return nil +} diff --git a/zetaclient/chains/bitcoin/observer/db_test.go b/zetaclient/chains/bitcoin/observer/db_test.go new file mode 100644 index 0000000000..1a3ad0c206 --- /dev/null +++ b/zetaclient/chains/bitcoin/observer/db_test.go @@ -0,0 +1,143 @@ +package observer_test + +import ( + "context" + "os" + "testing" + + "github.com/pkg/errors" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + "github.com/zeta-chain/node/pkg/chains" + "github.com/zeta-chain/node/testutil/sample" + "github.com/zeta-chain/node/zetaclient/chains/base" + "github.com/zeta-chain/node/zetaclient/chains/bitcoin/observer" +) + +func Test_SaveBroadcastedTx(t *testing.T) { + tests := []struct { + name string + wantErr string + }{ + { + name: "should be able to save broadcasted tx", + wantErr: "", + }, + { + name: "should fail on db error", + wantErr: "failed to save broadcasted outbound hash", + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + // ARRANGE + // test data + nonce := uint64(1) + txHash := sample.BtcHash().String() + dbPath := sample.CreateTempDir(t) + ob := newTestSuite(t, chains.BitcoinMainnet, withDatabasePath(dbPath)) + if tt.wantErr != "" { + // delete db to simulate db error + os.RemoveAll(dbPath) + } + + // ACT + // save a test tx + err := ob.SaveBroadcastedTx(txHash, nonce) + + // ASSERT + if tt.wantErr != "" { + require.ErrorContains(t, err, tt.wantErr) + } else { + require.NoError(t, err) + } + + // should always save broadcasted outbound to memory + gotHash, found := ob.GetBroadcastedTx(nonce) + require.True(t, found) + require.Equal(t, txHash, gotHash) + require.True(t, ob.IsTSSTransaction(txHash)) + }) + } +} + +func Test_LoadLastBlockScanned(t *testing.T) { + ctx := context.Background() + + // use Bitcoin mainnet chain for testing + chain := chains.BitcoinMainnet + + t.Run("should load last block scanned", func(t *testing.T) { + // create observer and write 199 as last block scanned + ob := newTestSuite(t, chain) + ob.WriteLastBlockScannedToDB(199) + + // load last block scanned + err := ob.LoadLastBlockScanned(ctx) + require.NoError(t, err) + require.EqualValues(t, 199, ob.LastBlockScanned()) + }) + t.Run("should fail on invalid env var", func(t *testing.T) { + // create observer + ob := newTestSuite(t, chain) + + // set invalid environment variable + envvar := base.EnvVarLatestBlockByChain(chain) + os.Setenv(envvar, "invalid") + defer os.Unsetenv(envvar) + + // load last block scanned + err := ob.LoadLastBlockScanned(ctx) + require.ErrorContains(t, err, "error LoadLastBlockScanned") + }) + t.Run("should fail on RPC error", func(t *testing.T) { + // create observer on separate path, as we need to reset last block scanned + obOther := newTestSuite(t, chain) + + // reset last block scanned to 0 so that it will be loaded from RPC + obOther.WithLastBlockScanned(0) + + // attach a mock btc client that returns rpc error + obOther.client.ExpectedCalls = nil + obOther.client.On("GetBlockCount", mock.Anything).Return(int64(0), errors.New("rpc error")) + + // load last block scanned + err := obOther.LoadLastBlockScanned(ctx) + require.ErrorContains(t, err, "unable to get block count") + }) + t.Run("should use hardcode block 100 for regtest", func(t *testing.T) { + // use regtest chain + obRegnet := newTestSuite(t, chains.BitcoinRegtest) + + // load last block scanned + err := obRegnet.LoadLastBlockScanned(ctx) + require.NoError(t, err) + require.EqualValues(t, observer.RegnetStartBlock, obRegnet.LastBlockScanned()) + }) +} + +func Test_LoadBroadcastedTxMap(t *testing.T) { + t.Run("should load broadcasted tx map", func(t *testing.T) { + // test data + nonce := uint64(1) + txHash := sample.BtcHash().String() + + // create observer and save a test tx + dbPath := sample.CreateTempDir(t) + obOld := newTestSuite(t, chains.BitcoinMainnet, withDatabasePath(dbPath)) + obOld.SaveBroadcastedTx(txHash, nonce) + + // create new observer using same db path + obNew := newTestSuite(t, chains.BitcoinMainnet, withDatabasePath(dbPath)) + + // check if the txHash is a TSS outbound + require.True(t, obNew.IsTSSTransaction(txHash)) + + // get the broadcasted tx + gotHash, found := obNew.GetBroadcastedTx(nonce) + require.True(t, found) + require.Equal(t, txHash, gotHash) + }) +} diff --git a/zetaclient/chains/bitcoin/observer/event_test.go b/zetaclient/chains/bitcoin/observer/event_test.go index d52d428795..e0ea0cb8e4 100644 --- a/zetaclient/chains/bitcoin/observer/event_test.go +++ b/zetaclient/chains/bitcoin/observer/event_test.go @@ -32,7 +32,7 @@ func createTestBtcEvent( memoStd *memo.InboundMemo, ) observer.BTCInboundEvent { return observer.BTCInboundEvent{ - FromAddress: sample.BtcAddressP2WPKH(t, net), + FromAddress: sample.BTCAddressP2WPKH(t, sample.Rand(), net).String(), ToAddress: sample.EthAddress().Hex(), MemoBytes: memo, MemoStd: memoStd, @@ -235,6 +235,8 @@ func Test_DecodeEventMemoBytes(t *testing.T) { } func Test_ValidateStandardMemo(t *testing.T) { + r := sample.Rand() + // test cases tests := []struct { name string @@ -249,7 +251,7 @@ func Test_ValidateStandardMemo(t *testing.T) { }, FieldsV0: memo.FieldsV0{ RevertOptions: crosschaintypes.RevertOptions{ - RevertAddress: sample.BtcAddressP2WPKH(t, &chaincfg.TestNet3Params), + RevertAddress: sample.BTCAddressP2WPKH(t, r, &chaincfg.TestNet3Params).String(), }, }, }, @@ -400,8 +402,9 @@ func Test_NewInboundVoteFromStdMemo(t *testing.T) { t.Run("should create new inbound vote msg with standard memo", func(t *testing.T) { // create revert options + r := sample.Rand() revertOptions := crosschaintypes.NewEmptyRevertOptions() - revertOptions.RevertAddress = sample.BtcAddressP2WPKH(t, &chaincfg.MainNetParams) + revertOptions.RevertAddress = sample.BTCAddressP2WPKH(t, r, &chaincfg.MainNetParams).String() // create test event receiver := sample.EthAddress() diff --git a/zetaclient/chains/bitcoin/observer/gas_price.go b/zetaclient/chains/bitcoin/observer/gas_price.go new file mode 100644 index 0000000000..8071702a2c --- /dev/null +++ b/zetaclient/chains/bitcoin/observer/gas_price.go @@ -0,0 +1,58 @@ +package observer + +import ( + "context" + "fmt" + + "github.com/pkg/errors" + + "github.com/zeta-chain/node/pkg/chains" + "github.com/zeta-chain/node/zetaclient/chains/bitcoin/client" + "github.com/zeta-chain/node/zetaclient/chains/bitcoin/common" +) + +// PostGasPrice posts gas price to zetacore +func (ob *Observer) PostGasPrice(ctx context.Context) error { + var ( + err error + feeRateEstimated int64 + ) + + // estimate fee rate according to network type + switch ob.Chain().NetworkType { + case chains.NetworkType_privnet: + // regnet RPC 'EstimateSmartFee' is not available + feeRateEstimated = client.FeeRateRegnet + case chains.NetworkType_testnet: + // testnet RPC 'EstimateSmartFee' can return unreasonable high fee rate + feeRateEstimated, err = common.GetRecentFeeRate(ctx, ob.rpc, ob.netParams) + if err != nil { + return errors.Wrapf(err, "unable to get recent fee rate") + } + case chains.NetworkType_mainnet: + feeRateEstimated, err = ob.rpc.GetEstimatedFeeRate(ctx, 1) + if err != nil { + return errors.Wrap(err, "unable to get estimated fee rate") + } + default: + return fmt.Errorf("unsupported bitcoin network type %d", ob.Chain().NetworkType) + } + + // query the current block number + blockNumber, err := ob.rpc.GetBlockCount(ctx) + 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 +} diff --git a/zetaclient/chains/bitcoin/observer/inbound_test.go b/zetaclient/chains/bitcoin/observer/inbound_test.go index 4e3951b120..01a44982a2 100644 --- a/zetaclient/chains/bitcoin/observer/inbound_test.go +++ b/zetaclient/chains/bitcoin/observer/inbound_test.go @@ -152,6 +152,8 @@ func TestAvgFeeRateBlock828440Errors(t *testing.T) { } func Test_GetInboundVoteFromBtcEvent(t *testing.T) { + r := sample.Rand() + // can use any bitcoin chain for testing chain := chains.BitcoinMainnet @@ -168,7 +170,7 @@ func Test_GetInboundVoteFromBtcEvent(t *testing.T) { { name: "should return vote for standard memo", event: &observer.BTCInboundEvent{ - FromAddress: sample.BtcAddressP2WPKH(t, &chaincfg.MainNetParams), + FromAddress: sample.BTCAddressP2WPKH(t, r, &chaincfg.MainNetParams).String(), // a deposit and call MemoBytes: testutil.HexToBytes( t, diff --git a/zetaclient/chains/bitcoin/observer/observer.go b/zetaclient/chains/bitcoin/observer/observer.go index 03a6e0ba5a..4b6cfec6f4 100644 --- a/zetaclient/chains/bitcoin/observer/observer.go +++ b/zetaclient/chains/bitcoin/observer/observer.go @@ -3,10 +3,7 @@ package observer import ( "context" - "fmt" - "math" "math/big" - "sort" "sync/atomic" "time" @@ -20,8 +17,6 @@ import ( "github.com/zeta-chain/node/pkg/chains" "github.com/zeta-chain/node/zetaclient/chains/base" - "github.com/zeta-chain/node/zetaclient/chains/bitcoin/common" - clienttypes "github.com/zeta-chain/node/zetaclient/types" ) type RPC interface { @@ -40,6 +35,7 @@ type RPC interface { res *btcjson.GetTransactionResult, ) (btcjson.TxRawResult, error) + GetEstimatedFeeRate(ctx context.Context, confTarget int64) (int64, error) GetTransactionFeeAndRate(ctx context.Context, tx *btcjson.TxRawResult) (int64, int64, error) EstimateSmartFee( @@ -101,8 +97,8 @@ type Observer struct { // utxos contains the UTXOs owned by the TSS address utxos []btcjson.ListUnspentResult - // includedTxHashes indexes included tx with tx hash - includedTxHashes map[string]bool + // tssOutboundHashes keeps track of outbound hashes sent from TSS address + tssOutboundHashes map[string]bool // includedTxResults indexes tx results with the outbound tx identifier includedTxResults map[string]*btcjson.GetTransactionResult @@ -131,9 +127,8 @@ func New(chain chains.Chain, baseObserver *base.Observer, rpc RPC) (*Observer, e Observer: baseObserver, netParams: netParams, rpc: rpc, - pendingNonce: 0, utxos: []btcjson.ListUnspentResult{}, - includedTxHashes: make(map[string]bool), + tssOutboundHashes: make(map[string]bool), includedTxResults: make(map[string]*btcjson.GetTransactionResult), broadcastedTx: make(map[string]string), logger: Logger{ @@ -153,17 +148,13 @@ func New(chain chains.Chain, baseObserver *base.Observer, rpc RPC) (*Observer, e } // load broadcasted transactions - if err = ob.LoadBroadcastedTxMap(); err != nil { + if err = ob.loadBroadcastedTxMap(); err != nil { return nil, errors.Wrap(err, "unable to load broadcasted tx map") } return ob, nil } -func (ob *Observer) isNodeEnabled() bool { - return ob.nodeEnabled.Load() -} - // GetPendingNonce returns the artificial pending nonce // Note: pending nonce is accessed concurrently func (ob *Observer) GetPendingNonce() uint64 { @@ -172,6 +163,12 @@ func (ob *Observer) GetPendingNonce() uint64 { return ob.pendingNonce } +func (ob *Observer) setPendingNonce(nonce uint64) { + ob.Mu().Lock() + defer ob.Mu().Unlock() + ob.pendingNonce = nonce +} + // ConfirmationsThreshold returns number of required Bitcoin confirmations depending on sent BTC amount. func (ob *Observer) ConfirmationsThreshold(amount *big.Int) int64 { if amount.Cmp(big.NewInt(BigValueSats)) >= 0 { @@ -185,142 +182,6 @@ func (ob *Observer) ConfirmationsThreshold(amount *big.Int) int64 { return int64(ob.ChainParams().ConfirmationCount) } -// PostGasPrice posts gas price to zetacore -// TODO(revamp): move to gas price file -func (ob *Observer) PostGasPrice(ctx context.Context) error { - var ( - err error - feeRateEstimated uint64 - ) - - // special handle regnet and testnet gas rate - // regnet: RPC 'EstimateSmartFee' is not available - // testnet: RPC 'EstimateSmartFee' returns unreasonable high gas rate - if ob.Chain().NetworkType != chains.NetworkType_mainnet { - feeRateEstimated, err = ob.specialHandleFeeRate(ctx) - 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.rpc.EstimateSmartFee(ctx, 1, &btcjson.EstimateModeEconomical) - if err != nil { - return errors.Wrap(err, "unable to estimate smart fee") - } - if feeResult.Errors != nil || feeResult.FeeRate == nil { - return fmt.Errorf("error getting gas price: %s", feeResult.Errors) - } - if *feeResult.FeeRate > math.MaxInt64 { - return fmt.Errorf("gas price is too large: %f", *feeResult.FeeRate) - } - feeRateEstimated = common.FeeRateToSatPerByte(*feeResult.FeeRate).Uint64() - } - - // query the current block number - blockNumber, err := ob.rpc.GetBlockCount(ctx) - if err != nil { - return errors.Wrap(err, "GetBlockCount error") - } - - // UTXO has no concept of priority fee (like eth) - const priorityFee = 0 - - // #nosec G115 always positive - _, err = ob.ZetacoreClient().PostVoteGasPrice(ctx, ob.Chain(), feeRateEstimated, priorityFee, uint64(blockNumber)) - if err != nil { - return errors.Wrap(err, "PostVoteGasPrice error") - } - - return nil -} - -// FetchUTXOs fetches TSS-owned UTXOs from the Bitcoin node -// TODO(revamp): move to UTXO file -func (ob *Observer) FetchUTXOs(ctx context.Context) error { - defer func() { - if err := recover(); err != nil { - ob.logger.UTXOs.Error().Msgf("BTC FetchUTXOs: caught panic error: %v", err) - } - }() - - // noop - if !ob.isNodeEnabled() { - return nil - } - - // This is useful when a zetaclient's pending nonce lagged behind for whatever reason. - ob.refreshPendingNonce(ctx) - - // get the current block height. - bh, err := ob.rpc.GetBlockCount(ctx) - if err != nil { - return errors.Wrap(err, "unable to get block height") - } - - maxConfirmations := int(bh) - - // List all unspent UTXOs (160ms) - tssAddr, err := ob.TSS().PubKey().AddressBTC(ob.Chain().ChainId) - if err != nil { - return errors.Wrap(err, "unable to get tss address") - } - - utxos, err := ob.rpc.ListUnspentMinMaxAddresses(ctx, 0, maxConfirmations, []btcutil.Address{tssAddr}) - if err != nil { - return errors.Wrap(err, "unable to list unspent utxo") - } - - // rigid sort to make utxo list deterministic - sort.SliceStable(utxos, func(i, j int) bool { - if utxos[i].Amount == utxos[j].Amount { - if utxos[i].TxID == utxos[j].TxID { - return utxos[i].Vout < utxos[j].Vout - } - return utxos[i].TxID < utxos[j].TxID - } - return utxos[i].Amount < utxos[j].Amount - }) - - // filter UTXOs good to spend for next TSS transaction - utxosFiltered := make([]btcjson.ListUnspentResult, 0) - for _, utxo := range utxos { - // UTXOs big enough to cover the cost of spending themselves - if utxo.Amount < common.DefaultDepositorFee { - continue - } - // we don't want to spend other people's unconfirmed UTXOs as they may not be safe to spend - if utxo.Confirmations == 0 { - if !ob.isTssTransaction(utxo.TxID) { - continue - } - } - utxosFiltered = append(utxosFiltered, utxo) - } - - ob.Mu().Lock() - ob.TelemetryServer().SetNumberOfUTXOs(len(utxosFiltered)) - ob.utxos = utxosFiltered - ob.Mu().Unlock() - return nil -} - -// SaveBroadcastedTx saves successfully broadcasted transaction -// TODO(revamp): move to db file -func (ob *Observer) SaveBroadcastedTx(txHash string, nonce uint64) { - outboundID := ob.OutboundID(nonce) - ob.Mu().Lock() - ob.broadcastedTx[outboundID] = txHash - ob.Mu().Unlock() - - broadcastEntry := clienttypes.ToOutboundHashSQLType(txHash, outboundID) - if err := ob.DB().Client().Save(&broadcastEntry).Error; err != nil { - ob.logger.Outbound.Error(). - Err(err). - Msgf("SaveBroadcastedTx: error saving broadcasted txHash %s for outbound %s", txHash, outboundID) - } - ob.logger.Outbound.Info().Msgf("SaveBroadcastedTx: saved broadcasted txHash %s for outbound %s", txHash, outboundID) -} - // GetBlockByNumberCached gets cached block (and header) by block number func (ob *Observer) GetBlockByNumberCached(ctx context.Context, blockNumber int64) (*BTCBlockNHeader, error) { if result, ok := ob.BlockCache().Get(blockNumber); ok { @@ -354,67 +215,23 @@ func (ob *Observer) GetBlockByNumberCached(ctx context.Context, blockNumber int6 return blockNheader, nil } -// LoadLastBlockScanned loads the last scanned block from the database -func (ob *Observer) LoadLastBlockScanned(ctx context.Context) 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.rpc.GetBlockCount(ctx) - 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 +// 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.tssOutboundHashes[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 -} +// GetBroadcastedTx gets successfully broadcasted transaction by nonce +func (ob *Observer) GetBroadcastedTx(nonce uint64) (string, bool) { + ob.Mu().Lock() + defer ob.Mu().Unlock() -// specialHandleFeeRate handles the fee rate for regnet and testnet -func (ob *Observer) specialHandleFeeRate(ctx context.Context) (uint64, error) { - switch ob.Chain().NetworkType { - case chains.NetworkType_privnet: - // hardcode gas price for regnet - return 1, nil - case chains.NetworkType_testnet: - feeRateEstimated, err := common.GetRecentFeeRate(ctx, ob.rpc, 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) - } + outboundID := ob.OutboundID(nonce) + txHash, found := ob.broadcastedTx[outboundID] + return txHash, found } -// isTssTransaction checks if a given transaction was sent by TSS itself. -// An unconfirmed transaction is safe to spend only if it was sent by TSS and verified by ourselves. -func (ob *Observer) isTssTransaction(txid string) bool { - _, found := ob.includedTxHashes[txid] - return found +func (ob *Observer) isNodeEnabled() bool { + return ob.nodeEnabled.Load() } diff --git a/zetaclient/chains/bitcoin/observer/observer_test.go b/zetaclient/chains/bitcoin/observer/observer_test.go index 57c023dc2b..beac15e0b4 100644 --- a/zetaclient/chains/bitcoin/observer/observer_test.go +++ b/zetaclient/chains/bitcoin/observer/observer_test.go @@ -2,6 +2,7 @@ package observer_test import ( "context" + "errors" "math/big" "os" "strconv" @@ -9,21 +10,22 @@ import ( "github.com/btcsuite/btcd/btcjson" "github.com/btcsuite/btcd/wire" - "github.com/pkg/errors" - "github.com/rs/zerolog" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" "github.com/zeta-chain/node/zetaclient/db" + "github.com/zeta-chain/node/zetaclient/testutils" "gorm.io/gorm" "github.com/zeta-chain/node/pkg/chains" "github.com/zeta-chain/node/testutil/sample" + "github.com/zeta-chain/node/x/crosschain/types" observertypes "github.com/zeta-chain/node/x/observer/types" "github.com/zeta-chain/node/zetaclient/chains/base" "github.com/zeta-chain/node/zetaclient/chains/bitcoin/observer" "github.com/zeta-chain/node/zetaclient/chains/interfaces" "github.com/zeta-chain/node/zetaclient/metrics" "github.com/zeta-chain/node/zetaclient/testutils/mocks" + "github.com/zeta-chain/node/zetaclient/testutils/testlog" clienttypes "github.com/zeta-chain/node/zetaclient/types" ) @@ -204,60 +206,6 @@ func Test_BlockCache(t *testing.T) { }) } -func Test_LoadLastBlockScanned(t *testing.T) { - // use Bitcoin mainnet chain for testing - chain := chains.BitcoinMainnet - ctx := context.Background() - - t.Run("should load last block scanned", func(t *testing.T) { - // create observer and write 199 as last block scanned - ob := newTestSuite(t, chain) - ob.WriteLastBlockScannedToDB(199) - - // load last block scanned - err := ob.LoadLastBlockScanned(ctx) - require.NoError(t, err) - require.EqualValues(t, 199, ob.LastBlockScanned()) - }) - t.Run("should fail on invalid env var", func(t *testing.T) { - // create observer - ob := newTestSuite(t, chain) - - // set invalid environment variable - envvar := base.EnvVarLatestBlockByChain(chain) - os.Setenv(envvar, "invalid") - defer os.Unsetenv(envvar) - - // load last block scanned - err := ob.LoadLastBlockScanned(ctx) - require.ErrorContains(t, err, "error LoadLastBlockScanned") - }) - t.Run("should fail on RPC error", func(t *testing.T) { - // create observer on separate path, as we need to reset last block scanned - obOther := newTestSuite(t, chain) - - // reset last block scanned to 0 so that it will be loaded from RPC - obOther.WithLastBlockScanned(0) - - // attach a mock btc client that returns rpc error - obOther.client.ExpectedCalls = nil - obOther.client.On("GetBlockCount", mock.Anything).Return(int64(0), errors.New("rpc error")) - - // load last block scanned - err := obOther.LoadLastBlockScanned(ctx) - require.ErrorContains(t, err, "rpc error") - }) - t.Run("should use hardcode block 100 for regtest", func(t *testing.T) { - // use regtest chain - obRegnet := newTestSuite(t, chains.BitcoinRegtest) - - // load last block scanned - err := obRegnet.LoadLastBlockScanned(ctx) - require.NoError(t, err) - require.EqualValues(t, observer.RegnetStartBlock, obRegnet.LastBlockScanned()) - }) -} - func TestConfirmationThreshold(t *testing.T) { chain := chains.BitcoinMainnet ob := newTestSuite(t, chain) @@ -309,43 +257,86 @@ type testSuite struct { db *db.DB } -func newTestSuite(t *testing.T, chain chains.Chain) *testSuite { - ctx := context.Background() +type testSuiteOpts struct { + dbPath string +} - require.True(t, chain.IsBitcoinChain()) +type opt func(t *testSuiteOpts) +// withDatabasePath is an option to set custom db path +func withDatabasePath(dbPath string) opt { + return func(t *testSuiteOpts) { t.dbPath = dbPath } +} + +func newTestSuite(t *testing.T, chain chains.Chain, opts ...opt) *testSuite { + // create test suite with options + var testOpts testSuiteOpts + for _, opt := range opts { + opt(&testOpts) + } + + require.True(t, chain.IsBitcoinChain()) chainParams := mocks.MockChainParams(chain.ChainId, 10) client := mocks.NewBitcoinClient(t) - client.On("GetBlockCount", mock.Anything).Return(int64(100), nil).Maybe() - zetacore := mocks.NewZetacoreClient(t) - database, err := db.NewFromSqliteInMemory(true) - require.NoError(t, err) + var tss interfaces.TSSSigner + if chains.IsBitcoinMainnet(chain.ChainId) { + tss = mocks.NewTSS(t).FakePubKey(testutils.TSSPubKeyMainnet) + } else { + tss = mocks.NewTSS(t).FakePubKey(testutils.TSSPubkeyAthens3) + } + + // create logger + logger := testlog.New(t) + baseLogger := base.Logger{Std: logger.Logger, Compliance: logger.Logger} - log := zerolog.New(zerolog.NewTestWriter(t)) + var database *db.DB + var err error + if testOpts.dbPath == "" { + database, err = db.NewFromSqliteInMemory(true) + require.NoError(t, err) + } else { + database, err = db.NewFromSqlite(testOpts.dbPath, "test.db", true) + require.NoError(t, err) + t.Cleanup(func() { os.RemoveAll(testOpts.dbPath) }) + } + + client.On("GetBlockCount", mock.Anything).Maybe().Return(int64(100), nil).Maybe() baseObserver, err := base.NewObserver( chain, chainParams, zetacore, - nil, + tss, 100, - nil, + &metrics.TelemetryServer{}, database, - base.Logger{Std: log, Compliance: log}, + baseLogger, ) require.NoError(t, err) ob, err := observer.New(chain, baseObserver, client) require.NoError(t, err) - return &testSuite{ - ctx: ctx, - Observer: ob, + ts := &testSuite{ + ctx: context.Background(), client: client, zetacore: zetacore, db: database, + Observer: ob, } + + ts.zetacore. + On("GetCctxByNonce", mock.Anything, mock.Anything, mock.Anything). + Return(ts.mockGetCCTXByNonce). + Maybe() + + return ts +} + +func (ts *testSuite) mockGetCCTXByNonce(_ context.Context, chainID int64, nonce uint64) (*types.CrossChainTx, error) { + // implement custom logic here if needed (e.g. mock) + return nil, errors.New("not implemented") } diff --git a/zetaclient/chains/bitcoin/observer/outbound.go b/zetaclient/chains/bitcoin/observer/outbound.go index 91c721cb3c..07fdebb572 100644 --- a/zetaclient/chains/bitcoin/observer/outbound.go +++ b/zetaclient/chains/bitcoin/observer/outbound.go @@ -17,69 +17,88 @@ import ( "github.com/zeta-chain/node/zetaclient/chains/bitcoin/common" "github.com/zeta-chain/node/zetaclient/chains/interfaces" "github.com/zeta-chain/node/zetaclient/compliance" + "github.com/zeta-chain/node/zetaclient/logs" "github.com/zeta-chain/node/zetaclient/zetacore" ) +const ( + // minTxConfirmations is the minimum confirmations for a Bitcoin tx to be considered valid by the observer + // Note: please change this value to 1 to be able to run the Bitcoin E2E RBF test + minTxConfirmations = 0 +) + func (ob *Observer) 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, "unable to get all outbound trackers") } + // logger fields + lf := map[string]any{ + logs.FieldMethod: "ProcessOutboundTrackers", + } + for _, tracker := range trackers { - // get original cctx parameters - outboundID := ob.OutboundID(tracker.Nonce) + // set logger fields + lf[logs.FieldNonce] = tracker.Nonce + + // get the CCTX cctx, err := ob.ZetacoreClient().GetCctxByNonce(ctx, chainID, tracker.Nonce) if err != nil { - return errors.Wrapf(err, "unable to get cctx by nonce %d", tracker.Nonce) + ob.logger.Outbound.Err(err).Fields(lf).Msg("cannot find cctx") + break } - - nonce := cctx.GetCurrentOutboundParam().TssNonce - if tracker.Nonce != nonce { // Tanmay: it doesn't hurt to check - return fmt.Errorf("tracker nonce %d not match cctx nonce %d", tracker.Nonce, nonce) - } - if len(tracker.HashList) > 1 { - ob.logger.Outbound.Warn(). - Msgf("WatchOutbound: oops, outboundID %s got multiple (%d) outbound hashes", outboundID, len(tracker.HashList)) + ob.logger.Outbound.Warn().Msgf("oops, got multiple (%d) outbound hashes", len(tracker.HashList)) } - // iterate over all txHashes to find the truly included one. - // we do it this (inefficient) way because we don't rely on the first one as it may be a false positive (for unknown reason). - txCount := 0 - var txResult *btcjson.GetTransactionResult + // Iterate over all txHashes to find the truly included outbound. + // At any time, there is guarantee that only one single txHash will be considered valid and included for each nonce. + // The reasons are: + // 1. CCTX with nonce 'N = 0' is the past and well-controlled. + // 2. Given any CCTX with nonce 'N > 0', its outbound MUST spend the previous nonce-mark UTXO (nonce N-1) to be considered valid. + // 3. Bitcoin prevents double spending of the same UTXO except for RBF. + // 4. When RBF happens, the original tx will be removed from Bitcoin core, and only the new tx will be valid. for _, txHash := range tracker.HashList { - result, inMempool := ob.checkIncludedTx(ctx, cctx, txHash.TxHash) - if result != nil && !inMempool { // included - txCount++ - txResult = result - ob.logger.Outbound.Info(). - Msgf("WatchOutbound: included outbound %s for chain %d nonce %d", txHash.TxHash, chainID, tracker.Nonce) - if txCount > 1 { - ob.logger.Outbound.Error().Msgf( - "WatchOutbound: checkIncludedTx passed, txCount %d chain %d nonce %d result %v", txCount, chainID, tracker.Nonce, result) - } + _, included := ob.TryIncludeOutbound(ctx, cctx, txHash.TxHash) + if included { + break } } - - if txCount == 1 { // should be only one txHash included for each nonce - ob.setIncludedTx(tracker.Nonce, txResult) - } else if txCount > 1 { - ob.removeIncludedTx(tracker.Nonce) // we can't tell which txHash is true, so we remove all (if any) to be safe - ob.logger.Outbound.Error().Msgf("WatchOutbound: included multiple (%d) outbound for chain %d nonce %d", txCount, chainID, tracker.Nonce) - } } return nil } -// VoteOutboundIfConfirmed checks outbound status and returns (continueKeysign, error) -func (ob *Observer) VoteOutboundIfConfirmed( +// TryIncludeOutbound tries to include an outbound for the given cctx and txHash. +// +// Due to 10-min block time, zetaclient observes outbounds both in mempool and in blocks. +// An outbound is considered included if it satisfies one of the following two cases: +// 1. a valid tx pending in mempool with confirmation == 0 +// 2. a valid tx included in a block with confirmation > 0 +// +// Returns: (txResult, included) +// +// Note: A 'included' tx may still be considered stuck if it sits in the mempool for too long. +func (ob *Observer) TryIncludeOutbound( ctx context.Context, cctx *crosschaintypes.CrossChainTx, -) (bool, error) { + txHash string, +) (*btcjson.GetTransactionResult, bool) { + nonce := cctx.GetCurrentOutboundParam().TssNonce + + // check tx inclusion and save tx result + txResult, included := ob.checkTxInclusion(ctx, cctx, txHash) + if included { + ob.SetIncludedTx(nonce, txResult) + } + + return txResult, included +} + +// VoteOutboundIfConfirmed checks outbound status and returns (continueKeysign, error) +func (ob *Observer) VoteOutboundIfConfirmed(ctx context.Context, cctx *crosschaintypes.CrossChainTx) (bool, error) { const ( // not used with Bitcoin outboundGasUsed = 0 @@ -102,6 +121,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 @@ -116,26 +138,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(). @@ -204,272 +215,159 @@ 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. // 2. The tracker is missing in zetacore. func (ob *Observer) refreshPendingNonce(ctx context.Context) { + logger := ob.logger.Outbound.With().Str(logs.FieldMethod, "refresh_pending_nonce").Logger() + // get pending nonces from zetacore p, err := ob.ZetacoreClient().GetPendingNoncesByChain(ctx, ob.Chain().ChainId) if err != nil { - ob.logger.Chain.Error().Err(err).Msg("refreshPendingNonce: error getting pending nonces") + logger.Error().Err(err).Msg("error getting pending nonces") } // 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) if err != nil { - ob.logger.Chain.Error().Err(err).Msg("refreshPendingNonce: error getting last outbound txid") + logger.Error().Err(err).Msg("error getting last outbound txid") } // set 'NonceLow' as the new pending nonce - ob.Mu().Lock() - defer ob.Mu().Unlock() - ob.pendingNonce = nonceLow - ob.logger.Chain.Info(). - Msgf("refreshPendingNonce: increase pending nonce to %d with txid %s", ob.pendingNonce, txid) + ob.setPendingNonce(nonceLow) + logger.Info().Uint64("pending_nonce", nonceLow).Str(logs.FieldTx, txid).Msg("increased pending nonce") } } -// 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) (string, error) { // There are 2 types of txids an observer can trust // 1. The ones had been verified and saved by observer self. // 2. The ones had been finalized in zetacore based on majority vote. - if res := ob.getIncludedTx(nonce); res != nil { + if res := ob.GetIncludedTx(nonce); res != nil { return res.TxID, nil } - if !test { // if not unit test, get cctx from zetacore - send, err := ob.ZetacoreClient().GetCctxByNonce(ctx, ob.Chain().ChainId, nonce) - if err != nil { - return "", errors.Wrapf(err, "getOutboundIDByNonce: error getting cctx for nonce %d", nonce) - } - txid := send.GetCurrentOutboundParam().Hash - if txid == "" { - return "", fmt.Errorf("getOutboundIDByNonce: cannot find outbound txid for nonce %d", nonce) - } - // make sure it's a real Bitcoin txid - _, getTxResult, err := ob.rpc.GetTransactionByStr(ctx, txid) - if err != nil { - return "", errors.Wrapf( - err, - "getOutboundIDByNonce: error getting outbound result for nonce %d hash %s", - nonce, - txid, - ) - } - if getTxResult.Confirmations <= 0 { // just a double check - return "", fmt.Errorf("getOutboundIDByNonce: outbound txid %s for nonce %d is not included", txid, nonce) - } - return txid, nil + + send, err := ob.ZetacoreClient().GetCctxByNonce(ctx, ob.Chain().ChainId, nonce) + if err != nil { + return "", errors.Wrapf(err, "error getting cctx for nonce %d", nonce) } - return "", fmt.Errorf("getOutboundIDByNonce: cannot find outbound txid for nonce %d", nonce) -} -// findNonceMarkUTXO finds the nonce-mark UTXO in the list of UTXOs. -func (ob *Observer) findNonceMarkUTXO(nonce uint64, txid string) (int, error) { - tssAddress := ob.TSSAddressString() - amount := chains.NonceMarkAmount(nonce) - for i, utxo := range ob.utxos { - sats, err := common.GetSatoshis(utxo.Amount) - if err != nil { - ob.logger.Outbound.Error().Err(err).Msgf("findNonceMarkUTXO: error getting satoshis for utxo %v", utxo) - } - if utxo.Address == tssAddress && sats == amount && utxo.TxID == txid && utxo.Vout == 0 { - ob.logger.Outbound.Info(). - Msgf("findNonceMarkUTXO: found nonce-mark utxo with txid %s, amount %d satoshi", utxo.TxID, sats) - return i, nil - } + txid := send.GetCurrentOutboundParam().Hash + if txid == "" { + return "", fmt.Errorf("cannot find outbound txid for nonce %d", nonce) + } + + // make sure it's a real Bitcoin txid + _, getTxResult, err := ob.rpc.GetTransactionByStr(ctx, txid) + switch { + case err != nil: + return "", errors.Wrapf(err, "error getting outbound result for nonce %d hash %s", nonce, txid) + case getTxResult.Confirmations <= 0: + // just a double check + return "", fmt.Errorf("outbound txid %s for nonce %d is not included", txid, nonce) } - return -1, fmt.Errorf("findNonceMarkUTXO: cannot find nonce-mark utxo with nonce %d", nonce) + + return txid, nil } -// checkIncludedTx checks if a txHash is included and returns (txResult, inMempool) -// Note: if txResult is nil, then inMempool flag should be ignored. -func (ob *Observer) checkIncludedTx( +// checkTxInclusion checks if a txHash is included and returns (txResult, included) +func (ob *Observer) checkTxInclusion( ctx context.Context, cctx *crosschaintypes.CrossChainTx, txHash string, ) (*btcjson.GetTransactionResult, bool) { - outboundID := ob.OutboundID(cctx.GetCurrentOutboundParam().TssNonce) - hash, getTxResult, err := ob.rpc.GetTransactionByStr(ctx, txHash) + // logger fields + lf := map[string]any{ + logs.FieldMethod: "checkTxInclusion", + logs.FieldNonce: cctx.GetCurrentOutboundParam().TssNonce, + logs.FieldTx: txHash, + } + + // fetch tx result + hash, txResult, err := ob.rpc.GetTransactionByStr(ctx, txHash) if err != nil { - ob.logger.Outbound.Error().Err(err).Msgf("checkIncludedTx: error GetTxResultByHash: %s", txHash) + ob.logger.Outbound.Warn().Err(err).Fields(lf).Msg("GetTxResultByHash failed") return nil, false } - if txHash != getTxResult.TxID { // just in case, we'll use getTxResult.TxID later - ob.logger.Outbound.Error(). - Msgf("checkIncludedTx: inconsistent txHash %s and getTxResult.TxID %s", txHash, getTxResult.TxID) + // check minimum confirmations + if txResult.Confirmations < minTxConfirmations { + ob.logger.Outbound.Warn().Fields(lf).Msgf("invalid confirmations %d", txResult.Confirmations) return nil, false } - if getTxResult.Confirmations >= 0 { // check included tx only - err = ob.checkTssOutboundResult(ctx, cctx, hash, getTxResult) - if err != nil { - ob.logger.Outbound.Error(). - Err(err). - Msgf("checkIncludedTx: error verify bitcoin outbound %s outboundID %s", txHash, outboundID) - return nil, false - } - return getTxResult, false // included + // validate tx result + err = ob.checkTssOutboundResult(ctx, cctx, hash, txResult) + if err != nil { + ob.logger.Outbound.Error().Err(err).Fields(lf).Msg("checkTssOutboundResult failed") + return nil, false } - return getTxResult, true // in mempool + + // tx is valid and included + return txResult, true } -// setIncludedTx saves included tx result in memory -func (ob *Observer) setIncludedTx(nonce uint64, getTxResult *btcjson.GetTransactionResult) { - txHash := getTxResult.TxID - outboundID := ob.OutboundID(nonce) +// SetIncludedTx saves included tx result in memory. +// - the outbounds are chained (by nonce) txs sequentially included. +// - tx results may be set in arbitrary order as the method is called across goroutines, and it doesn't matter. +func (ob *Observer) SetIncludedTx(nonce uint64, getTxResult *btcjson.GetTransactionResult) { + var ( + txHash = getTxResult.TxID + outboundID = ob.OutboundID(nonce) + lf = map[string]any{ + logs.FieldMethod: "SetIncludedTx", + logs.FieldNonce: nonce, + logs.FieldTx: txHash, + logs.FieldOutboundID: outboundID, + } + ) ob.Mu().Lock() defer ob.Mu().Unlock() res, found := ob.includedTxResults[outboundID] - if !found { // not found. - ob.includedTxHashes[txHash] = true - ob.includedTxResults[outboundID] = getTxResult // include new outbound and enforce rigid 1-to-1 mapping: nonce <===> txHash - if nonce >= ob.pendingNonce { // try increasing pending nonce on every newly included outbound + if !found { + // for new hash: + // - include new outbound and enforce rigid 1-to-1 mapping: nonce <===> txHash + // - try increasing pending nonce on every newly included outbound + ob.tssOutboundHashes[txHash] = true + ob.includedTxResults[outboundID] = getTxResult + if nonce >= ob.pendingNonce { ob.pendingNonce = nonce + 1 } - ob.logger.Outbound.Info(). - Msgf("setIncludedTx: included new bitcoin outbound %s outboundID %s pending nonce %d", txHash, outboundID, ob.pendingNonce) - } else if txHash == res.TxID { // found same hash - ob.includedTxResults[outboundID] = getTxResult // update tx result as confirmations may increase + lf["pending_nonce"] = ob.pendingNonce + ob.logger.Outbound.Info().Fields(lf).Msg("included new bitcoin outbound") + } 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().Fields(lf).Msgf("bitcoin outbound got %d confirmations", getTxResult.Confirmations) } - } else { // found other hash. + } else { + // for 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) + // we can't tell which txHash is true, so we remove all to be safe + delete(ob.tssOutboundHashes, res.TxID) + delete(ob.includedTxResults, outboundID) + lf["prior_outbound"] = res.TxID + ob.logger.Outbound.Error().Fields(lf).Msg("be alert for duplicate payment") } } -// getIncludedTx gets the receipt and transaction from memory -func (ob *Observer) getIncludedTx(nonce uint64) *btcjson.GetTransactionResult { +// GetIncludedTx gets the receipt and transaction from memory +func (ob *Observer) GetIncludedTx(nonce uint64) *btcjson.GetTransactionResult { ob.Mu().Lock() defer ob.Mu().Unlock() return ob.includedTxResults[ob.OutboundID(nonce)] } -// removeIncludedTx removes included tx from memory -func (ob *Observer) removeIncludedTx(nonce uint64) { - ob.Mu().Lock() - defer ob.Mu().Unlock() - txResult, found := ob.includedTxResults[ob.OutboundID(nonce)] - if found { - delete(ob.includedTxHashes, txResult.TxID) - delete(ob.includedTxResults, ob.OutboundID(nonce)) - } -} - // Basic TSS outbound checks: // - should be able to query the raw tx // - check if all inputs are segwit && TSS inputs @@ -485,7 +383,7 @@ func (ob *Observer) checkTssOutboundResult( nonce := params.TssNonce rawResult, err := ob.rpc.GetRawTransactionResult(ctx, hash, res) if err != nil { - return errors.Wrapf(err, "checkTssOutboundResult: error GetRawTxResultByHash %s", hash.String()) + return errors.Wrapf(err, "checkTssOutboundResult: error GetRawTransactionResult %s", hash.String()) } err = ob.checkTSSVin(ctx, rawResult.Vin, nonce) if err != nil { @@ -531,7 +429,7 @@ 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) if err != nil { return fmt.Errorf("checkTSSVin: error findTxIDByNonce %d", nonce-1) } diff --git a/zetaclient/chains/bitcoin/observer/outbound_test.go b/zetaclient/chains/bitcoin/observer/outbound_test.go index 10544871d6..3ec63b952f 100644 --- a/zetaclient/chains/bitcoin/observer/outbound_test.go +++ b/zetaclient/chains/bitcoin/observer/outbound_test.go @@ -1,9 +1,6 @@ package observer import ( - "context" - "math" - "sort" "testing" "github.com/btcsuite/btcd/btcjson" @@ -66,46 +63,6 @@ func createObserverWithPrivateKey(t *testing.T) *Observer { return ob } -// helper function to create a test Bitcoin observer with UTXOs -func createObserverWithUTXOs(t *testing.T) *Observer { - // Create Bitcoin observer - ob := createObserverWithPrivateKey(t) - tssAddress, err := ob.TSS().PubKey().AddressBTC(chains.BitcoinTestnet.ChainId) - require.NoError(t, err) - - // Create 10 dummy UTXOs (22.44 BTC in total) - ob.utxos = make([]btcjson.ListUnspentResult, 0, 10) - amounts := []float64{0.01, 0.12, 0.18, 0.24, 0.5, 1.26, 2.97, 3.28, 5.16, 8.72} - for _, amount := range amounts { - ob.utxos = append(ob.utxos, btcjson.ListUnspentResult{Address: tssAddress.EncodeAddress(), Amount: amount}) - } - return ob -} - -func mineTxNSetNonceMark(t *testing.T, ob *Observer, nonce uint64, txid string, preMarkIndex int) { - // Mine transaction - outboundID := ob.OutboundID(nonce) - ob.includedTxResults[outboundID] = &btcjson.GetTransactionResult{TxID: txid} - - // Set nonce mark - tssAddress, err := ob.TSS().PubKey().AddressBTC(chains.BitcoinMainnet.ChainId) - require.NoError(t, err) - nonceMark := btcjson.ListUnspentResult{ - TxID: txid, - Address: tssAddress.EncodeAddress(), - Amount: float64(chains.NonceMarkAmount(nonce)) * 1e-8, - } - if preMarkIndex >= 0 { // replace nonce-mark utxo - ob.utxos[preMarkIndex] = nonceMark - - } else { // add nonce-mark utxo directly - ob.utxos = append(ob.utxos, nonceMark) - } - sort.SliceStable(ob.utxos, func(i, j int) bool { - return ob.utxos[i].Amount < ob.utxos[j].Amount - }) -} - func TestCheckTSSVout(t *testing.T) { // the archived outbound raw result file and cctx file // https://blockstream.info/tx/030cd813443f7b70cc6d8a544d320c6d8465e4528fc0f3410b599dc0b26753a0 @@ -256,220 +213,3 @@ func TestCheckTSSVoutCancelled(t *testing.T) { require.ErrorContains(t, err, "not match TSS address") }) } - -func TestSelectUTXOs(t *testing.T) { - ctx := context.Background() - - ob := createObserverWithUTXOs(t) - dummyTxID := "6e6f71d281146c1fc5c755b35908ee449f26786c84e2ae18f98b268de40b7ec4" - - // Case1: nonce = 0, bootstrap - // input: utxoCap = 5, amount = 0.01, nonce = 0 - // output: [0.01], 0.01 - result, amount, _, _, err := ob.SelectUTXOs(ctx, 0.01, 5, 0, math.MaxUint16, true) - require.NoError(t, err) - require.Equal(t, 0.01, amount) - require.Equal(t, ob.utxos[0:1], result) - - // Case2: nonce = 1, must FAIL and wait for previous transaction to be mined - // input: utxoCap = 5, amount = 0.5, nonce = 1 - // output: error - result, amount, _, _, err = ob.SelectUTXOs(ctx, 0.5, 5, 1, math.MaxUint16, true) - require.Error(t, err) - require.Nil(t, result) - require.Zero(t, amount) - require.Equal(t, "getOutboundIDByNonce: cannot find outbound txid for nonce 0", err.Error()) - mineTxNSetNonceMark(t, ob, 0, dummyTxID, -1) // mine a transaction and set nonce-mark utxo for nonce 0 - - // Case3: nonce = 1, should pass now - // input: utxoCap = 5, amount = 0.5, nonce = 1 - // output: [0.00002, 0.01, 0.12, 0.18, 0.24], 0.55002 - result, amount, _, _, err = ob.SelectUTXOs(ctx, 0.5, 5, 1, math.MaxUint16, true) - require.NoError(t, err) - require.Equal(t, 0.55002, amount) - require.Equal(t, ob.utxos[0:5], result) - mineTxNSetNonceMark(t, ob, 1, dummyTxID, 0) // mine a transaction and set nonce-mark utxo for nonce 1 - - // Case4: - // input: utxoCap = 5, amount = 1.0, nonce = 2 - // output: [0.00002001, 0.01, 0.12, 0.18, 0.24, 0.5], 1.05002001 - result, amount, _, _, err = ob.SelectUTXOs(ctx, 1.0, 5, 2, math.MaxUint16, true) - require.NoError(t, err) - require.InEpsilon(t, 1.05002001, amount, 1e-8) - require.Equal(t, ob.utxos[0:6], result) - mineTxNSetNonceMark(t, ob, 2, dummyTxID, 0) // mine a transaction and set nonce-mark utxo for nonce 2 - - // Case5: should include nonce-mark utxo on the LEFT - // input: utxoCap = 5, amount = 8.05, nonce = 3 - // output: [0.00002002, 0.24, 0.5, 1.26, 2.97, 3.28], 8.25002002 - result, amount, _, _, err = ob.SelectUTXOs(ctx, 8.05, 5, 3, math.MaxUint16, true) - require.NoError(t, err) - require.InEpsilon(t, 8.25002002, amount, 1e-8) - expected := append([]btcjson.ListUnspentResult{ob.utxos[0]}, ob.utxos[4:9]...) - require.Equal(t, expected, result) - mineTxNSetNonceMark(t, ob, 24105431, dummyTxID, 0) // mine a transaction and set nonce-mark utxo for nonce 24105431 - - // Case6: should include nonce-mark utxo on the RIGHT - // input: utxoCap = 5, amount = 0.503, nonce = 24105432 - // output: [0.24107432, 0.01, 0.12, 0.18, 0.24], 0.55002002 - result, amount, _, _, err = ob.SelectUTXOs(ctx, 0.503, 5, 24105432, math.MaxUint16, true) - require.NoError(t, err) - require.InEpsilon(t, 0.79107431, amount, 1e-8) - expected = append([]btcjson.ListUnspentResult{ob.utxos[4]}, ob.utxos[0:4]...) - require.Equal(t, expected, result) - mineTxNSetNonceMark(t, ob, 24105432, dummyTxID, 4) // mine a transaction and set nonce-mark utxo for nonce 24105432 - - // Case7: should include nonce-mark utxo in the MIDDLE - // input: utxoCap = 5, amount = 1.0, nonce = 24105433 - // output: [0.24107432, 0.12, 0.18, 0.24, 0.5], 1.28107432 - result, amount, _, _, err = ob.SelectUTXOs(ctx, 1.0, 5, 24105433, math.MaxUint16, true) - require.NoError(t, err) - require.InEpsilon(t, 1.28107432, amount, 1e-8) - expected = append([]btcjson.ListUnspentResult{ob.utxos[4]}, ob.utxos[1:4]...) - expected = append(expected, ob.utxos[5]) - require.Equal(t, expected, result) - - // Case8: should work with maximum amount - // input: utxoCap = 5, amount = 16.03 - // output: [0.24107432, 1.26, 2.97, 3.28, 5.16, 8.72], 21.63107432 - result, amount, _, _, err = ob.SelectUTXOs(ctx, 16.03, 5, 24105433, math.MaxUint16, true) - require.NoError(t, err) - require.InEpsilon(t, 21.63107432, amount, 1e-8) - expected = append([]btcjson.ListUnspentResult{ob.utxos[4]}, ob.utxos[6:11]...) - require.Equal(t, expected, result) - - // Case9: must FAIL due to insufficient funds - // input: utxoCap = 5, amount = 21.64 - // output: error - result, amount, _, _, err = ob.SelectUTXOs(ctx, 21.64, 5, 24105433, math.MaxUint16, true) - require.Error(t, err) - require.Nil(t, result) - require.Zero(t, amount) - require.Equal( - t, - "SelectUTXOs: not enough btc in reserve - available : 21.63107432 , tx amount : 21.64", - err.Error(), - ) -} - -func TestUTXOConsolidation(t *testing.T) { - ctx := context.Background() - - dummyTxID := "6e6f71d281146c1fc5c755b35908ee449f26786c84e2ae18f98b268de40b7ec4" - - t.Run("should not consolidate", func(t *testing.T) { - ob := createObserverWithUTXOs(t) - mineTxNSetNonceMark(t, ob, 0, dummyTxID, -1) // mine a transaction and set nonce-mark utxo for nonce 0 - - // input: utxoCap = 10, amount = 0.01, nonce = 1, rank = 10 - // output: [0.00002, 0.01], 0.01002 - result, amount, clsdtUtxo, clsdtValue, err := ob.SelectUTXOs(ctx, 0.01, 10, 1, 10, true) - require.NoError(t, err) - require.Equal(t, 0.01002, amount) - require.Equal(t, ob.utxos[0:2], result) - require.Equal(t, uint16(0), clsdtUtxo) - require.Equal(t, 0.0, clsdtValue) - }) - - t.Run("should consolidate 1 utxo", func(t *testing.T) { - ob := createObserverWithUTXOs(t) - mineTxNSetNonceMark(t, ob, 0, dummyTxID, -1) // mine a transaction and set nonce-mark utxo for nonce 0 - - // input: utxoCap = 9, amount = 0.01, nonce = 1, rank = 9 - // output: [0.00002, 0.01, 0.12], 0.13002 - result, amount, clsdtUtxo, clsdtValue, err := ob.SelectUTXOs(ctx, 0.01, 9, 1, 9, true) - require.NoError(t, err) - require.Equal(t, 0.13002, amount) - require.Equal(t, ob.utxos[0:3], result) - require.Equal(t, uint16(1), clsdtUtxo) - require.Equal(t, 0.12, clsdtValue) - }) - - t.Run("should consolidate 3 utxos", func(t *testing.T) { - ob := createObserverWithUTXOs(t) - mineTxNSetNonceMark(t, ob, 0, dummyTxID, -1) // mine a transaction and set nonce-mark utxo for nonce 0 - - // input: utxoCap = 5, amount = 0.01, nonce = 0, rank = 5 - // output: [0.00002, 0.014, 1.26, 0.5, 0.2], 2.01002 - result, amount, clsdtUtxo, clsdtValue, err := ob.SelectUTXOs(ctx, 0.01, 5, 1, 5, true) - require.NoError(t, err) - require.Equal(t, 2.01002, amount) - expected := make([]btcjson.ListUnspentResult, 2) - copy(expected, ob.utxos[0:2]) - for i := 6; i >= 4; i-- { // append consolidated utxos in descending order - expected = append(expected, ob.utxos[i]) - } - require.Equal(t, expected, result) - require.Equal(t, uint16(3), clsdtUtxo) - require.Equal(t, 2.0, clsdtValue) - }) - - t.Run("should consolidate all utxos using rank 1", func(t *testing.T) { - ob := createObserverWithUTXOs(t) - mineTxNSetNonceMark(t, ob, 0, dummyTxID, -1) // mine a transaction and set nonce-mark utxo for nonce 0 - - // input: utxoCap = 12, amount = 0.01, nonce = 0, rank = 1 - // output: [0.00002, 0.01, 8.72, 5.16, 3.28, 2.97, 1.26, 0.5, 0.24, 0.18, 0.12], 22.44002 - result, amount, clsdtUtxo, clsdtValue, err := ob.SelectUTXOs(ctx, 0.01, 12, 1, 1, true) - require.NoError(t, err) - require.Equal(t, 22.44002, amount) - expected := make([]btcjson.ListUnspentResult, 2) - copy(expected, ob.utxos[0:2]) - for i := 10; i >= 2; i-- { // append consolidated utxos in descending order - expected = append(expected, ob.utxos[i]) - } - require.Equal(t, expected, result) - require.Equal(t, uint16(9), clsdtUtxo) - require.Equal(t, 22.43, clsdtValue) - }) - - t.Run("should consolidate 3 utxos sparse", func(t *testing.T) { - ob := createObserverWithUTXOs(t) - mineTxNSetNonceMark( - t, - ob, - 24105431, - dummyTxID, - -1, - ) // mine a transaction and set nonce-mark utxo for nonce 24105431 - - // input: utxoCap = 5, amount = 0.13, nonce = 24105432, rank = 5 - // output: [0.24107431, 0.01, 0.12, 1.26, 0.5, 0.24], 2.37107431 - result, amount, clsdtUtxo, clsdtValue, err := ob.SelectUTXOs(ctx, 0.13, 5, 24105432, 5, true) - require.NoError(t, err) - require.InEpsilon(t, 2.37107431, amount, 1e-8) - expected := append([]btcjson.ListUnspentResult{ob.utxos[4]}, ob.utxos[0:2]...) - expected = append(expected, ob.utxos[6]) - expected = append(expected, ob.utxos[5]) - expected = append(expected, ob.utxos[3]) - require.Equal(t, expected, result) - require.Equal(t, uint16(3), clsdtUtxo) - require.Equal(t, 2.0, clsdtValue) - }) - - t.Run("should consolidate all utxos sparse", func(t *testing.T) { - ob := createObserverWithUTXOs(t) - mineTxNSetNonceMark( - t, - ob, - 24105431, - dummyTxID, - -1, - ) // mine a transaction and set nonce-mark utxo for nonce 24105431 - - // input: utxoCap = 12, amount = 0.13, nonce = 24105432, rank = 1 - // output: [0.24107431, 0.01, 0.12, 8.72, 5.16, 3.28, 2.97, 1.26, 0.5, 0.24, 0.18], 22.68107431 - result, amount, clsdtUtxo, clsdtValue, err := ob.SelectUTXOs(ctx, 0.13, 12, 24105432, 1, true) - require.NoError(t, err) - require.InEpsilon(t, 22.68107431, amount, 1e-8) - expected := append([]btcjson.ListUnspentResult{ob.utxos[4]}, ob.utxos[0:2]...) - for i := 10; i >= 5; i-- { // append consolidated utxos in descending order - expected = append(expected, ob.utxos[i]) - } - expected = append(expected, ob.utxos[3]) - expected = append(expected, ob.utxos[2]) - require.Equal(t, expected, result) - require.Equal(t, uint16(8), clsdtUtxo) - require.Equal(t, 22.31, clsdtValue) - }) -} diff --git a/zetaclient/chains/bitcoin/observer/utxos.go b/zetaclient/chains/bitcoin/observer/utxos.go new file mode 100644 index 0000000000..84905cc779 --- /dev/null +++ b/zetaclient/chains/bitcoin/observer/utxos.go @@ -0,0 +1,192 @@ +package observer + +import ( + "context" + "fmt" + "sort" + + "github.com/btcsuite/btcd/btcjson" + "github.com/btcsuite/btcd/btcutil" + + "github.com/zeta-chain/node/pkg/chains" + "github.com/zeta-chain/node/zetaclient/chains/bitcoin/common" +) + +// SelectedUTXOs is a struct containing the selected UTXOs' details. +type SelectedUTXOs struct { + // A sublist of UTXOs selected for the outbound transaction. + UTXOs []btcjson.ListUnspentResult + + // The total value of the selected UTXOs. + Value float64 + + // The number of consolidated UTXOs. + ConsolidatedUTXOs uint16 + + // The total value of the consolidated UTXOs. + ConsolidatedValue float64 +} + +// FetchUTXOs fetches TSS-owned UTXOs from the Bitcoin node +func (ob *Observer) FetchUTXOs(ctx context.Context) error { + // this is useful when a zetaclient's pending nonce lagged behind for whatever reason. + ob.refreshPendingNonce(ctx) + + // list all unspent UTXOs (160ms) + tssAddr, err := ob.TSS().PubKey().AddressBTC(ob.Chain().ChainId) + if err != nil { + return err + } + utxos, err := ob.rpc.ListUnspentMinMaxAddresses(ctx, 0, 9999999, []btcutil.Address{tssAddr}) + if err != nil { + return err + } + + // rigid sort to make utxo list deterministic + sort.SliceStable(utxos, func(i, j int) bool { + if utxos[i].Amount == utxos[j].Amount { + if utxos[i].TxID == utxos[j].TxID { + return utxos[i].Vout < utxos[j].Vout + } + return utxos[i].TxID < utxos[j].TxID + } + return utxos[i].Amount < utxos[j].Amount + }) + + // filter UTXOs good to spend for next TSS transaction + utxosFiltered := make([]btcjson.ListUnspentResult, 0) + for _, utxo := range utxos { + // UTXOs big enough to cover the cost of spending themselves + if utxo.Amount < common.DefaultDepositorFee { + continue + } + // we don't want to spend other people's unconfirmed UTXOs as they may not be safe to spend + if utxo.Confirmations == 0 { + if !ob.IsTSSTransaction(utxo.TxID) { + continue + } + } + utxosFiltered = append(utxosFiltered, utxo) + } + + ob.TelemetryServer().SetNumberOfUTXOs(len(utxosFiltered)) + ob.Mu().Lock() + 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. +func (ob *Observer) SelectUTXOs( + ctx context.Context, + amount float64, + utxosToSpend uint16, + nonce uint64, + consolidateRank uint16, +) (SelectedUTXOs, 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) + if err != nil { + return SelectedUTXOs{}, err + } + ob.Mu().Lock() + defer ob.Mu().Unlock() + idx, err = ob.findNonceMarkUTXO(nonce-1, preTxid) + if err != nil { + return SelectedUTXOs{}, 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 SelectedUTXOs{}, 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 SelectedUTXOs{ + UTXOs: results, + Value: total, + ConsolidatedUTXOs: consolidatedUtxo, + ConsolidatedValue: consolidatedValue, + }, nil +} + +// findNonceMarkUTXO finds the nonce-mark UTXO in the list of UTXOs. +func (ob *Observer) findNonceMarkUTXO(nonce uint64, txid string) (int, error) { + tssAddress := ob.TSSAddressString() + amount := chains.NonceMarkAmount(nonce) + for i, utxo := range ob.utxos { + sats, err := common.GetSatoshis(utxo.Amount) + if err != nil { + ob.logger.Outbound.Error().Err(err).Msgf("FindNonceMarkUTXO: error getting satoshis for utxo %v", utxo) + } + if utxo.Address == tssAddress && sats == amount && utxo.TxID == txid && utxo.Vout == 0 { + ob.logger.Outbound.Info(). + Msgf("FindNonceMarkUTXO: found nonce-mark utxo with txid %s, amount %d satoshi", utxo.TxID, sats) + return i, nil + } + } + return -1, fmt.Errorf("FindNonceMarkUTXO: cannot find nonce-mark utxo with nonce %d", nonce) +} diff --git a/zetaclient/chains/bitcoin/observer/utxos_test.go b/zetaclient/chains/bitcoin/observer/utxos_test.go new file mode 100644 index 0000000000..57d32a43e7 --- /dev/null +++ b/zetaclient/chains/bitcoin/observer/utxos_test.go @@ -0,0 +1,321 @@ +package observer_test + +import ( + "context" + "math" + "testing" + "time" + + "github.com/btcsuite/btcd/btcjson" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + observertypes "github.com/zeta-chain/node/x/observer/types" + "golang.org/x/exp/rand" + + "github.com/zeta-chain/node/pkg/chains" +) + +func Test_FetchUTXOs(t *testing.T) { + // create test suite + ob, utxos := newTestSuitWithUTXOs(t) + + // check number of UTXOs again + require.Equal(t, len(utxos), ob.TelemetryServer().GetNumberOfUTXOs()) +} + +func Test_SelectUTXOs(t *testing.T) { + ctx := context.Background() + dummyTxID := "6e6f71d281146c1fc5c755b35908ee449f26786c84e2ae18f98b268de40b7ec4" + + t.Run("noce = 0, should bootstrap", func(t *testing.T) { + // input: utxoCap = 5, amount = 0.01, nonce = 0 + // output: [0.01], 0.01 + ob, utxos := newTestSuitWithUTXOs(t) + selected, err := ob.SelectUTXOs(ctx, 0.01, 5, 0, math.MaxUint16) + require.NoError(t, err) + require.Equal(t, 0.01, selected.Value) + require.Equal(t, utxos[0:1], selected.UTXOs) + }) + + t.Run("nonce = 1, must FAIL and wait for previous transaction to be mined", func(t *testing.T) { + // input: utxoCap = 5, amount = 0.5, nonce = 1 + // output: error + ob, _ := newTestSuitWithUTXOs(t) + selected, err := ob.SelectUTXOs(ctx, 0.5, 5, 1, math.MaxUint16) + require.Error(t, err) + require.Nil(t, selected.UTXOs) + require.Zero(t, selected.Value) + require.ErrorContains(t, err, "error getting cctx for nonce 0") + }) + + t.Run("nonce = 1, should pass when nonce mark 0 is set", func(t *testing.T) { + // input: utxoCap = 5, amount = 0.5, nonce = 1 + // output: [0.00002, 0.01, 0.12, 0.18, 0.24], 0.55002 + ob, utxos := createTestSuitWithUTXOsAndNonceMark(t, 0, dummyTxID) + selected, err := ob.SelectUTXOs(ctx, 0.5, 5, 1, math.MaxUint16) + require.NoError(t, err) + require.Equal(t, 0.55002, selected.Value) + require.Equal(t, utxos[0:5], selected.UTXOs) + }) + + t.Run("nonce = 2, should pass when nonce mark 1 is set", func(t *testing.T) { + // input: utxoCap = 5, amount = 1.0, nonce = 2 + // output: [0.00002001, 0.01, 0.12, 0.18, 0.24, 0.5], 1.05002001 + ob, utxos := createTestSuitWithUTXOsAndNonceMark(t, 1, dummyTxID) + selected, err := ob.SelectUTXOs(ctx, 1.0, 5, 2, math.MaxUint16) + require.NoError(t, err) + require.InEpsilon(t, 1.05002001, selected.Value, 1e-8) + require.Equal(t, utxos[0:6], selected.UTXOs) + }) + + t.Run("nonce = 3, should select nonce-mark utxo on the LEFT", func(t *testing.T) { + // input: utxoCap = 5, amount = 8.05, nonce = 3 + // output: [0.00002002, 0.24, 0.5, 1.26, 2.97, 3.28], 8.25002002 + ob, utxos := createTestSuitWithUTXOsAndNonceMark(t, 2, dummyTxID) + selected, err := ob.SelectUTXOs(ctx, 8.05, 5, 3, math.MaxUint16) + require.NoError(t, err) + require.InEpsilon(t, 8.25002002, selected.Value, 1e-8) + expected := append([]btcjson.ListUnspentResult{utxos[0]}, utxos[4:9]...) + require.Equal(t, expected, selected.UTXOs) + }) + + t.Run("nonce = 24105432, should select nonce-mark utxo on the RIGHT", func(t *testing.T) { + // input: utxoCap = 5, amount = 0.503, nonce = 24105432 + // output: [0.24107432, 0.01, 0.12, 0.18, 0.24], 0.7910731 + ob, utxos := createTestSuitWithUTXOsAndNonceMark(t, 24105431, dummyTxID) + selected, err := ob.SelectUTXOs(ctx, 0.503, 5, 24105432, math.MaxUint16) + require.NoError(t, err) + require.InEpsilon(t, 0.79107431, selected.Value, 1e-8) + expected := append([]btcjson.ListUnspentResult{utxos[4]}, utxos[0:4]...) + require.Equal(t, expected, selected.UTXOs) + }) + + t.Run("nonce = 24105433, should select nonce-mark utxo in the MIDDLE", func(t *testing.T) { + // input: utxoCap = 5, amount = 1.0, nonce = 24105433 + // output: [0.24107432, 0.12, 0.18, 0.24, 0.5], 1.28107432 + ob, utxos := createTestSuitWithUTXOsAndNonceMark(t, 24105432, dummyTxID) + selected, err := ob.SelectUTXOs(ctx, 1.0, 5, 24105433, math.MaxUint16) + require.NoError(t, err) + require.InEpsilon(t, 1.28107432, selected.Value, 1e-8) + expected := append([]btcjson.ListUnspentResult{utxos[4]}, utxos[1:4]...) + expected = append(expected, utxos[5]) + require.Equal(t, expected, selected.UTXOs) + }) + + t.Run("nonce = 24105433, should select biggest utxos to maximize amount", func(t *testing.T) { + // input: utxoCap = 5, amount = 16.03 + // output: [0.24107432, 1.26, 2.97, 3.28, 5.16, 8.72], 21.63107432 + ob, utxos := createTestSuitWithUTXOsAndNonceMark(t, 24105432, dummyTxID) + selected, err := ob.SelectUTXOs(ctx, 16.03, 5, 24105433, math.MaxUint16) + require.NoError(t, err) + require.InEpsilon(t, 21.63107432, selected.Value, 1e-8) + expected := append([]btcjson.ListUnspentResult{utxos[4]}, utxos[6:11]...) + require.Equal(t, expected, selected.UTXOs) + }) + + t.Run("nonce = 24105433, should fail due to insufficient funds", func(t *testing.T) { + // input: utxoCap = 5, amount = 21.64 + // output: error + ob, _ := createTestSuitWithUTXOsAndNonceMark(t, 24105432, dummyTxID) + selected, err := ob.SelectUTXOs(ctx, 21.64, 5, 24105433, math.MaxUint16) + require.Error(t, err) + require.Nil(t, selected.UTXOs) + require.Zero(t, selected.Value) + require.ErrorContains(t, err, "not enough btc in reserve - available : 21.63107432 , tx amount : 21.64") + }) +} + +func Test_SelectUTXOs_Consolidation(t *testing.T) { + ctx := context.Background() + dummyTxID := "6e6f71d281146c1fc5c755b35908ee449f26786c84e2ae18f98b268de40b7ec4" + + t.Run("should not consolidate", func(t *testing.T) { + // create test suite and set nonce-mark utxo for nonce 0 + ob, utxos := createTestSuitWithUTXOsAndNonceMark(t, 0, dummyTxID) + + // input: utxoCap = 10, amount = 0.01, nonce = 1, rank = 10 + // output: [0.00002, 0.01], 0.01002 + res, err := ob.SelectUTXOs(ctx, 0.01, 10, 1, 10) + require.NoError(t, err) + require.Equal(t, 0.01002, res.Value) + require.Equal(t, utxos[0:2], res.UTXOs) + require.Zero(t, res.ConsolidatedUTXOs) + require.Zero(t, res.ConsolidatedValue) + }) + + t.Run("should consolidate 1 utxo", func(t *testing.T) { + // create test suite and set nonce-mark utxo for nonce 0 + ob, utxos := createTestSuitWithUTXOsAndNonceMark(t, 0, dummyTxID) + + // input: utxoCap = 9, amount = 0.01, nonce = 1, rank = 9 + // output: [0.00002, 0.01, 0.12], 0.13002 + selected, err := ob.SelectUTXOs(ctx, 0.01, 9, 1, 9) + require.NoError(t, err) + require.Equal(t, 0.13002, selected.Value) + require.Equal(t, utxos[0:3], selected.UTXOs) + require.Equal(t, uint16(1), selected.ConsolidatedUTXOs) + require.Equal(t, 0.12, selected.ConsolidatedValue) + }) + + t.Run("should consolidate 3 utxos", func(t *testing.T) { + // create test suite and set nonce-mark utxo for nonce 0 + ob, utxos := createTestSuitWithUTXOsAndNonceMark(t, 0, dummyTxID) + + // input: utxoCap = 5, amount = 0.01, nonce = 0, rank = 5 + // output: [0.00002, 0.014, 1.26, 0.5, 0.2], 2.01002 + selected, err := ob.SelectUTXOs(ctx, 0.01, 5, 1, 5) + require.NoError(t, err) + require.Equal(t, 2.01002, selected.Value) + expected := make([]btcjson.ListUnspentResult, 2) + copy(expected, utxos[0:2]) + for i := 6; i >= 4; i-- { // append consolidated utxos in descending order + expected = append(expected, utxos[i]) + } + require.Equal(t, expected, selected.UTXOs) + require.Equal(t, uint16(3), selected.ConsolidatedUTXOs) + require.Equal(t, 2.0, selected.ConsolidatedValue) + }) + + t.Run("should consolidate all utxos using rank 1", func(t *testing.T) { + // create test suite and set nonce-mark utxo for nonce 0 + ob, utxos := createTestSuitWithUTXOsAndNonceMark(t, 0, dummyTxID) + + // input: utxoCap = 12, amount = 0.01, nonce = 0, rank = 1 + // output: [0.00002, 0.01, 8.72, 5.16, 3.28, 2.97, 1.26, 0.5, 0.24, 0.18, 0.12], 22.44002 + selected, err := ob.SelectUTXOs(ctx, 0.01, 12, 1, 1) + require.NoError(t, err) + require.Equal(t, 22.44002, selected.Value) + expected := make([]btcjson.ListUnspentResult, 2) + copy(expected, utxos[0:2]) + for i := 10; i >= 2; i-- { // append consolidated utxos in descending order + expected = append(expected, utxos[i]) + } + require.Equal(t, expected, selected.UTXOs) + require.Equal(t, uint16(9), selected.ConsolidatedUTXOs) + require.Equal(t, 22.43, selected.ConsolidatedValue) + }) + + t.Run("should consolidate 3 utxos sparse", func(t *testing.T) { + // create test suite and set nonce-mark utxo for nonce 24105431 + ob, utxos := createTestSuitWithUTXOsAndNonceMark(t, 24105431, dummyTxID) + + // input: utxoCap = 5, amount = 0.13, nonce = 24105432, rank = 5 + // output: [0.24107431, 0.01, 0.12, 1.26, 0.5, 0.24], 2.37107431 + selected, err := ob.SelectUTXOs(ctx, 0.13, 5, 24105432, 5) + require.NoError(t, err) + require.InEpsilon(t, 2.37107431, selected.Value, 1e-8) + expected := append([]btcjson.ListUnspentResult{utxos[4]}, utxos[0:2]...) + expected = append(expected, utxos[6]) + expected = append(expected, utxos[5]) + expected = append(expected, utxos[3]) + require.Equal(t, expected, selected.UTXOs) + require.Equal(t, uint16(3), selected.ConsolidatedUTXOs) + require.Equal(t, 2.0, selected.ConsolidatedValue) + }) + + t.Run("should consolidate all utxos sparse", func(t *testing.T) { + // create test suite and set nonce-mark utxo for nonce 24105431 + ob, utxos := createTestSuitWithUTXOsAndNonceMark(t, 24105431, dummyTxID) + + // input: utxoCap = 12, amount = 0.13, nonce = 24105432, rank = 1 + // output: [0.24107431, 0.01, 0.12, 8.72, 5.16, 3.28, 2.97, 1.26, 0.5, 0.24, 0.18], 22.68107431 + selected, err := ob.SelectUTXOs(ctx, 0.13, 12, 24105432, 1) + require.NoError(t, err) + require.InEpsilon(t, 22.68107431, selected.Value, 1e-8) + expected := append([]btcjson.ListUnspentResult{utxos[4]}, utxos[0:2]...) + for i := 10; i >= 5; i-- { // append consolidated utxos in descending order + expected = append(expected, utxos[i]) + } + expected = append(expected, utxos[3]) + expected = append(expected, utxos[2]) + require.Equal(t, expected, selected.UTXOs) + require.Equal(t, uint16(8), selected.ConsolidatedUTXOs) + require.Equal(t, 22.31, selected.ConsolidatedValue) + }) +} + +// helper function to create a test suite with UTXOs +func newTestSuitWithUTXOs(t *testing.T) (*testSuite, []btcjson.ListUnspentResult) { + // create test observer + ob := newTestSuite(t, chains.BitcoinMainnet) + + // get test UTXOs + tssAddress, err := ob.TSS().PubKey().AddressBTC(ob.Chain().ChainId) + require.NoError(t, err) + utxos := getTestUTXOs(tssAddress.EncodeAddress()) + + // mock up pending nonces and UTXOs + pendingNonces := observertypes.PendingNonces{} + ob.zetacore.On("GetPendingNoncesByChain", mock.Anything, mock.Anything).Maybe().Return(pendingNonces, nil) + ob.client.On("ListUnspentMinMaxAddresses", mock.Anything, mock.Anything, mock.Anything, mock.Anything). + Maybe(). + Return(utxos, nil) + + // update UTXOs + err = ob.FetchUTXOs(context.Background()) + require.NoError(t, err) + + return ob, utxos +} + +// helper function to create a test suite with UTXOs and nonce mark +func createTestSuitWithUTXOsAndNonceMark( + t *testing.T, + nonce uint64, + txid string, +) (*testSuite, []btcjson.ListUnspentResult) { + // create test observer + ob := newTestSuite(t, chains.BitcoinMainnet) + + // make a nonce mark UTXO + tssAddress, err := ob.TSS().PubKey().AddressBTC(ob.Chain().ChainId) + require.NoError(t, err) + nonceMark := btcjson.ListUnspentResult{ + TxID: txid, + Address: tssAddress.EncodeAddress(), + Amount: float64(chains.NonceMarkAmount(nonce)) * 1e-8, + Confirmations: 1, + } + + // get test UTXOs and append nonce-mark UTXO + utxos := getTestUTXOs(tssAddress.EncodeAddress()) + utxos = append(utxos, nonceMark) + + // mock up pending nonces and UTXOs + pendingNonces := observertypes.PendingNonces{} + ob.zetacore.On("GetPendingNoncesByChain", mock.Anything, mock.Anything).Maybe().Return(pendingNonces, nil) + ob.client.On("ListUnspentMinMaxAddresses", mock.Anything, mock.Anything, mock.Anything, mock.Anything). + Maybe(). + Return(utxos, nil) + + // update UTXOs + err = ob.FetchUTXOs(context.Background()) + require.NoError(t, err) + + // set nonce-mark + ob.Observer.SetIncludedTx(nonce, &btcjson.GetTransactionResult{TxID: txid}) + + return ob, utxos +} + +// getTestUTXOs returns a list of constant UTXOs for testing +func getTestUTXOs(owner string) []btcjson.ListUnspentResult { + // create 10 constant dummy UTXOs (22.44 BTC in total) + utxos := make([]btcjson.ListUnspentResult, 0, 10) + amounts := []float64{0.01, 0.12, 0.18, 0.24, 0.5, 1.26, 2.97, 3.28, 5.16, 8.72} + for _, amount := range amounts { + utxos = append(utxos, btcjson.ListUnspentResult{ + Address: owner, + Amount: amount, + Confirmations: 1, + }) + } + + // shuffle the UTXOs, zetaclient will always sort them + rand.Seed(uint64(time.Now().Second())) + rand.Shuffle(len(utxos), func(i, j int) { + utxos[i], utxos[j] = utxos[j], utxos[i] + }) + + return utxos +} diff --git a/zetaclient/chains/bitcoin/signer/outbound_data.go b/zetaclient/chains/bitcoin/signer/outbound_data.go new file mode 100644 index 0000000000..ed62195707 --- /dev/null +++ b/zetaclient/chains/bitcoin/signer/outbound_data.go @@ -0,0 +1,129 @@ +package signer + +import ( + "fmt" + "math" + "strconv" + + "github.com/btcsuite/btcd/btcutil" + "github.com/pkg/errors" + "github.com/rs/zerolog" + + "github.com/zeta-chain/node/pkg/chains" + "github.com/zeta-chain/node/pkg/coin" + "github.com/zeta-chain/node/pkg/constant" + "github.com/zeta-chain/node/x/crosschain/types" + "github.com/zeta-chain/node/zetaclient/chains/bitcoin/common" + "github.com/zeta-chain/node/zetaclient/compliance" +) + +// OutboundData is a data structure containing necessary data to construct a BTC outbound transaction +type OutboundData struct { + // to is the recipient address + to btcutil.Address + + // amount is the amount in BTC + amount float64 + + // amountSats is the amount in satoshis + amountSats int64 + + // feeRate is the fee rate in satoshis/vByte + feeRate int64 + + // txSize is the average size of a BTC outbound transaction + // user is charged (in ZRC20 contract) at a static txSize on each withdrawal + txSize int64 + + // nonce is the nonce of the outbound + nonce uint64 + + // height is the ZetaChain block height + height uint64 + + // cancelTx is a flag to indicate if this outbound should be cancelled + cancelTx bool +} + +// NewOutboundData creates OutboundData from the given CCTX. +func NewOutboundData( + cctx *types.CrossChainTx, + height uint64, + minRelayFee float64, + logger, loggerCompliance zerolog.Logger, +) (*OutboundData, error) { + if cctx == nil { + return nil, errors.New("cctx is nil") + } + params := cctx.GetCurrentOutboundParam() + + // support gas token only for Bitcoin outbound + if cctx.InboundParams.CoinType != coin.CoinType_Gas { + return nil, fmt.Errorf("invalid coin type %s", cctx.InboundParams.CoinType.String()) + } + + // parse fee rate + feeRate, err := strconv.ParseInt(params.GasPrice, 10, 64) + if err != nil || feeRate <= 0 { + return nil, fmt.Errorf("invalid fee rate %s", params.GasPrice) + } + + // check receiver address + to, err := chains.DecodeBtcAddress(params.Receiver, params.ReceiverChainId) + if err != nil { + return nil, errors.Wrapf(err, "cannot decode receiver address %s", params.Receiver) + } + if !chains.IsBtcAddressSupported(to) { + return nil, fmt.Errorf("unsupported receiver address %s", to.EncodeAddress()) + } + + // amount in BTC and satoshis + amount := float64(params.Amount.Uint64()) / 1e8 + amountSats := params.Amount.BigInt().Int64() + + // check gas limit + if params.CallOptions == nil { + // never happens, 'GetCurrentOutboundParam' will create it + return nil, errors.New("call options is nil") + } + if params.CallOptions.GasLimit > math.MaxInt64 { + return nil, fmt.Errorf("invalid gas limit %d", params.CallOptions.GasLimit) + } + + // add minimum relay fee (1000 satoshis/KB by default) to gasPrice to avoid minRelayTxFee error + // see: https://github.com/bitcoin/bitcoin/blob/master/src/policy/policy.h#L35 + satPerByte := common.FeeRateToSatPerByte(minRelayFee) + feeRate += satPerByte + + // compliance check + restrictedCCTX := compliance.IsCctxRestricted(cctx) + if restrictedCCTX { + compliance.PrintComplianceLog(logger, loggerCompliance, + true, params.ReceiverChainId, cctx.Index, cctx.InboundParams.Sender, params.Receiver, "BTC") + } + + // check dust amount + dustAmount := params.Amount.Uint64() < constant.BTCWithdrawalDustAmount + if dustAmount { + logger.Warn().Msgf("dust amount %d sats, canceling tx", params.Amount.Uint64()) + } + + // set the amount to 0 when the tx should be cancelled + cancelTx := restrictedCCTX || dustAmount + if cancelTx { + amount = 0.0 + amountSats = 0 + } + + return &OutboundData{ + to: to, + amount: amount, + amountSats: amountSats, + feeRate: feeRate, + // #nosec G115 checked in range + txSize: int64(params.CallOptions.GasLimit), + nonce: params.TssNonce, + height: height, + cancelTx: cancelTx, + }, nil +} diff --git a/zetaclient/chains/bitcoin/signer/outbound_data_test.go b/zetaclient/chains/bitcoin/signer/outbound_data_test.go new file mode 100644 index 0000000000..faf94920f1 --- /dev/null +++ b/zetaclient/chains/bitcoin/signer/outbound_data_test.go @@ -0,0 +1,205 @@ +package signer + +import ( + "math" + "testing" + + sdkmath "cosmossdk.io/math" + "github.com/rs/zerolog/log" + "github.com/stretchr/testify/require" + "github.com/zeta-chain/node/pkg/chains" + "github.com/zeta-chain/node/pkg/coin" + "github.com/zeta-chain/node/pkg/constant" + "github.com/zeta-chain/node/testutil/sample" + crosschaintypes "github.com/zeta-chain/node/x/crosschain/types" + "github.com/zeta-chain/node/zetaclient/config" +) + +func Test_NewOutboundData(t *testing.T) { + // sample address + chain := chains.BitcoinMainnet + receiver, err := chains.DecodeBtcAddress("bc1qaxf82vyzy8y80v000e7t64gpten7gawewzu42y", chain.ChainId) + require.NoError(t, err) + + // setup compliance config + cfg := config.Config{ + ComplianceConfig: sample.ComplianceConfig(), + } + config.LoadComplianceConfig(cfg) + + // test cases + tests := []struct { + name string + cctx *crosschaintypes.CrossChainTx + cctxModifier func(cctx *crosschaintypes.CrossChainTx) + height uint64 + minRelayFee float64 + expected *OutboundData + errMsg string + }{ + { + name: "create new outbound data successfully", + cctx: sample.CrossChainTx(t, "0x123"), + cctxModifier: func(cctx *crosschaintypes.CrossChainTx) { + cctx.InboundParams.CoinType = coin.CoinType_Gas + cctx.GetCurrentOutboundParam().Receiver = receiver.String() + cctx.GetCurrentOutboundParam().ReceiverChainId = chain.ChainId + cctx.GetCurrentOutboundParam().Amount = sdkmath.NewUint(1e7) // 0.1 BTC + cctx.GetCurrentOutboundParam().CallOptions.GasLimit = 254 // 254 bytes + cctx.GetCurrentOutboundParam().GasPrice = "10" // 10 sats/vByte + cctx.GetCurrentOutboundParam().TssNonce = 1 + }, + height: 101, + minRelayFee: 0.00001, // 1000 sat/KB + expected: &OutboundData{ + to: receiver, + amount: 0.1, + amountSats: 10000000, + feeRate: 11, // 10 + 1 (minRelayFee) + txSize: 254, + nonce: 1, + height: 101, + cancelTx: false, + }, + errMsg: "", + }, + { + name: "cctx is nil", + cctx: nil, + cctxModifier: nil, + expected: nil, + errMsg: "cctx is nil", + }, + { + name: "invalid coin types", + cctx: sample.CrossChainTx(t, "0x123"), + cctxModifier: func(cctx *crosschaintypes.CrossChainTx) { + cctx.InboundParams.CoinType = coin.CoinType_ERC20 + }, + expected: nil, + errMsg: "invalid coin type", + }, + { + name: "invalid gas price", + cctx: sample.CrossChainTx(t, "0x123"), + cctxModifier: func(cctx *crosschaintypes.CrossChainTx) { + cctx.InboundParams.CoinType = coin.CoinType_Gas + cctx.GetCurrentOutboundParam().GasPrice = "invalid" + }, + expected: nil, + errMsg: "invalid fee rate", + }, + { + name: "zero fee rate", + cctx: sample.CrossChainTx(t, "0x123"), + cctxModifier: func(cctx *crosschaintypes.CrossChainTx) { + cctx.InboundParams.CoinType = coin.CoinType_Gas + cctx.GetCurrentOutboundParam().GasPrice = "0" + }, + expected: nil, + errMsg: "invalid fee rate", + }, + { + name: "invalid receiver address", + cctx: sample.CrossChainTx(t, "0x123"), + cctxModifier: func(cctx *crosschaintypes.CrossChainTx) { + cctx.InboundParams.CoinType = coin.CoinType_Gas + cctx.GetCurrentOutboundParam().Receiver = "invalid" + }, + expected: nil, + errMsg: "cannot decode receiver address", + }, + { + name: "unsupported receiver address", + cctx: sample.CrossChainTx(t, "0x123"), + cctxModifier: func(cctx *crosschaintypes.CrossChainTx) { + cctx.InboundParams.CoinType = coin.CoinType_Gas + cctx.GetCurrentOutboundParam().Receiver = "035e4ae279bd416b5da724972c9061ec6298dac020d1e3ca3f06eae715135cdbec" + cctx.GetCurrentOutboundParam().ReceiverChainId = chain.ChainId + }, + expected: nil, + errMsg: "unsupported receiver address", + }, + { + name: "invalid gas limit", + cctx: sample.CrossChainTx(t, "0x123"), + cctxModifier: func(cctx *crosschaintypes.CrossChainTx) { + cctx.InboundParams.CoinType = coin.CoinType_Gas + cctx.GetCurrentOutboundParam().Receiver = receiver.String() + cctx.GetCurrentOutboundParam().ReceiverChainId = chain.ChainId + cctx.GetCurrentOutboundParam().CallOptions.GasLimit = math.MaxInt64 + 1 + }, + expected: nil, + errMsg: "invalid gas limit", + }, + { + name: "should cancel restricted CCTX", + cctx: sample.CrossChainTx(t, "0x123"), + cctxModifier: func(cctx *crosschaintypes.CrossChainTx) { + cctx.InboundParams.CoinType = coin.CoinType_Gas + cctx.InboundParams.Sender = sample.RestrictedEVMAddressTest + cctx.GetCurrentOutboundParam().Receiver = receiver.String() + cctx.GetCurrentOutboundParam().ReceiverChainId = chain.ChainId + cctx.GetCurrentOutboundParam().Amount = sdkmath.NewUint(1e7) // 0.1 BTC + cctx.GetCurrentOutboundParam().CallOptions.GasLimit = 254 // 254 bytes + cctx.GetCurrentOutboundParam().GasPrice = "10" // 10 sats/vByte + cctx.GetCurrentOutboundParam().TssNonce = 1 + }, + height: 101, + minRelayFee: 0.00001, // 1000 sat/KB + expected: &OutboundData{ + to: receiver, + amount: 0, // should cancel the tx + amountSats: 0, + feeRate: 11, // 10 + 1 (minRelayFee) + txSize: 254, + nonce: 1, + height: 101, + cancelTx: true, + }, + }, + { + name: "should cancel dust amount CCTX", + cctx: sample.CrossChainTx(t, "0x123"), + cctxModifier: func(cctx *crosschaintypes.CrossChainTx) { + cctx.InboundParams.CoinType = coin.CoinType_Gas + cctx.GetCurrentOutboundParam().Receiver = receiver.String() + cctx.GetCurrentOutboundParam().ReceiverChainId = chain.ChainId + cctx.GetCurrentOutboundParam().Amount = sdkmath.NewUint(constant.BTCWithdrawalDustAmount - 1) + cctx.GetCurrentOutboundParam().CallOptions.GasLimit = 254 // 254 bytes + cctx.GetCurrentOutboundParam().GasPrice = "10" // 10 sats/vByte + cctx.GetCurrentOutboundParam().TssNonce = 1 + }, + height: 101, + minRelayFee: 0.00001, // 1000 sat/KB + expected: &OutboundData{ + to: receiver, + amount: 0, // should cancel the tx + amountSats: 0, + feeRate: 11, // 10 + 1 (minRelayFee) + txSize: 254, + nonce: 1, + height: 101, + cancelTx: true, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // modify cctx if needed + if tt.cctxModifier != nil { + tt.cctxModifier(tt.cctx) + } + + outboundData, err := NewOutboundData(tt.cctx, tt.height, tt.minRelayFee, log.Logger, log.Logger) + if tt.errMsg != "" { + require.Nil(t, outboundData) + require.ErrorContains(t, err, tt.errMsg) + } else { + require.NoError(t, err) + require.Equal(t, tt.expected, outboundData) + } + }) + } +} diff --git a/zetaclient/chains/bitcoin/signer/sign.go b/zetaclient/chains/bitcoin/signer/sign.go new file mode 100644 index 0000000000..de76cb3057 --- /dev/null +++ b/zetaclient/chains/bitcoin/signer/sign.go @@ -0,0 +1,243 @@ +package signer + +import ( + "context" + "fmt" + + "github.com/btcsuite/btcd/btcec/v2" + btcecdsa "github.com/btcsuite/btcd/btcec/v2/ecdsa" + "github.com/btcsuite/btcd/btcjson" + "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/txscript" + "github.com/btcsuite/btcd/wire" + "github.com/pkg/errors" + + "github.com/zeta-chain/node/pkg/chains" + "github.com/zeta-chain/node/pkg/constant" + "github.com/zeta-chain/node/zetaclient/chains/bitcoin/common" + "github.com/zeta-chain/node/zetaclient/chains/bitcoin/observer" + "github.com/zeta-chain/node/zetaclient/logs" +) + +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) + + // we don't know how many UTXOs will be used beforehand, so we do + // a conservative estimation using the maximum size of the outbound tx: + // estimateFee = feeRate * maxTxSize + estimateFee := float64(txData.feeRate*common.OutboundBytesMax) / 1e8 + totalAmount := txData.amount + estimateFee + float64(nonceMark)*1e-8 + + // refresh unspent UTXOs and continue with keysign regardless of error + if err := ob.FetchUTXOs(ctx); err != nil { + signer.Logger(). + Std.Error(). + Err(err). + Uint64(logs.FieldNonce, txData.nonce). + Msg("FetchUTXOs failed") + } + + // select N UTXOs to cover the total expense + selected, err := ob.SelectUTXOs( + ctx, + totalAmount, + MaxNoOfInputsPerTx, + txData.nonce, + consolidationRank, + ) + if err != nil { + return nil, err + } + + // build tx and add inputs + tx := wire.NewMsgTx(wire.TxVersion) + inAmounts, err := AddTxInputs(tx, selected.UTXOs) + if err != nil { + return nil, err + } + + // size checking + // #nosec G115 always positive + txSize, err := common.EstimateOutboundSize(int64(len(selected.UTXOs)), []btcutil.Address{txData.to}) + if err != nil { + return nil, err + } + logger := signer.Logger().Std.With().Uint64("tx.nonce", txData.nonce).Int64("tx.size", txSize).Logger() + if txSize > common.OutboundBytesMax { + // in case of accident + logger.Warn().Msg("tx size is greater than outboundBytesMax") + txSize = common.OutboundBytesMax + } + + // fee calculation + fees := txSize * txData.feeRate + + // add tx outputs + inputValue := selected.Value + if err := signer.AddWithdrawTxOutputs(tx, txData.to, inputValue, txData.amountSats, nonceMark, fees, txData.cancelTx); err != nil { + return nil, err + } + signer.Logger(). + Std.Info(). + Int64("tx.rate", txData.feeRate). + Int64("tx.fees", fees). + Uint16("tx.consolidated_utxos", selected.ConsolidatedUTXOs). + Float64("tx.consolidated_value", selected.ConsolidatedValue). + Msg("signing bitcoin outbound") + + // sign the tx + if err := signer.SignTx(ctx, tx, inAmounts, txData.height, txData.nonce); err != nil { + return nil, errors.Wrap(err, "SignTx failed") + } + + return tx, nil +} + +// AddTxInputs adds the inputs to the tx and returns input amounts +func 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 + } + + outpoint := wire.NewOutPoint(hash, utxo.Vout) + txIn := wire.NewTxIn(outpoint, nil, nil) + tx.AddTxIn(txIn) + + // store the amount for later signing use + amount, err := common.GetSatoshis(utxos[i].Amount) + if err != nil { + return nil, err + } + amounts[i] = amount + } + + return amounts, nil +} + +// AddWithdrawTxOutputs adds the 3 outputs to the withdraw tx +// 1st output: the nonce-mark btc to TSS itself +// 2nd output: the payment to the recipient +// 3rd output: the remaining btc to TSS itself +// +// Note: float64 is used for for 'inputValue' because UTXOs struct uses float64. +// But we need to use 'int64' for the outputs because NewTxOut expects int64. +func (signer *Signer) AddWithdrawTxOutputs( + tx *wire.MsgTx, + to btcutil.Address, + inputValue float64, + amountSats int64, + nonceMark int64, + fees int64, + cancelTx bool, +) error { + // convert withdraw amount to BTC + amount := float64(amountSats) / 1e8 + + // calculate remaining btc (the change) to TSS self + remaining := inputValue - amount + remainingSats, err := common.GetSatoshis(remaining) + if err != nil { + return err + } + remainingSats -= fees + remainingSats -= nonceMark + if remainingSats < 0 { + return fmt.Errorf("remainder value is negative: %d", remainingSats) + } else if remainingSats == nonceMark { + signer.Logger().Std.Info().Msgf("adjust remainder value to avoid duplicate nonce-mark: %d", remainingSats) + remainingSats-- + } + + // 1st output: the nonce-mark btc to TSS self + payToSelfScript, err := signer.TSS().PubKey().BTCPayToAddrScript(signer.Chain().ChainId) + if err != nil { + return err + } + txOut1 := wire.NewTxOut(nonceMark, payToSelfScript) + tx.AddTxOut(txOut1) + + // 2nd output: the payment to the recipient + if !cancelTx { + pkScript, err := txscript.PayToAddrScript(to) + if err != nil { + return err + } + txOut2 := wire.NewTxOut(amountSats, pkScript) + tx.AddTxOut(txOut2) + } else { + // send the amount to TSS self if tx is cancelled + remainingSats += amountSats + } + + // 3rd output: the remaining btc to TSS self + if remainingSats >= constant.BTCWithdrawalDustAmount { + txOut3 := wire.NewTxOut(remainingSats, payToSelfScript) + tx.AddTxOut(txOut3) + } + return nil +} + +// SignTx signs the given tx with TSS +func (signer *Signer) SignTx( + ctx context.Context, + tx *wire.MsgTx, + inputAmounts []int64, + height uint64, + nonce uint64, +) error { + pkScript, err := signer.TSS().PubKey().BTCPayToAddrScript(signer.Chain().ChainId) + 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 errors.Wrap(err, "SignBatch failed") + } + + // add witnesses to the tx + pkCompressed := signer.TSS().PubKey().Bytes(true) + hashType := txscript.SigHashAll + 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) + + txWitness := wire.TxWitness{append(sig.Serialize(), byte(hashType)), pkCompressed} + tx.TxIn[ix].Witness = txWitness + } + + return nil +} diff --git a/zetaclient/chains/bitcoin/signer/sign_test.go b/zetaclient/chains/bitcoin/signer/sign_test.go new file mode 100644 index 0000000000..fe68de3c9c --- /dev/null +++ b/zetaclient/chains/bitcoin/signer/sign_test.go @@ -0,0 +1,439 @@ +package signer_test + +import ( + "context" + "fmt" + "reflect" + "testing" + + sdkmath "cosmossdk.io/math" + "github.com/btcsuite/btcd/btcjson" + "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/chaincfg" + "github.com/btcsuite/btcd/txscript" + "github.com/btcsuite/btcd/wire" + "github.com/pkg/errors" + "github.com/rs/zerolog" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + "github.com/zeta-chain/node/testutil/sample" + "github.com/zeta-chain/node/zetaclient/testutils" + + "github.com/zeta-chain/node/pkg/chains" + "github.com/zeta-chain/node/pkg/coin" + crosschaintypes "github.com/zeta-chain/node/x/crosschain/types" + observertypes "github.com/zeta-chain/node/x/observer/types" + "github.com/zeta-chain/node/zetaclient/chains/base" + "github.com/zeta-chain/node/zetaclient/chains/bitcoin/signer" + "github.com/zeta-chain/node/zetaclient/testutils/mocks" +) + +func Test_SignWithdrawTx(t *testing.T) { + net := &chaincfg.MainNetParams + + // make sample cctx + mkCCTX := func(t *testing.T) *crosschaintypes.CrossChainTx { + cctx := sample.CrossChainTx(t, "0x123") + cctx.InboundParams.CoinType = coin.CoinType_Gas + cctx.GetCurrentOutboundParam().GasPrice = "10" + cctx.GetCurrentOutboundParam().Receiver = sample.BTCAddressP2WPKH(t, sample.Rand(), net).String() + cctx.GetCurrentOutboundParam().ReceiverChainId = chains.BitcoinMainnet.ChainId + cctx.GetCurrentOutboundParam().Amount = sdkmath.NewUint(1e7) // 0.1 BTC + cctx.GetCurrentOutboundParam().CallOptions = &crosschaintypes.CallOptions{GasLimit: 254} + cctx.GetCurrentOutboundParam().TssNonce = 0 + return cctx + } + + // helper function to create tx data + mkTxData := func(height uint64, minRelayFee float64) signer.OutboundData { + cctx := mkCCTX(t) + txData, err := signer.NewOutboundData(cctx, height, minRelayFee, zerolog.Nop(), zerolog.Nop()) + require.NoError(t, err) + return *txData + } + + tests := []struct { + name string + chain chains.Chain + txData signer.OutboundData + failFetchUTXOs bool + failSignTx bool + fail bool + }{ + { + name: "should sign withdraw tx successfully", + chain: chains.BitcoinMainnet, + txData: mkTxData(101, 0.00001), + }, + { + name: "should fail if no UTXOs fetched due to RPC error", + chain: chains.BitcoinMainnet, + txData: mkTxData(101, 0.00001), + failFetchUTXOs: true, + fail: true, + }, + { + name: "should fail if TSS keysign fails", + chain: chains.BitcoinMainnet, + txData: mkTxData(101, 0.00001), + failSignTx: true, + fail: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // ARRANGE + // setup signer + s := newTestSuite(t, tt.chain) + btcAddress, err := s.TSS().PubKey().AddressBTC(tt.chain.ChainId) + require.NoError(t, err) + tssAddress := btcAddress.EncodeAddress() + + // mock up pending nonces + pendingNonces := observertypes.PendingNonces{} + s.zetacoreClient.On("GetPendingNoncesByChain", mock.Anything, mock.Anything). + Maybe(). + Return(pendingNonces, nil) + + // mock up utxos + utxos := []btcjson.ListUnspentResult{} + utxos = append(utxos, btcjson.ListUnspentResult{Address: tssAddress, Amount: 1.0, Confirmations: 1}) + if !tt.failFetchUTXOs { + s.client.On("ListUnspentMinMaxAddresses", mock.Anything, mock.Anything, mock.Anything, mock.Anything). + Return(utxos, nil) + } else { + s.client.On("ListUnspentMinMaxAddresses", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil, errors.New("rpc error")) + } + + // mock up TSS SignBatch error + if tt.failSignTx { + s.tss.Pause() + } + + // ACT + // sign withdraw tx + ctx := context.Background() + tx, err := s.SignWithdrawTx(ctx, &tt.txData, s.observer) + + // ASSERT + if tt.fail { + require.Error(t, err) + require.Nil(t, tx) + return + } + require.NoError(t, err) + + // check tx signature + for i := range tx.TxIn { + require.Len(t, tx.TxIn[i].Witness, 2) + } + }) + } +} + +func Test_AddTxInputs(t *testing.T) { + r := sample.Rand() + net := &chaincfg.MainNetParams + + tests := []struct { + name string + utxos []btcjson.ListUnspentResult + expectedAmounts []int64 + fail bool + }{ + { + name: "should add tx inputs successfully", + utxos: []btcjson.ListUnspentResult{ + { + TxID: sample.BtcHash().String(), + Vout: 0, + Address: sample.BTCAddressP2WPKH(t, r, net).String(), + Amount: 0.1, + }, + { + TxID: sample.BtcHash().String(), + Vout: 1, + Address: sample.BTCAddressP2WPKH(t, r, net).String(), + Amount: 0.2, + }, + }, + expectedAmounts: []int64{10000000, 20000000}, + }, + { + name: "should fail on invalid txid", + utxos: []btcjson.ListUnspentResult{ + { + TxID: "invalid txid", + }, + }, + fail: true, + }, + { + name: "should fail on invalid amount", + utxos: []btcjson.ListUnspentResult{ + { + TxID: sample.BtcHash().String(), + Amount: -0.1, + }, + }, + fail: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // create tx msg and add inputs + tx := wire.NewMsgTx(wire.TxVersion) + inAmounts, err := signer.AddTxInputs(tx, tt.utxos) + + // assert + if tt.fail { + require.Error(t, err) + require.Nil(t, inAmounts) + } else { + require.NoError(t, err) + require.Equal(t, tt.expectedAmounts, inAmounts) + } + }) + } +} + +func Test_AddWithdrawTxOutputs(t *testing.T) { + // Create test signer and receiver address + baseSigner := base.NewSigner( + chains.BitcoinMainnet, + mocks.NewTSS(t).FakePubKey(testutils.TSSPubKeyMainnet), + base.DefaultLogger(), + ) + signer := signer.New( + baseSigner, + mocks.NewBitcoinClient(t), + ) + + // tss address and script + tssAddr, err := signer.TSS().PubKey().AddressBTC(chains.BitcoinMainnet.ChainId) + require.NoError(t, err) + tssScript, err := txscript.PayToAddrScript(tssAddr) + require.NoError(t, err) + fmt.Printf("tss address: %s", tssAddr.EncodeAddress()) + + // receiver addresses + receiver := "bc1qaxf82vyzy8y80v000e7t64gpten7gawewzu42y" + to, err := chains.DecodeBtcAddress(receiver, chains.BitcoinMainnet.ChainId) + require.NoError(t, err) + toScript, err := txscript.PayToAddrScript(to) + require.NoError(t, err) + + // test cases + tests := []struct { + name string + tx *wire.MsgTx + to btcutil.Address + total float64 + amountSats int64 + nonceMark int64 + fees int64 + cancelTx bool + fail bool + message string + txout []*wire.TxOut + }{ + { + name: "should add outputs successfully", + tx: wire.NewMsgTx(wire.TxVersion), + to: to, + total: 1.00012000, + amountSats: 20000000, + nonceMark: 10000, + fees: 2000, + fail: false, + txout: []*wire.TxOut{ + {Value: 10000, PkScript: tssScript}, + {Value: 20000000, PkScript: toScript}, + {Value: 80000000, PkScript: tssScript}, + }, + }, + { + name: "should add outputs without change successfully", + tx: wire.NewMsgTx(wire.TxVersion), + to: to, + total: 0.20012000, + amountSats: 20000000, + nonceMark: 10000, + fees: 2000, + fail: false, + txout: []*wire.TxOut{ + {Value: 10000, PkScript: tssScript}, + {Value: 20000000, PkScript: toScript}, + }, + }, + { + name: "should cancel tx successfully", + tx: wire.NewMsgTx(wire.TxVersion), + to: to, + total: 1.00012000, + amountSats: 20000000, + nonceMark: 10000, + fees: 2000, + cancelTx: true, + fail: false, + txout: []*wire.TxOut{ + {Value: 10000, PkScript: tssScript}, + {Value: 100000000, PkScript: tssScript}, + }, + }, + { + name: "should fail when total < amount", + tx: wire.NewMsgTx(wire.TxVersion), + to: to, + total: 0.00012000, + amountSats: 20000000, + fail: true, + }, + { + name: "should fail when total < fees + amount + nonce", + tx: wire.NewMsgTx(wire.TxVersion), + to: to, + total: 0.20011000, + amountSats: 20000000, + nonceMark: 10000, + fees: 2000, + fail: true, + message: "remainder value is negative", + }, + { + name: "should not produce duplicate nonce mark", + tx: wire.NewMsgTx(wire.TxVersion), + to: to, + total: 0.20022000, // 0.2 + fee + nonceMark * 2 + amountSats: 20000000, + nonceMark: 10000, + fees: 2000, + fail: false, + txout: []*wire.TxOut{ + {Value: 10000, PkScript: tssScript}, + {Value: 20000000, PkScript: toScript}, + {Value: 9999, PkScript: tssScript}, // nonceMark - 1 + }, + }, + { + name: "should not produce dust change to TSS self", + tx: wire.NewMsgTx(wire.TxVersion), + to: to, + total: 0.20012999, // 0.2 + fee + nonceMark + amountSats: 20000000, + nonceMark: 10000, + fees: 2000, + fail: false, + txout: []*wire.TxOut{ // 3rd output 999 is dust and removed + {Value: 10000, PkScript: tssScript}, + {Value: 20000000, PkScript: toScript}, + }, + }, + { + name: "should fail on invalid to address", + tx: wire.NewMsgTx(wire.TxVersion), + to: nil, + total: 1.00012000, + amountSats: 20000000, + nonceMark: 10000, + fees: 2000, + fail: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := signer.AddWithdrawTxOutputs( + tt.tx, + tt.to, + tt.total, + tt.amountSats, + tt.nonceMark, + tt.fees, + tt.cancelTx, + ) + if tt.fail { + require.Error(t, err) + if tt.message != "" { + require.ErrorContains(t, err, tt.message) + } + return + } else { + require.NoError(t, err) + require.True(t, reflect.DeepEqual(tt.txout, tt.tx.TxOut)) + } + }) + } +} + +func Test_SignTx(t *testing.T) { + tests := []struct { + name string + chain chains.Chain + net *chaincfg.Params + inputs []float64 + outputs []int64 + height uint64 + nonce uint64 + }{ + { + name: "should sign tx successfully", + chain: chains.BitcoinMainnet, + net: &chaincfg.MainNetParams, + inputs: []float64{ + 0.0001, + 0.0002, + }, + outputs: []int64{ + 5000, + 20000, + }, + nonce: 100, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // setup signer + s := newTestSuite(t, tt.chain) + address, err := s.TSS().PubKey().AddressBTC(tt.chain.ChainId) + require.NoError(t, err) + + // create tx msg + tx := wire.NewMsgTx(wire.TxVersion) + + // add inputs + utxos := []btcjson.ListUnspentResult{} + for i, amount := range tt.inputs { + utxos = append(utxos, btcjson.ListUnspentResult{ + TxID: sample.BtcHash().String(), + Vout: uint32(i), + Address: address.EncodeAddress(), + Amount: amount, + }) + } + inAmounts, err := signer.AddTxInputs(tx, utxos) + require.NoError(t, err) + require.Len(t, inAmounts, len(tt.inputs)) + + // add outputs + r := sample.Rand() + for _, amount := range tt.outputs { + pkScript := sample.BTCAddressP2WPKHScript(t, r, tt.net) + tx.AddTxOut(wire.NewTxOut(amount, pkScript)) + } + + // sign tx + ctx := context.Background() + err = s.SignTx(ctx, tx, inAmounts, tt.height, tt.nonce) + require.NoError(t, err) + + // check tx signature + for i := range tx.TxIn { + require.Len(t, tx.TxIn[i].Witness, 2) + } + }) + } +} diff --git a/zetaclient/chains/bitcoin/signer/signer.go b/zetaclient/chains/bitcoin/signer/signer.go index 40d06a64d4..6884336d5a 100644 --- a/zetaclient/chains/bitcoin/signer/signer.go +++ b/zetaclient/chains/bitcoin/signer/signer.go @@ -5,48 +5,35 @@ import ( "bytes" "context" "encoding/hex" - "fmt" - "math/big" "time" - "github.com/btcsuite/btcd/btcec/v2" - btcecdsa "github.com/btcsuite/btcd/btcec/v2/ecdsa" "github.com/btcsuite/btcd/btcjson" "github.com/btcsuite/btcd/btcutil" "github.com/btcsuite/btcd/chaincfg/chainhash" - "github.com/btcsuite/btcd/txscript" "github.com/btcsuite/btcd/wire" + "github.com/cenkalti/backoff/v4" "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/pkg/retry" "github.com/zeta-chain/node/x/crosschain/types" - observertypes "github.com/zeta-chain/node/x/observer/types" "github.com/zeta-chain/node/zetaclient/chains/base" - "github.com/zeta-chain/node/zetaclient/chains/bitcoin/common" "github.com/zeta-chain/node/zetaclient/chains/bitcoin/observer" "github.com/zeta-chain/node/zetaclient/chains/interfaces" - "github.com/zeta-chain/node/zetaclient/compliance" "github.com/zeta-chain/node/zetaclient/logs" ) 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 + // broadcastBackoff is the backoff duration for retrying broadcast + broadcastBackoff = time.Second * 6 // broadcastRetries is the maximum number of retries for broadcasting a transaction - broadcastRetries = 5 + broadcastRetries = 10 ) type RPC interface { GetNetworkInfo(ctx context.Context) (*btcjson.GetNetworkInfoResult, error) + GetRawTransaction(ctx context.Context, hash *chainhash.Hash) (*btcutil.Tx, error) + GetEstimatedFeeRate(ctx context.Context, confTarget int64) (int64, error) SendRawTransaction(ctx context.Context, tx *wire.MsgTx, allowHighFees bool) (*chainhash.Hash, error) } @@ -61,196 +48,6 @@ func New(baseSigner *base.Signer, rpc RPC) *Signer { return &Signer{Signer: baseSigner, rpc: rpc} } -// AddWithdrawTxOutputs adds the 3 outputs to the withdraw tx -// 1st output: the nonce-mark btc to TSS itself -// 2nd output: the payment to the recipient -// 3rd output: the remaining btc to TSS itself -func (signer *Signer) AddWithdrawTxOutputs( - tx *wire.MsgTx, - to btcutil.Address, - total float64, - amount float64, - nonceMark int64, - fees *big.Int, - cancelTx bool, -) error { - // convert withdraw amount to satoshis - amountSatoshis, err := common.GetSatoshis(amount) - if err != nil { - return err - } - - // calculate remaining btc (the change) to TSS self - remaining := total - amount - remainingSats, err := common.GetSatoshis(remaining) - if err != nil { - return err - } - remainingSats -= fees.Int64() - remainingSats -= nonceMark - if remainingSats < 0 { - return fmt.Errorf("remainder value is negative: %d", remainingSats) - } else if remainingSats == nonceMark { - signer.Logger().Std.Info().Msgf("adjust remainder value to avoid duplicate nonce-mark: %d", remainingSats) - remainingSats-- - } - - // 1st output: the nonce-mark btc to TSS self - tssAddrP2WPKH, err := signer.TSS().PubKey().AddressBTC(signer.Chain().ChainId) - if err != nil { - return err - } - payToSelfScript, err := txscript.PayToAddrScript(tssAddrP2WPKH) - if err != nil { - return err - } - txOut1 := wire.NewTxOut(nonceMark, payToSelfScript) - tx.AddTxOut(txOut1) - - // 2nd output: the payment to the recipient - if !cancelTx { - pkScript, err := txscript.PayToAddrScript(to) - if err != nil { - return err - } - txOut2 := wire.NewTxOut(amountSatoshis, pkScript) - tx.AddTxOut(txOut2) - } else { - // send the amount to TSS self if tx is cancelled - remainingSats += amountSatoshis - } - - // 3rd output: the remaining btc to TSS self - if remainingSats > 0 { - txOut3 := wire.NewTxOut(remainingSats, payToSelfScript) - tx.AddTxOut(txOut3) - } - return nil -} - -// SignWithdrawTx receives utxos sorted by value, amount in BTC, feeRate in BTC per Kb -// TODO(revamp): simplify the function -func (signer *Signer) SignWithdrawTx( - ctx context.Context, - to btcutil.Address, - amount float64, - gasPrice *big.Int, - sizeLimit uint64, - observer *observer.Observer, - height uint64, - nonce uint64, - chain chains.Chain, - cancelTx bool, -) (*wire.MsgTx, error) { - estimateFee := float64(gasPrice.Uint64()*common.OutboundBytesMax) / 1e8 - nonceMark := chains.NonceMarkAmount(nonce) - - // refresh unspent UTXOs and continue with keysign regardless of error - if err := observer.FetchUTXOs(ctx); err != nil { - signer.Logger().Std.Error().Err(err).Uint64("nonce", nonce).Msg("SignWithdrawTx: FetchUTXOs failed") - } - - // select N UTXOs to cover the total expense - prevOuts, total, consolidatedUtxo, consolidatedValue, err := observer.SelectUTXOs( - ctx, - amount+estimateFee+float64(nonceMark)*1e-8, - MaxNoOfInputsPerTx, - nonce, - consolidationRank, - false, - ) - if err != nil { - return nil, errors.Wrap(err, "unable to select UTXOs") - } - - // build tx with selected unspents - tx := wire.NewMsgTx(wire.TxVersion) - for _, prevOut := range prevOuts { - hash, err := chainhash.NewHashFromStr(prevOut.TxID) - if err != nil { - return nil, errors.Wrap(err, "unable to construct hash") - } - - outpoint := wire.NewOutPoint(hash, prevOut.Vout) - txIn := wire.NewTxIn(outpoint, nil, nil) - tx.AddTxIn(txIn) - } - - // size checking - // #nosec G115 always positive - txSize, err := common.EstimateOutboundSize(uint64(len(prevOuts)), []btcutil.Address{to}) - if err != nil { - return nil, errors.Wrap(err, "unable to estimate tx size") - } - if sizeLimit < common.BtcOutboundBytesWithdrawer { // ZRC20 'withdraw' charged less fee from end user - signer.Logger().Std.Info(). - Msgf("sizeLimit %d is less than BtcOutboundBytesWithdrawer %d for nonce %d", sizeLimit, txSize, nonce) - } - if txSize < common.OutboundBytesMin { // outbound shouldn't be blocked a low sizeLimit - signer.Logger().Std.Warn(). - Msgf("txSize %d is less than outboundBytesMin %d; use outboundBytesMin", txSize, common.OutboundBytesMin) - txSize = common.OutboundBytesMin - } - if txSize > common.OutboundBytesMax { // in case of accident - signer.Logger().Std.Warn(). - Msgf("txSize %d is greater than outboundBytesMax %d; use outboundBytesMax", txSize, common.OutboundBytesMax) - txSize = common.OutboundBytesMax - } - - // fee calculation - // #nosec G115 always in range (checked above) - fees := new(big.Int).Mul(big.NewInt(int64(txSize)), gasPrice) - signer.Logger(). - Std.Info(). - Msgf("bitcoin outbound nonce %d gasPrice %s size %d fees %s consolidated %d utxos of value %v", - nonce, gasPrice.String(), txSize, fees.String(), consolidatedUtxo, consolidatedValue) - - // add tx outputs - err = signer.AddWithdrawTxOutputs(tx, to, total, amount, nonceMark, fees, cancelTx) - if err != nil { - return nil, errors.Wrap(err, "unable to add withdrawal tx outputs") - } - - // sign the tx - sigHashes := txscript.NewTxSigHashes(tx, txscript.NewCannedPrevOutputFetcher([]byte{}, 0)) - witnessHashes := make([][]byte, len(tx.TxIn)) - for ix := range tx.TxIn { - amt, err := common.GetSatoshis(prevOuts[ix].Amount) - if err != nil { - return nil, err - } - pkScript, err := hex.DecodeString(prevOuts[ix].ScriptPubKey) - if err != nil { - return nil, err - } - witnessHashes[ix], err = txscript.CalcWitnessSigHash(pkScript, sigHashes, txscript.SigHashAll, tx, ix, amt) - if err != nil { - return nil, err - } - } - - sig65Bs, err := signer.TSS().SignBatch(ctx, witnessHashes, height, nonce, chain.ChainId) - if err != nil { - return nil, errors.Wrap(err, "unable to batch sign") - } - - for ix := range tx.TxIn { - sig65B := sig65Bs[ix] - R := &btcec.ModNScalar{} - R.SetBytes((*[32]byte)(sig65B[:32])) - S := &btcec.ModNScalar{} - S.SetBytes((*[32]byte)(sig65B[32:64])) - sig := btcecdsa.NewSignature(R, S) - - pkCompressed := signer.TSS().PubKey().Bytes(true) - hashType := txscript.SigHashAll - txWitness := wire.TxWitness{append(sig.Serialize(), byte(hashType)), pkCompressed} - tx.TxIn[ix].Witness = txWitness - } - - return tx, nil -} - // Broadcast sends the signed transaction to the network func (signer *Signer) Broadcast(ctx context.Context, signedTx *wire.MsgTx) error { var outBuff bytes.Buffer @@ -259,7 +56,7 @@ func (signer *Signer) Broadcast(ctx context.Context, signedTx *wire.MsgTx) error } signer.Logger().Std.Info(). - Stringer("signer.tx_hash", signedTx.TxHash()). + Str(logs.FieldTx, signedTx.TxHash().String()). Str("signer.tx_payload", hex.EncodeToString(outBuff.Bytes())). Msg("Broadcasting transaction") @@ -272,7 +69,6 @@ func (signer *Signer) Broadcast(ctx context.Context, 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, @@ -293,135 +89,97 @@ func (signer *Signer) TryProcessOutbound( // prepare logger params := cctx.GetCurrentOutboundParam() - // prepare logger fields lf := map[string]any{ logs.FieldMethod: "TryProcessOutbound", logs.FieldCctx: cctx.Index, logs.FieldNonce: params.TssNonce, } - logger := signer.Logger().Std.With().Fields(lf).Logger() - - // support gas token only for Bitcoin outbound - coinType := cctx.InboundParams.CoinType - if coinType == coin.CoinType_Zeta || coinType == coin.CoinType_ERC20 { - logger.Error().Msg("can only send BTC to a BTC network") - return - } - - chain := observer.Chain() - outboundTssNonce := params.TssNonce signerAddress, err := zetacoreClient.GetKeys().GetAddress() if err != nil { - logger.Error().Err(err).Msg("cannot get signer address") return } lf["signer"] = signerAddress.String() + logger := signer.Logger().Std.With().Fields(lf).Logger() - // 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 - } - - // Check receiver P2WPKH address - to, err := chains.DecodeBtcAddress(params.Receiver, params.ReceiverChainId) + // query network info to get minRelayFee (typically 1000 satoshis) + networkInfo, err := signer.rpc.GetNetworkInfo(ctx) if err != nil { - logger.Error().Err(err).Msgf("cannot decode address %s ", params.Receiver) + logger.Error().Err(err).Msg("failed get bitcoin network info") return } - if !chains.IsBtcAddressSupported(to) { - logger.Error().Msgf("unsupported address %s", params.Receiver) + minRelayFee := networkInfo.RelayFee + if minRelayFee <= 0 { + logger.Error().Msgf("invalid minimum relay fee: %f", minRelayFee) return } - amount := float64(params.Amount.Uint64()) / 1e8 - // Add 1 satoshi/byte to gasPrice to avoid minRelayTxFee issue - networkInfo, err := signer.rpc.GetNetworkInfo(ctx) + // setup outbound data + txData, err := NewOutboundData(cctx, 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 := common.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") + // sign withdraw tx + signedTx, err := signer.SignWithdrawTx(ctx, txData, observer) + if err != nil { + logger.Error().Err(err).Msg("SignWithdrawTx failed") + return } + logger.Info().Str(logs.FieldTx, signedTx.TxID()).Msg("SignWithdrawTx succeed") + + // broadcast signed outbound + signer.BroadcastOutbound(ctx, signedTx, params.TssNonce, cctx, observer, zetacoreClient) +} - // check dust amount - dustAmount := params.Amount.Uint64() < constant.BTCWithdrawalDustAmount - if dustAmount { - logger.Warn().Msgf("dust amount %d sats, canceling tx", params.Amount.Uint64()) +// 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 + logger := signer.Logger().Std.With(). + Str(logs.FieldMethod, "BroadcastOutbound"). + Uint64(logs.FieldNonce, nonce). + Str(logs.FieldTx, txHash). + Str(logs.FieldCctx, cctx.Index). + Logger() + + // try broacasting tx with backoff in case of RPC error + broadcast := func() error { + return retry.Retry(signer.Broadcast(ctx, tx)) } - // set the amount to 0 when the tx should be cancelled - cancelTx := restrictedCCTX || dustAmount - if cancelTx { - amount = 0.0 + bo := backoff.NewConstantBackOff(broadcastBackoff) + boWithMaxRetries := backoff.WithMaxRetries(bo, broadcastRetries) + if err := retry.DoWithBackoff(broadcast, boWithMaxRetries); err != nil { + logger.Error().Err(err).Msgf("unable to broadcast Bitcoin outbound") } + logger.Info().Msg("broadcasted Bitcoin outbound successfully") - // sign withdraw tx - tx, err := signer.SignWithdrawTx( - ctx, - to, - amount, - gasprice, - sizelimit, - observer, - height, - outboundTssNonce, - chain, - cancelTx, - ) - if err != nil { - logger.Warn().Err(err).Msg("SignWithdrawTx failed") - return + // save tx local db and ignore db error. + // db error is not critical and should not block outbound tracker. + if err := ob.SaveBroadcastedTx(txHash, nonce); err != nil { + logger.Error().Err(err).Msg("unable to save broadcasted Bitcoin outbound") } - logger.Info().Msg("Key-sign success") - // FIXME: add prometheus metrics - _, err = zetacoreClient.GetObserverList(ctx) + // 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.Warn(). - Err(err).Stringer("observation_type", observertypes.ObservationType_OutboundTx). - Msg("unable to get observer list, observation") + logger.Err(err).Msg("unable to add Bitcoin outbound tracker") + } else { + logger.Info().Str(logs.FieldZetaTx, zetaHash).Msg("add Bitcoin outbound tracker successfully") } - 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(ctx, 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") - // Save successfully broadcasted transaction to btc chain observer - observer.SaveBroadcastedTx(outboundHash, outboundTssNonce) - - break // successful broadcast; no need to retry - } + // try including this outbound as early as possible, no need to wait for outbound tracker + _, included := ob.TryIncludeOutbound(ctx, cctx, txHash) + if included { + logger.Info().Msg("included newly broadcasted Bitcoin outbound") } } diff --git a/zetaclient/chains/bitcoin/signer/signer_test.go b/zetaclient/chains/bitcoin/signer/signer_test.go index b62f9913e6..a6c65e6932 100644 --- a/zetaclient/chains/bitcoin/signer/signer_test.go +++ b/zetaclient/chains/bitcoin/signer/signer_test.go @@ -1,71 +1,168 @@ -package signer +package signer_test import ( + "context" "encoding/hex" - "fmt" - "math/big" - "reflect" "testing" "github.com/btcsuite/btcd/btcec/v2" - btcecdsa "github.com/btcsuite/btcd/btcec/v2/ecdsa" + "github.com/btcsuite/btcd/btcec/v2/ecdsa" + "github.com/btcsuite/btcd/btcjson" "github.com/btcsuite/btcd/btcutil" "github.com/btcsuite/btcd/chaincfg" "github.com/btcsuite/btcd/chaincfg/chainhash" "github.com/btcsuite/btcd/txscript" "github.com/btcsuite/btcd/wire" - "github.com/ethereum/go-ethereum/crypto" + "github.com/rs/zerolog" + "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" - "github.com/zeta-chain/node/zetaclient/testutils" - . "gopkg.in/check.v1" "github.com/zeta-chain/node/pkg/chains" + "github.com/zeta-chain/node/testutil/sample" + observertypes "github.com/zeta-chain/node/x/observer/types" "github.com/zeta-chain/node/zetaclient/chains/base" + "github.com/zeta-chain/node/zetaclient/chains/bitcoin/observer" + "github.com/zeta-chain/node/zetaclient/chains/bitcoin/signer" + "github.com/zeta-chain/node/zetaclient/config" + zctx "github.com/zeta-chain/node/zetaclient/context" + "github.com/zeta-chain/node/zetaclient/db" + "github.com/zeta-chain/node/zetaclient/keys" + "github.com/zeta-chain/node/zetaclient/metrics" + "github.com/zeta-chain/node/zetaclient/testutils" "github.com/zeta-chain/node/zetaclient/testutils/mocks" + "github.com/zeta-chain/node/zetaclient/testutils/testlog" ) -type BTCSignerSuite struct { - btcSigner *Signer -} +// the relative path to the testdata directory +var TestDataDir = "../../../" -var _ = Suite(&BTCSignerSuite{}) +type testSuite struct { + *signer.Signer + observer *observer.Observer + tss *mocks.TSS + client *mocks.BitcoinClient + zetacoreClient *mocks.ZetacoreClient +} -type cWrapper struct{ *C } +func newTestSuite(t *testing.T, chain chains.Chain) *testSuite { + // mock BTC RPC client + rpcClient := mocks.NewBitcoinClient(t) + rpcClient.On("GetBlockCount", mock.Anything).Maybe().Return(int64(101), nil) + + // mock TSS + var tss *mocks.TSS + if chains.IsBitcoinMainnet(chain.ChainId) { + tss = mocks.NewTSS(t).FakePubKey(testutils.TSSPubKeyMainnet) + } else { + tss = mocks.NewTSS(t).FakePubKey(testutils.TSSPubkeyAthens3) + } -func (cWrapper) Cleanup(func()) { /* noop */ } + // mock Zetacore client + zetacoreClient := mocks.NewZetacoreClient(t). + WithKeys(&keys.Keys{}). + WithZetaChain() + + // create logger + logger := testlog.New(t) + baseLogger := base.Logger{Std: logger.Logger, Compliance: logger.Logger} + + // create signer + baseSigner := base.NewSigner(chain, tss, baseLogger) + signer := signer.New(baseSigner, rpcClient) + + // create test suite and observer + suite := &testSuite{ + Signer: signer, + tss: tss, + client: rpcClient, + zetacoreClient: zetacoreClient, + } + suite.createObserver(t) -func (s *BTCSignerSuite) SetUpTest(c *C) { - // test private key with EVM address - //// EVM: 0x236C7f53a90493Bb423411fe4117Cb4c2De71DfB - // BTC testnet3: muGe9prUBjQwEnX19zG26fVRHNi8z7kSPo - skHex := "7b8507ba117e069f4a3f456f505276084f8c92aee86ac78ae37b4d1801d35fa8" - privateKey, err := crypto.HexToECDSA(skHex) - pkBytes := crypto.FromECDSAPub(&privateKey.PublicKey) - c.Logf("pubkey: %d", len(pkBytes)) - // Uncomment the following code to generate new random private key pairs - //privateKey, err := crypto.GenerateKey() - //privkeyBytes := crypto.FromECDSA(privateKey) - //c.Logf("privatekey %s", hex.EncodeToString(privkeyBytes)) - c.Assert(err, IsNil) + return suite +} - tss := mocks.NewTSSFromPrivateKey(c, privateKey) +func Test_BroadcastOutbound(t *testing.T) { + // test cases + tests := []struct { + name string + chain chains.Chain + nonce uint64 + failTracker bool + }{ + { + name: "should successfully broadcast and include outbound", + chain: chains.BitcoinMainnet, + nonce: uint64(148), + }, + { + name: "should successfully broadcast and include outbound, but fail to post outbound tracker", + chain: chains.BitcoinMainnet, + nonce: uint64(148), + failTracker: true, + }, + } - baseSigner := base.NewSigner(chains.Chain{}, tss, base.DefaultLogger()) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // setup signer and observer + s := newTestSuite(t, tt.chain) + + // load tx and result + chainID := tt.chain.ChainId + rawResult, cctx := testutils.LoadBTCTxRawResultNCctx(t, TestDataDir, chainID, tt.nonce) + txResult := testutils.LoadBTCTransaction(t, TestDataDir, chainID, rawResult.Txid) + msgTx := testutils.LoadBTCMsgTx(t, TestDataDir, chainID, rawResult.Txid) + hash := hashFromTXID(t, rawResult.Txid) + + // mock RPC response + s.client.On("SendRawTransaction", mock.Anything, mock.Anything, mock.Anything).Maybe().Return(nil, nil) + s.client.On("GetTransactionByStr", mock.Anything, mock.Anything).Maybe().Return(hash, txResult, nil) + s.client.On("GetRawTransactionResult", mock.Anything, mock.Anything, mock.Anything). + Maybe(). + Return(*rawResult, nil) + + // mock Zetacore client response + if tt.failTracker { + s.zetacoreClient.WithPostOutboundTracker("") + } else { + s.zetacoreClient.WithPostOutboundTracker("0x123") + } - s.btcSigner = New(baseSigner, mocks.NewBitcoinClient(cWrapper{c})) + // mock the previous tx as included + // this is necessary to allow the 'checkTSSVin' function to pass + s.observer.SetIncludedTx(tt.nonce-1, &btcjson.GetTransactionResult{ + TxID: rawResult.Vin[0].Txid, + }) + + ctx := makeCtx(t) + s.BroadcastOutbound( + ctx, + msgTx, + tt.nonce, + cctx, + s.observer, + s.zetacoreClient, + ) + + // check if outbound is included + gotResult := s.observer.GetIncludedTx(tt.nonce) + require.Equal(t, txResult, gotResult) + }) + } } -func (s *BTCSignerSuite) TestP2PH(c *C) { +func Test_P2PH(t *testing.T) { // Ordinarily the private key would come from whatever storage mechanism // is being used, but for this example just hard code it. privKeyBytes, err := hex.DecodeString("22a47fa09a223f2aa079edf85a7c2" + "d4f8720ee63e502ee2869afab7de234b80c") - c.Assert(err, IsNil) + require.NoError(t, err) privKey, pubKey := btcec.PrivKeyFromBytes(privKeyBytes) pubKeyHash := btcutil.Hash160(pubKey.SerializeCompressed()) addr, err := btcutil.NewAddressPubKeyHash(pubKeyHash, &chaincfg.RegressionNetParams) - c.Assert(err, IsNil) + require.NoError(t, err) // For this example, create a fake transaction that represents what // would ordinarily be the real transaction that is being spent. It @@ -75,8 +172,7 @@ func (s *BTCSignerSuite) TestP2PH(c *C) { txIn := wire.NewTxIn(prevOut, []byte{txscript.OP_0, txscript.OP_0}, nil) originTx.AddTxIn(txIn) pkScript, err := txscript.PayToAddrScript(addr) - - c.Assert(err, IsNil) + require.NoError(t, err) txOut := wire.NewTxOut(100000000, pkScript) originTx.AddTxOut(txOut) @@ -107,7 +203,7 @@ func (s *BTCSignerSuite) TestP2PH(c *C) { sigScript, err := txscript.SignTxOutput(&chaincfg.MainNetParams, redeemTx, 0, originTx.TxOut[0].PkScript, txscript.SigHashAll, txscript.KeyClosure(lookupKey), nil, nil) - c.Assert(err, IsNil) + require.NoError(t, err) redeemTx.TxIn[0].SignatureScript = sigScript @@ -118,26 +214,24 @@ func (s *BTCSignerSuite) TestP2PH(c *C) { txscript.ScriptDiscourageUpgradableNops vm, err := txscript.NewEngine(originTx.TxOut[0].PkScript, redeemTx, 0, flags, nil, nil, -1, txscript.NewMultiPrevOutFetcher(nil)) - c.Assert(err, IsNil) + require.NoError(t, err) err = vm.Execute() - c.Assert(err, IsNil) - - fmt.Println("Transaction successfully signed") + require.NoError(t, err) } -func (s *BTCSignerSuite) TestP2WPH(c *C) { +func Test_P2WPH(t *testing.T) { // Ordinarily the private key would come from whatever storage mechanism // is being used, but for this example just hard code it. privKeyBytes, err := hex.DecodeString("22a47fa09a223f2aa079edf85a7c2" + "d4f8720ee63e502ee2869afab7de234b80c") - c.Assert(err, IsNil) + require.NoError(t, err) privKey, pubKey := btcec.PrivKeyFromBytes(privKeyBytes) pubKeyHash := btcutil.Hash160(pubKey.SerializeCompressed()) //addr, err := btcutil.NewAddressPubKeyHash(pubKeyHash, &chaincfg.RegressionNetParams) addr, err := btcutil.NewAddressWitnessPubKeyHash(pubKeyHash, &chaincfg.RegressionNetParams) - c.Assert(err, IsNil) + require.NoError(t, err) // For this example, create a fake transaction that represents what // would ordinarily be the real transaction that is being spent. It @@ -147,7 +241,7 @@ func (s *BTCSignerSuite) TestP2WPH(c *C) { txIn := wire.NewTxIn(prevOut, []byte{txscript.OP_0, txscript.OP_0}, nil) originTx.AddTxIn(txIn) pkScript, err := txscript.PayToAddrScript(addr) - c.Assert(err, IsNil) + require.NoError(t, err) txOut := wire.NewTxOut(100000000, pkScript) originTx.AddTxOut(txOut) originTxHash := originTx.TxHash() @@ -168,7 +262,7 @@ func (s *BTCSignerSuite) TestP2WPH(c *C) { redeemTx.AddTxOut(txOut) txSigHashes := txscript.NewTxSigHashes(redeemTx, txscript.NewCannedPrevOutputFetcher([]byte{}, 0)) pkScript, err = txscript.PayToAddrScript(addr) - c.Assert(err, IsNil) + require.NoError(t, err) { txWitness, err := txscript.WitnessSignature( @@ -181,7 +275,7 @@ func (s *BTCSignerSuite) TestP2WPH(c *C) { privKey, true, ) - c.Assert(err, IsNil) + require.NoError(t, err) redeemTx.TxIn[0].Witness = txWitness // Prove that the transaction has been validly signed by executing the // script pair. @@ -190,10 +284,10 @@ func (s *BTCSignerSuite) TestP2WPH(c *C) { txscript.ScriptDiscourageUpgradableNops vm, err := txscript.NewEngine(originTx.TxOut[0].PkScript, redeemTx, 0, flags, nil, nil, -1, txscript.NewCannedPrevOutputFetcher([]byte{}, 0)) - c.Assert(err, IsNil) + require.NoError(t, err) err = vm.Execute() - c.Assert(err, IsNil) + require.NoError(t, err) } { @@ -205,8 +299,8 @@ func (s *BTCSignerSuite) TestP2WPH(c *C) { 0, 100000000, ) - c.Assert(err, IsNil) - sig := btcecdsa.Sign(privKey, witnessHash) + require.NoError(t, err) + sig := ecdsa.Sign(privKey, witnessHash) txWitness := wire.TxWitness{append(sig.Serialize(), byte(txscript.SigHashAll)), pubKeyHash} redeemTx.TxIn[0].Witness = txWitness @@ -215,164 +309,57 @@ func (s *BTCSignerSuite) TestP2WPH(c *C) { txscript.ScriptDiscourageUpgradableNops vm, err := txscript.NewEngine(originTx.TxOut[0].PkScript, redeemTx, 0, flags, nil, nil, -1, txscript.NewMultiPrevOutFetcher(nil)) - c.Assert(err, IsNil) + require.NoError(t, err) err = vm.Execute() - c.Assert(err, IsNil) + require.NoError(t, err) } - - fmt.Println("Transaction successfully signed") } -func TestAddWithdrawTxOutputs(t *testing.T) { - // Create test signer and receiver address - baseSigner := base.NewSigner( - chains.BitcoinMainnet, - mocks.NewTSS(t).FakePubKey(testutils.TSSPubKeyMainnet), - base.DefaultLogger(), +func makeCtx(t *testing.T) context.Context { + app := zctx.New(config.New(false), nil, zerolog.Nop()) + + chain := chains.BitcoinMainnet + btcParams := mocks.MockChainParams(chain.ChainId, 2) + + err := app.Update( + []chains.Chain{chain, chains.ZetaChainMainnet}, + nil, + map[int64]*observertypes.ChainParams{ + chain.ChainId: &btcParams, + }, + *sample.CrosschainFlags(), + sample.OperationalFlags(), ) + require.NoError(t, err, "unable to update app context") - signer := New(baseSigner, mocks.NewBitcoinClient(t)) + return zctx.WithAppContext(context.Background(), app) +} - // tss address and script - tssAddr, err := signer.TSS().PubKey().AddressBTC(chains.BitcoinMainnet.ChainId) - require.NoError(t, err) - tssScript, err := txscript.PayToAddrScript(tssAddr) - require.NoError(t, err) - fmt.Printf("tss address: %s", tssAddr.EncodeAddress()) +// createObserver creates a new BTC chain observer for test suite +func (s *testSuite) createObserver(t *testing.T) { + // prepare mock arguments to create observer + params := mocks.MockChainParams(s.Chain().ChainId, 2) + ts := &metrics.TelemetryServer{} - // receiver addresses - receiver := "bc1qaxf82vyzy8y80v000e7t64gpten7gawewzu42y" - to, err := chains.DecodeBtcAddress(receiver, chains.BitcoinMainnet.ChainId) + // create in-memory db + database, err := db.NewFromSqliteInMemory(true) require.NoError(t, err) - toScript, err := txscript.PayToAddrScript(to) + + // create logger + logger := testlog.New(t) + baseLogger := base.Logger{Std: logger.Logger, Compliance: logger.Logger} + + // create observer + baseObserver, err := base.NewObserver(s.Chain(), params, s.zetacoreClient, s.tss, 100, ts, database, baseLogger) require.NoError(t, err) - // test cases - tests := []struct { - name string - tx *wire.MsgTx - to btcutil.Address - total float64 - amount float64 - nonce int64 - fees *big.Int - cancelTx bool - fail bool - message string - txout []*wire.TxOut - }{ - { - name: "should add outputs successfully", - tx: wire.NewMsgTx(wire.TxVersion), - to: to, - total: 1.00012000, - amount: 0.2, - nonce: 10000, - fees: big.NewInt(2000), - fail: false, - txout: []*wire.TxOut{ - {Value: 10000, PkScript: tssScript}, - {Value: 20000000, PkScript: toScript}, - {Value: 80000000, PkScript: tssScript}, - }, - }, - { - name: "should add outputs without change successfully", - tx: wire.NewMsgTx(wire.TxVersion), - to: to, - total: 0.20012000, - amount: 0.2, - nonce: 10000, - fees: big.NewInt(2000), - fail: false, - txout: []*wire.TxOut{ - {Value: 10000, PkScript: tssScript}, - {Value: 20000000, PkScript: toScript}, - }, - }, - { - name: "should cancel tx successfully", - tx: wire.NewMsgTx(wire.TxVersion), - to: to, - total: 1.00012000, - amount: 0.2, - nonce: 10000, - fees: big.NewInt(2000), - cancelTx: true, - fail: false, - txout: []*wire.TxOut{ - {Value: 10000, PkScript: tssScript}, - {Value: 100000000, PkScript: tssScript}, - }, - }, - { - name: "should fail on invalid amount", - tx: wire.NewMsgTx(wire.TxVersion), - to: to, - total: 1.00012000, - amount: -0.5, - fail: true, - }, - { - name: "should fail when total < amount", - tx: wire.NewMsgTx(wire.TxVersion), - to: to, - total: 0.00012000, - amount: 0.2, - fail: true, - }, - { - name: "should fail when total < fees + amount + nonce", - tx: wire.NewMsgTx(wire.TxVersion), - to: to, - total: 0.20011000, - amount: 0.2, - nonce: 10000, - fees: big.NewInt(2000), - fail: true, - message: "remainder value is negative", - }, - { - name: "should not produce duplicate nonce mark", - tx: wire.NewMsgTx(wire.TxVersion), - to: to, - total: 0.20022000, // 0.2 + fee + nonceMark * 2 - amount: 0.2, - nonce: 10000, - fees: big.NewInt(2000), - fail: false, - txout: []*wire.TxOut{ - {Value: 10000, PkScript: tssScript}, - {Value: 20000000, PkScript: toScript}, - {Value: 9999, PkScript: tssScript}, // nonceMark - 1 - }, - }, - { - name: "should fail on invalid to address", - tx: wire.NewMsgTx(wire.TxVersion), - to: nil, - total: 1.00012000, - amount: 0.2, - nonce: 10000, - fees: big.NewInt(2000), - fail: true, - }, - } + s.observer, err = observer.New(s.Chain(), baseObserver, s.client) + require.NoError(t, err) +} - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - err := signer.AddWithdrawTxOutputs(tt.tx, tt.to, tt.total, tt.amount, tt.nonce, tt.fees, tt.cancelTx) - if tt.fail { - require.Error(t, err) - if tt.message != "" { - require.ErrorContains(t, err, tt.message) - } - return - } else { - require.NoError(t, err) - require.True(t, reflect.DeepEqual(tt.txout, tt.tx.TxOut)) - } - }) - } +func hashFromTXID(t *testing.T, txid string) *chainhash.Hash { + h, err := chainhash.NewHashFromStr(txid) + require.NoError(t, err) + return h } diff --git a/zetaclient/chains/evm/observer/observer.go b/zetaclient/chains/evm/observer/observer.go index 18e0c1179f..b804d36fa5 100644 --- a/zetaclient/chains/evm/observer/observer.go +++ b/zetaclient/chains/evm/observer/observer.go @@ -254,7 +254,7 @@ func (ob *Observer) LoadLastBlockScanned(ctx context.Context) error { } ob.WithLastBlockScanned(blockNumber) } - ob.Logger().Chain.Info().Msgf("chain %d starts scanning from block %d", ob.Chain().ChainId, ob.LastBlockScanned()) + ob.Logger().Chain.Info().Uint64("last_block_scanned", ob.LastBlockScanned()).Msg("LoadLastBlockScanned succeed") return nil } diff --git a/zetaclient/chains/evm/signer/signer.go b/zetaclient/chains/evm/signer/signer.go index 011c1b302c..46453a3298 100644 --- a/zetaclient/chains/evm/signer/signer.go +++ b/zetaclient/chains/evm/signer/signer.go @@ -6,7 +6,6 @@ import ( "encoding/hex" "fmt" "math/big" - "strconv" "strings" "time" @@ -491,8 +490,8 @@ func (signer *Signer) BroadcastOutbound( outboundHash, toChain.ID(), cctx.GetCurrentOutboundParam().TssNonce, i, myID) retry, report := zetacore.HandleBroadcastError( err, - strconv.FormatUint(cctx.GetCurrentOutboundParam().TssNonce, 10), - fmt.Sprintf("%d", toChain.ID()), + cctx.GetCurrentOutboundParam().TssNonce, + toChain.ID(), outboundHash, ) if report { diff --git a/zetaclient/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/metrics/telemetry.go b/zetaclient/metrics/telemetry.go index d225e1ccf5..5a4bf4c27f 100644 --- a/zetaclient/metrics/telemetry.go +++ b/zetaclient/metrics/telemetry.go @@ -152,6 +152,13 @@ func (t *TelemetryServer) SetNumberOfUTXOs(numberOfUTXOs int) { t.mu.Unlock() } +// GetNumberOfUTXOs returns number of UTXOs +func (t *TelemetryServer) GetNumberOfUTXOs() int { + t.mu.Lock() + defer t.mu.Unlock() + return t.status.BTCNumberOfUTXOs +} + // AddFeeEntry adds fee entry func (t *TelemetryServer) AddFeeEntry(block int64, amount int64) { t.mu.Lock() diff --git a/zetaclient/testdata/btc/chain_8332_msgtx_030cd813443f7b70cc6d8a544d320c6d8465e4528fc0f3410b599dc0b26753a0.json b/zetaclient/testdata/btc/chain_8332_msgtx_030cd813443f7b70cc6d8a544d320c6d8465e4528fc0f3410b599dc0b26753a0.json new file mode 100644 index 0000000000..a96ef05a83 --- /dev/null +++ b/zetaclient/testdata/btc/chain_8332_msgtx_030cd813443f7b70cc6d8a544d320c6d8465e4528fc0f3410b599dc0b26753a0.json @@ -0,0 +1,95 @@ +{ + "Version": 1, + "TxIn": [ + { + "PreviousOutPoint": { + "Hash": "efca302a18bd8cebb3b8afef13e98ecaac47157755a62ab241ef3848140cfe92", + "Index": 0 + }, + "SignatureScript": "", + "Witness": [ + "MEUCIQD+vXB5QFfmG4PiqsCTAtiZEOO3mgMbCEtPFIxVKaGJxgIgfGCjg07rfdmJwQjHNbwX4NU853oBbowIkNvB5dxXO2wB", + "AvrTNIt8fXPoWnz+U69CtTW3UEGe/FXDbIB/ON0LoG7c" + ], + "Sequence": 4294967295 + }, + { + "PreviousOutPoint": { + "Hash": "3dc005eb0c1d393e717070ea84aa13e334a458a4fb7c7f9f98dbf8b231b5ceef", + "Index": 0 + }, + "SignatureScript": "", + "Witness": [ + "MEUCIQDip9szSAtGI8GRvjSFJfSNLGx/2MepdquH1Vaj2fG/DAIgYMUfOFQvE8MywRSqqiCTcoNDqVUGkw1cgQvd3koxIVMB", + "AvrTNIt8fXPoWnz+U69CtTW3UEGe/FXDbIB/ON0LoG7c" + ], + "Sequence": 4294967295 + }, + { + "PreviousOutPoint": { + "Hash": "74c3aca825f3b21b82ee344d939c40d4c1e836a89c18abbd521bfa69f5f6e5d7", + "Index": 0 + }, + "SignatureScript": "", + "Witness": [ + "MEQCIDzr8YVsCvLFwtjs5DVBjpmecAUH6mR7tc8QmUmzN9VzAiBnU/AbfIG3MQRrGK/3WJ6EcVJK7+Y0mjRocLwJyh3o1wE=", + "AvrTNIt8fXPoWnz+U69CtTW3UEGe/FXDbIB/ON0LoG7c" + ], + "Sequence": 4294967295 + }, + { + "PreviousOutPoint": { + "Hash": "87264cef0e581f4aab3c99c53221bec3219686b48088d651a8cf8a98e4c2c5bf", + "Index": 0 + }, + "SignatureScript": "", + "Witness": [ + "MEQCIFM1gzPXKK/6SpXiP2ZZn2bJQB5PgCu7E/AUrPrighdoAiB5PFg1YmenwAUoiafag9N+sBMGJ3SWs+BE5KW0q9xEYQE=", + "AvrTNIt8fXPoWnz+U69CtTW3UEGe/FXDbIB/ON0LoG7c" + ], + "Sequence": 4294967295 + }, + { + "PreviousOutPoint": { + "Hash": "5af24933973df03d96624ae1341d79a860e8dbc2ffc841420aa6710f3abc0074", + "Index": 0 + }, + "SignatureScript": "", + "Witness": [ + "MEQCICFVKukAkYOXm1NN7bJR1VWqyqaPFAwBbr+x5nh6NcXgAiAwnfdr1RESQ1LDlV+S0NscurZQT+VkmwWFsMdABANXCwE=", + "AvrTNIt8fXPoWnz+U69CtTW3UEGe/FXDbIB/ON0LoG7c" + ], + "Sequence": 4294967295 + }, + { + "PreviousOutPoint": { + "Hash": "efca302a18bd8cebb3b8afef13e98ecaac47157755a62ab241ef3848140cfe92", + "Index": 2 + }, + "SignatureScript": "", + "Witness": [ + "MEUCIQD6vL28zA0kaK9gdD+oFxWf3Qmj+XGT8Rl4DulatAFMkgIgX3KMst6jqScmUdCcI4ImSbOMFg0MwiJhPLddsbzeXhgB", + "AvrTNIt8fXPoWnz+U69CtTW3UEGe/FXDbIB/ON0LoG7c" + ], + "Sequence": 4294967295 + }, + { + "PreviousOutPoint": { + "Hash": "b85755938ac026b2d13e5fbacf015288f453712b4eb4a02d7e4c98ee76ada530", + "Index": 0 + }, + "SignatureScript": "", + "Witness": [ + "MEUCIQCFNdqVZQvNeGSV8/2/GRA/wNZAjQAtYCErth+8e/aJRQIgK6Xl4ymJrD7yk/VWGWwmM+bnN1DjJT7UdONmxWSawd0B", + "AvrTNIt8fXPoWnz+U69CtTW3UEGe/FXDbIB/ON0LoG7c" + ], + "Sequence": 4294967295 + } + ], + "TxOut": [ + { "Value": 2148, "PkScript": "ABTaquDT3p2P3uMWYeYa6oKLWb54ZA==" }, + { "Value": 12000, "PkScript": "ABQMG/t9ON/wlG/exWJtUa1Y1+m8VA==" }, + { "Value": 39041489, "PkScript": "ABTaquDT3p2P3uMWYeYa6oKLWb54ZA==" } + ], + "LockTime": 0 +} diff --git a/zetaclient/testdata/btc/chain_8332_tx_030cd813443f7b70cc6d8a544d320c6d8465e4528fc0f3410b599dc0b26753a0.json b/zetaclient/testdata/btc/chain_8332_tx_030cd813443f7b70cc6d8a544d320c6d8465e4528fc0f3410b599dc0b26753a0.json new file mode 100644 index 0000000000..554dcbdd1b --- /dev/null +++ b/zetaclient/testdata/btc/chain_8332_tx_030cd813443f7b70cc6d8a544d320c6d8465e4528fc0f3410b599dc0b26753a0.json @@ -0,0 +1,58 @@ +{ + "amount": -0.00012, + "fee": -0.00027213, + "confirmations": 0, + "blockhash": "000000000000000000019881b7ae81a9bfac866989c8b976b1aff7ace01b85e7", + "blockindex": 150, + "blocktime": 1708608596, + "txid": "030cd813443f7b70cc6d8a544d320c6d8465e4528fc0f3410b599dc0b26753a0", + "walletconflicts": [], + "time": 1708608291, + "timereceived": 1708608291, + "details": [ + { + "account": "", + "address": "bc1qm24wp577nk8aacckv8np465z3dvmu7ry45el6y", + "amount": -0.00002148, + "category": "send", + "involveswatchonly": true, + "fee": -0.00027213, + "vout": 0 + }, + { + "account": "", + "address": "bc1qpsdlklfcmlcfgm77c43x65ddtrt7n0z57hsyjp", + "amount": -0.00012, + "category": "send", + "involveswatchonly": true, + "fee": -0.00027213, + "vout": 1 + }, + { + "account": "", + "address": "bc1qm24wp577nk8aacckv8np465z3dvmu7ry45el6y", + "amount": -0.39041489, + "category": "send", + "involveswatchonly": true, + "fee": -0.00027213, + "vout": 2 + }, + { + "account": "", + "address": "bc1qm24wp577nk8aacckv8np465z3dvmu7ry45el6y", + "amount": 0.00002148, + "category": "receive", + "involveswatchonly": true, + "vout": 0 + }, + { + "account": "", + "address": "bc1qm24wp577nk8aacckv8np465z3dvmu7ry45el6y", + "amount": 0.39041489, + "category": "receive", + "involveswatchonly": true, + "vout": 2 + } + ], + "hex": "0100000000010792fe0c144838ef41b22aa655771547acca8ee913efafb8b3eb8cbd182a30caef0000000000ffffffffefceb531b2f8db989f7f7cfba458a434e313aa84ea7070713e391d0ceb05c03d0000000000ffffffffd7e5f6f569fa1b52bdab189ca836e8c1d4409c934d34ee821bb2f325a8acc3740000000000ffffffffbfc5c2e4988acfa851d68880b4869621c3be2132c5993cab4a1f580eef4c26870000000000ffffffff7400bc3a0f71a60a4241c8ffc2dbe860a8791d34e14a62963df03d973349f25a0000000000ffffffff92fe0c144838ef41b22aa655771547acca8ee913efafb8b3eb8cbd182a30caef0200000000ffffffff30a5ad76ee984c7e2da0b44e2b7153f4885201cfba5f3ed1b226c08a935557b80000000000ffffffff036408000000000000160014daaae0d3de9d8fdee31661e61aea828b59be7864e02e0000000000001600140c1bfb7d38dff0946fdec5626d51ad58d7e9bc54d1b9530200000000160014daaae0d3de9d8fdee31661e61aea828b59be786402483045022100febd70794057e61b83e2aac09302d89910e3b79a031b084b4f148c5529a189c602207c60a3834eeb7dd989c108c735bc17e0d53ce77a016e8c0890dbc1e5dc573b6c012102fad3348b7c7d73e85a7cfe53af42b535b750419efc55c36c807f38dd0ba06edc02483045022100e2a7db33480b4623c191be348525f48d2c6c7fd8c7a976ab87d556a3d9f1bf0c022060c51f38542f13c332c114aaaa2093728343a95506930d5c810bddde4a312153012102fad3348b7c7d73e85a7cfe53af42b535b750419efc55c36c807f38dd0ba06edc0247304402203cebf1856c0af2c5c2d8ece435418e999e700507ea647bb5cf109949b337d57302206753f01b7c81b731046b18aff7589e8471524aefe6349a346870bc09ca1de8d7012102fad3348b7c7d73e85a7cfe53af42b535b750419efc55c36c807f38dd0ba06edc02473044022053358333d728affa4a95e23f66599f66c9401e4f802bbb13f014acfae28217680220793c58356267a7c0052889a7da83d37eb01306277496b3e044e4a5b4abdc4461012102fad3348b7c7d73e85a7cfe53af42b535b750419efc55c36c807f38dd0ba06edc02473044022021552ae9009183979b534dedb251d555aacaa68f140c016ebfb1e6787a35c5e00220309df76bd511124352c3955f92d0db1cbab6504fe5649b0585b0c7400403570b012102fad3348b7c7d73e85a7cfe53af42b535b750419efc55c36c807f38dd0ba06edc02483045022100fabcbdbccc0d2468af60743fa817159fdd09a3f97193f119780ee95ab4014c9202205f728cb2dea3a9272651d09c23822649b38c160d0cc222613cb75db1bcde5e18012102fad3348b7c7d73e85a7cfe53af42b535b750419efc55c36c807f38dd0ba06edc024830450221008535da95650bcd786495f3fdbf19103fc0d6408d002d60212bb61fbc7bf6894502202ba5e5e32989ac3ef293f556196c2633e6e73750e3253ed474e366c5649ac1dd012102fad3348b7c7d73e85a7cfe53af42b535b750419efc55c36c807f38dd0ba06edc00000000" +} diff --git a/zetaclient/testutils/mocks/bitcoin_client.go b/zetaclient/testutils/mocks/bitcoin_client.go index c9219f6aab..5f76cdbc42 100644 --- a/zetaclient/testutils/mocks/bitcoin_client.go +++ b/zetaclient/testutils/mocks/bitcoin_client.go @@ -1,4 +1,4 @@ -// Code generated by mockery v2.51.0. DO NOT EDIT. +// Code generated by mockery v2.45.0. DO NOT EDIT. package mocks @@ -357,6 +357,34 @@ func (_m *BitcoinClient) GetBlockVerboseByStr(ctx context.Context, blockHash str return r0, r1 } +// GetEstimatedFeeRate provides a mock function with given fields: ctx, confTarget +func (_m *BitcoinClient) GetEstimatedFeeRate(ctx context.Context, confTarget int64) (int64, error) { + ret := _m.Called(ctx, confTarget) + + if len(ret) == 0 { + panic("no return value specified for GetEstimatedFeeRate") + } + + var r0 int64 + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, int64) (int64, error)); ok { + return rf(ctx, confTarget) + } + if rf, ok := ret.Get(0).(func(context.Context, int64) int64); ok { + r0 = rf(ctx, confTarget) + } else { + r0 = ret.Get(0).(int64) + } + + if rf, ok := ret.Get(1).(func(context.Context, int64) error); ok { + r1 = rf(ctx, confTarget) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // GetNetworkInfo provides a mock function with given fields: ctx func (_m *BitcoinClient) GetNetworkInfo(ctx context.Context) (*btcjson.GetNetworkInfoResult, error) { ret := _m.Called(ctx) diff --git a/zetaclient/testutils/mocks/zetacore_client_opts.go b/zetaclient/testutils/mocks/zetacore_client_opts.go index 503264d867..723daff490 100644 --- a/zetaclient/testutils/mocks/zetacore_client_opts.go +++ b/zetaclient/testutils/mocks/zetacore_client_opts.go @@ -34,6 +34,17 @@ func (_m *ZetacoreClient) WithPostVoteOutbound(zetaTxHash string, ballotIndex st return _m } +func (_m *ZetacoreClient) WithPostOutboundTracker(zetaTxHash string) *ZetacoreClient { + on := _m.On("PostOutboundTracker", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Maybe() + if zetaTxHash != "" { + on.Return(zetaTxHash, nil) + } else { + on.Return("", errSomethingIsWrong) + } + + return _m +} + func (_m *ZetacoreClient) WithPostVoteInbound(zetaTxHash string, ballotIndex string) *ZetacoreClient { _m.On("PostVoteInbound", mock.Anything, mock.Anything, mock.Anything, mock.Anything). Maybe(). diff --git a/zetaclient/testutils/testdata.go b/zetaclient/testutils/testdata.go index 007d5a87e6..8936849448 100644 --- a/zetaclient/testutils/testdata.go +++ b/zetaclient/testutils/testdata.go @@ -117,6 +117,14 @@ func LoadBTCMsgTx(t *testing.T, dir string, chainID int64, txHash string) *wire. return msgTx } +// LoadBTCTransaction loads archived Bitcoin transaction from file +func LoadBTCTransaction(t *testing.T, dir string, chainID int64, txHash string) *btcjson.GetTransactionResult { + name := path.Join(dir, TestDataPathBTC, FileNameBTCTransaction(chainID, txHash)) + tx := &btcjson.GetTransactionResult{} + LoadObjectFromJSONFile(t, tx, name) + return tx +} + // LoadBTCTxRawResult loads archived Bitcoin tx raw result from file func LoadBTCTxRawResult(t *testing.T, dir string, chainID int64, txType string, txHash string) *btcjson.TxRawResult { name := path.Join(dir, TestDataPathBTC, FileNameBTCTxByType(chainID, txType, txHash)) diff --git a/zetaclient/testutils/testdata_naming.go b/zetaclient/testutils/testdata_naming.go index be09b0b0fa..03751c7722 100644 --- a/zetaclient/testutils/testdata_naming.go +++ b/zetaclient/testutils/testdata_naming.go @@ -64,6 +64,11 @@ func FileNameBTCMsgTx(chainID int64, txHash string) string { return fmt.Sprintf("chain_%d_msgtx_%s.json", chainID, txHash) } +// FileNameBTCTransaction returns unified archive file name for btc transaction +func FileNameBTCTransaction(chainID int64, txHash string) string { + return fmt.Sprintf("chain_%d_tx_%s.json", chainID, txHash) +} + // FileNameEVMOutbound returns unified archive file name for outbound tx func FileNameEVMOutbound(chainID int64, txHash string, coinType coin.CoinType) string { return fmt.Sprintf("chain_%d_outbound_%s_%s.json", chainID, coinType, txHash) diff --git a/zetaclient/tss/crypto.go b/zetaclient/tss/crypto.go index 170f433657..731b4344e3 100644 --- a/zetaclient/tss/crypto.go +++ b/zetaclient/tss/crypto.go @@ -11,6 +11,7 @@ import ( "github.com/btcsuite/btcd/btcutil" "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/txscript" "github.com/cosmos/cosmos-sdk/crypto/keys/secp256k1" cryptotypes "github.com/cosmos/cosmos-sdk/crypto/types" eth "github.com/ethereum/go-ethereum/common" @@ -111,11 +112,20 @@ func (k PubKey) Bech32String() string { return v } -// AddressBTC returns the bitcoin address of the public key. +// AddressBTC returns the Bitcoin address of the public key. func (k PubKey) AddressBTC(chainID int64) (*btcutil.AddressWitnessPubKeyHash, error) { return bitcoinP2WPKH(k.Bytes(true), chainID) } +// BTCPayToAddrScript returns the script for the Bitcoin TSS address. +func (k PubKey) BTCPayToAddrScript(chainID int64) ([]byte, error) { + tssAddrP2WPKH, err := k.AddressBTC(chainID) + if err != nil { + return nil, err + } + return txscript.PayToAddrScript(tssAddrP2WPKH) +} + // AddressEVM returns the ethereum address of the public key. func (k PubKey) AddressEVM() eth.Address { return crypto.PubkeyToAddress(*k.ecdsaPubKey) diff --git a/zetaclient/tss/crypto_test.go b/zetaclient/tss/crypto_test.go index b34e0dc21e..287a39947b 100644 --- a/zetaclient/tss/crypto_test.go +++ b/zetaclient/tss/crypto_test.go @@ -5,6 +5,7 @@ import ( "strings" "testing" + "github.com/btcsuite/btcd/txscript" "github.com/ethereum/go-ethereum/crypto" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -46,6 +47,12 @@ func TestPubKey(t *testing.T) { addrBTC, err := pk.AddressBTC(chains.BitcoinMainnet.ChainId) require.NoError(t, err) + expectedPkScript, err := txscript.PayToAddrScript(addrBTC) + require.NoError(t, err) + pkScript, err := pk.BTCPayToAddrScript(chains.BitcoinMainnet.ChainId) + require.NoError(t, err) + assert.Equal(t, expectedPkScript, pkScript) + assert.Equal(t, sample, pk.Bech32String()) assert.Equal(t, "0x70e967acfcc17c3941e87562161406d41676fd83", strings.ToLower(addrEVM.Hex())) assert.Equal(t, "bc1qm24wp577nk8aacckv8np465z3dvmu7ry45el6y", addrBTC.String()) diff --git a/zetaclient/zetacore/broadcast.go b/zetaclient/zetacore/broadcast.go index f9cc0eb3ec..3ec979a348 100644 --- a/zetaclient/zetacore/broadcast.go +++ b/zetaclient/zetacore/broadcast.go @@ -20,6 +20,7 @@ import ( "github.com/zeta-chain/node/app/ante" "github.com/zeta-chain/node/cmd/zetacored/config" "github.com/zeta-chain/node/zetaclient/authz" + "github.com/zeta-chain/node/zetaclient/logs" ) // paying 50% more than the current base gas price to buffer for potential block-by-block @@ -159,16 +160,17 @@ func (c *Client) QueryTxResult(hash string) (*sdktypes.TxResponse, error) { // HandleBroadcastError returns whether to retry in a few seconds, and whether to report via AddOutboundTracker // returns (bool retry, bool report) -func HandleBroadcastError(err error, nonce, toChain, outboundHash string) (bool, bool) { +func HandleBroadcastError(err error, nonce uint64, toChain int64, outboundHash string) (bool, bool) { if err == nil { return false, false } msg := err.Error() evt := log.Warn().Err(err). - Str("broadcast.nonce", nonce). - Str("broadcast.to_chain", toChain). - Str("broadcast.outbound_hash", outboundHash) + Str(logs.FieldMethod, "HandleBroadcastError"). + Int64(logs.FieldChain, toChain). + Uint64(logs.FieldNonce, nonce). + Str(logs.FieldTx, outboundHash) switch { case strings.Contains(msg, "nonce too low"): diff --git a/zetaclient/zetacore/broadcast_test.go b/zetaclient/zetacore/broadcast_test.go index f6e3b3ca84..f91aad2110 100644 --- a/zetaclient/zetacore/broadcast_test.go +++ b/zetaclient/zetacore/broadcast_test.go @@ -32,7 +32,7 @@ func TestHandleBroadcastError(t *testing.T) { errors.New(""): {retry: true, report: false}, } for input, output := range testCases { - retry, report := HandleBroadcastError(input, "", "", "") + retry, report := HandleBroadcastError(input, 100, 1, "") require.Equal(t, output.report, report) require.Equal(t, output.retry, retry) }