Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: support for Bitcoin RBF (Replace-By-Fee) and CPFP (Child-Pays-for-Parent) #3306

Open
wants to merge 26 commits into
base: develop
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 22 commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
799a22b
initiate Bitcoin RBF live tests
ws4charlie Dec 7, 2024
e7cade5
Merge branch 'develop' of https://github.com/zeta-chain/node into fea…
ws4charlie Dec 7, 2024
3af0e09
remove duplicate LiveTest_PendingMempoolTx test
ws4charlie Dec 9, 2024
1ad6628
initiate Bitcoin mempool watcher and RBF keysign logic
ws4charlie Dec 22, 2024
7cd37e9
Merge branch 'develop' of https://github.com/zeta-chain/node into fea…
ws4charlie Dec 31, 2024
2cbc42a
move functions to separate files; add unit tests for signer, RBF fee …
ws4charlie Jan 5, 2025
7cb6118
mark mempool test as live test
ws4charlie Jan 6, 2025
f730749
Merge branch 'develop' into feat-bitcoin-Replace-By-Fee
ws4charlie Jan 6, 2025
5d993a6
update BTC CCTX gas rate in block beginner
ws4charlie Jan 7, 2025
e0845bd
add mempool tests
ws4charlie Jan 8, 2025
5c15070
add keysign tests
ws4charlie Jan 8, 2025
847e2f1
use latest bitcoin-core-docker to be able to call mempool related RPCs
ws4charlie Jan 8, 2025
c507687
always use latest gas rate for Bitcoin outbound; enforce gas rate cap
ws4charlie Jan 8, 2025
b991a37
Merge branch 'develop' into feat-bitcoin-Replace-By-Fee
ws4charlie Jan 8, 2025
cc6c258
fix gosec and unit tests
ws4charlie Jan 8, 2025
6fd4335
add changelog entry
ws4charlie Jan 8, 2025
dfb9f65
add Bitcoin RBF transaction E2E test
ws4charlie Jan 10, 2025
e03ba4b
fix unit tests
ws4charlie Jan 10, 2025
5df65fb
handle integer overflow and correct typo in function comment
ws4charlie Jan 13, 2025
f4d3fc8
renamed a few functions to use capitalized CCTX
ws4charlie Jan 13, 2025
0b954d0
unify log fields and add Base chain URL for live test
ws4charlie Jan 13, 2025
24d12ae
use CheckAndUpdateCCTXGasPrice to dispatch evm/btc gas price updating…
ws4charlie Jan 14, 2025
86df3b0
Merge branch 'develop' of https://github.com/zeta-chain/node into fea…
ws4charlie Jan 14, 2025
728f697
double check to ensure the RBF tx is the last tx before broadcasting
ws4charlie Jan 14, 2025
104f372
resolve readme conflict
ws4charlie Jan 14, 2025
03d737c
add optGenericSkipper to mempool txs watcher
ws4charlie Jan 15, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions changelog.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,17 @@
# CHANGELOG

## Unreleased

### Features

* [3306](https://github.com/zeta-chain/node/pull/3306) - add support for Bitcoin RBF (Replace-By-Fee)

### Tests

## Refactor

### Fixes

## v25.0.0

### Features
Expand Down
1 change: 1 addition & 0 deletions cmd/zetae2e/local/local.go
Original file line number Diff line number Diff line change
Expand Up @@ -317,6 +317,7 @@ func localE2ETest(cmd *cobra.Command, _ []string) {
e2etests.TestBitcoinWithdrawP2WSHName,
e2etests.TestBitcoinWithdrawMultipleName,
e2etests.TestBitcoinWithdrawRestrictedName,
//e2etests.TestBitcoinWithdrawRBFName, // leave it as the last BTC test
ws4charlie marked this conversation as resolved.
Show resolved Hide resolved
}

if !light {
Expand Down
2 changes: 1 addition & 1 deletion contrib/localnet/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -199,7 +199,7 @@ services:
ipv4_address: 172.20.0.102

bitcoin:
image: ghcr.io/zeta-chain/bitcoin-core-docker:a94b52f
image: ghcr.io/zeta-chain/bitcoin-core-docker:28.0-zeta6
container_name: bitcoin
hostname: bitcoin
networks:
Expand Down
10 changes: 10 additions & 0 deletions e2e/e2etests/e2etests.go
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ const (
TestBitcoinWithdrawP2SHName = "bitcoin_withdraw_p2sh"
TestBitcoinWithdrawInvalidAddressName = "bitcoin_withdraw_invalid"
TestBitcoinWithdrawRestrictedName = "bitcoin_withdraw_restricted"
TestBitcoinWithdrawRBFName = "bitcoin_withdraw_rbf"

/*
Application tests
Expand Down Expand Up @@ -717,6 +718,15 @@ var AllE2ETests = []runner.E2ETest{
},
TestBitcoinWithdrawRestricted,
),
runner.NewE2ETest(
TestBitcoinWithdrawRBFName,
"withdraw Bitcoin from ZEVM and replace the outbound using RBF",
[]runner.ArgDefinition{
{Description: "receiver address", DefaultValue: ""},
{Description: "amount in btc", DefaultValue: "0.001"},
},
TestBitcoinWithdrawRBF,
),
/*
Application tests
*/
Expand Down
45 changes: 25 additions & 20 deletions e2e/e2etests/helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"github.com/btcsuite/btcd/btcjson"
"github.com/btcsuite/btcd/btcutil"
"github.com/btcsuite/btcd/chaincfg/chainhash"
ethtypes "github.com/ethereum/go-ethereum/core/types"
"github.com/stretchr/testify/require"

"github.com/zeta-chain/node/e2e/runner"
Expand All @@ -25,31 +26,13 @@ func randomPayload(r *runner.E2ERunner) string {
}

func withdrawBTCZRC20(r *runner.E2ERunner, to btcutil.Address, amount *big.Int) *btcjson.TxRawResult {
tx, err := r.BTCZRC20.Approve(
r.ZEVMAuth,
r.BTCZRC20Addr,
big.NewInt(amount.Int64()*2),
) // approve more to cover withdraw fee
require.NoError(r, err)

receipt := utils.MustWaitForTxReceipt(r.Ctx, r.ZEVMClient, tx, r.Logger, r.ReceiptTimeout)
utils.RequireTxSuccessful(r, receipt)
// call approve and withdraw on ZRC20 contract
receipt := approveAndWithdrawBTCZRC20(r, to, amount)

// mine blocks if testing on regnet
stop := r.MineBlocksIfLocalBitcoin()
defer stop()

// withdraw 'amount' of BTC from ZRC20 to BTC address
tx, err = r.BTCZRC20.Withdraw(r.ZEVMAuth, []byte(to.EncodeAddress()), amount)
require.NoError(r, err)

receipt = utils.MustWaitForTxReceipt(r.Ctx, r.ZEVMClient, tx, r.Logger, r.ReceiptTimeout)
utils.RequireTxSuccessful(r, receipt)

// mine 10 blocks to confirm the withdrawal tx
_, err = r.GenerateToAddressIfLocalBitcoin(10, to)
require.NoError(r, err)

// get cctx and check status
cctx := utils.WaitCctxMinedByInboundHash(r.Ctx, receipt.TxHash.Hex(), r.CctxClient, r.Logger, r.CctxTimeout)
utils.RequireCCTXStatus(r, cctx, crosschaintypes.CctxStatus_OutboundMined)
Expand Down Expand Up @@ -79,6 +62,28 @@ func withdrawBTCZRC20(r *runner.E2ERunner, to btcutil.Address, amount *big.Int)
return rawTx
}

// approveAndWithdrawBTCZRC20 is a helper function to call 'approve' and 'withdraw' on BTCZRC20 contract
func approveAndWithdrawBTCZRC20(r *runner.E2ERunner, to btcutil.Address, amount *big.Int) *ethtypes.Receipt {
tx, err := r.BTCZRC20.Approve(
r.ZEVMAuth,
r.BTCZRC20Addr,
big.NewInt(amount.Int64()*2),
) // approve more to cover withdraw fee
require.NoError(r, err)

receipt := utils.MustWaitForTxReceipt(r.Ctx, r.ZEVMClient, tx, r.Logger, r.ReceiptTimeout)
utils.RequireTxSuccessful(r, receipt)

// withdraw 'amount' of BTC from ZRC20 to BTC address
tx, err = r.BTCZRC20.Withdraw(r.ZEVMAuth, []byte(to.EncodeAddress()), amount)
require.NoError(r, err)

receipt = utils.MustWaitForTxReceipt(r.Ctx, r.ZEVMClient, tx, r.Logger, r.ReceiptTimeout)
utils.RequireTxSuccessful(r, receipt)

return receipt
}
ws4charlie marked this conversation as resolved.
Show resolved Hide resolved

// bigAdd is shorthand for new(big.Int).Add(x, y)
func bigAdd(x *big.Int, y *big.Int) *big.Int {
return new(big.Int).Add(x, y)
Expand Down
16 changes: 14 additions & 2 deletions e2e/e2etests/test_bitcoin_deposit_and_withdraw_with_dust.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package e2etests

import (
"math/big"
"sync"

"github.com/ethereum/go-ethereum/accounts/abi/bind"
"github.com/stretchr/testify/require"
Expand All @@ -12,17 +13,28 @@ import (
crosschaintypes "github.com/zeta-chain/node/x/crosschain/types"
)

// wgDeposit is a wait group for deposit runner to finish
var wgDepositRunner sync.WaitGroup

func init() {
ws4charlie marked this conversation as resolved.
Show resolved Hide resolved
// there is one single deposit runner for Bitcoin E2E tests
wgDepositRunner.Add(1)
}

// TestBitcoinDepositAndWithdrawWithDust deposits Bitcoin and call a smart contract that withdraw dust amount
// It tests the edge case where during a cross-chain call, a invaild withdraw is initiated (processLogs fails)
func TestBitcoinDepositAndWithdrawWithDust(r *runner.E2ERunner, args []string) {
// Given "Live" BTC network
stop := r.MineBlocksIfLocalBitcoin()
defer stop()
defer func() {
stop()
// signal the deposit runner is done after this last test
wgDepositRunner.Done()
}()

require.Len(r, args, 0)

// ARRANGE

// Deploy the withdrawer contract on ZetaChain with a withdraw amount of 100 satoshis (dust amount is 1000 satoshis)
withdrawerAddr, tx, _, err := withdrawer.DeployWithdrawer(r.ZEVMAuth, r.ZEVMClient, big.NewInt(100))
require.NoError(r, err)
Expand Down
77 changes: 77 additions & 0 deletions e2e/e2etests/test_bitcoin_withdraw_rbf.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
package e2etests

import (
"strconv"
"time"

"github.com/btcsuite/btcd/chaincfg/chainhash"
"github.com/stretchr/testify/require"

"github.com/zeta-chain/node/e2e/runner"
"github.com/zeta-chain/node/e2e/utils"
crosschaintypes "github.com/zeta-chain/node/x/crosschain/types"
"github.com/zeta-chain/node/zetaclient/chains/bitcoin/rpc"
)

// TestBitcoinWithdrawRBF tests the RBF (Replace-By-Fee) feature in Zetaclient.
// It needs block mining to be stopped and runs as the last test in the suite.
//
// IMPORTANT: the test requires to simulate a stuck tx in the Bitcoin regnet.
// Changing the 'minTxConfirmations' to 1 to not include Bitcoin pending txs.
// https://github.com/zeta-chain/node/blob/feat-bitcoin-Replace-By-Fee/zetaclient/chains/bitcoin/observer/outbound.go#L30
func TestBitcoinWithdrawRBF(r *runner.E2ERunner, args []string) {
require.Len(r, args, 2)

// wait for block mining to stop
wgDepositRunner.Wait()
r.Logger.Print("Bitcoin mining stopped, starting RBF test")

// parse arguments
defaultReceiver := r.BTCDeployerAddress.EncodeAddress()
to, amount := utils.ParseBitcoinWithdrawArgs(r, args, defaultReceiver, r.GetBitcoinChainID())

// initiate a withdraw CCTX
receipt := approveAndWithdrawBTCZRC20(r, to, amount)
cctx := utils.GetCCTXByInboundHash(r.Ctx, receipt.TxHash.Hex(), r.CctxClient)

// wait for the 1st outbound tracker hash to come in
nonce := cctx.GetCurrentOutboundParam().TssNonce
hashes := utils.WaitOutboundTracker(r.Ctx, r.CctxClient, r.GetBitcoinChainID(), nonce, 1, r.Logger, 3*time.Minute)
txHash, err := chainhash.NewHashFromStr(hashes[0])
r.Logger.Info("got 1st tracker hash: %s", txHash)

// get original tx
require.NoError(r, err)
txResult, err := r.BtcRPCClient.GetTransaction(txHash)
require.NoError(r, err)
require.Zero(r, txResult.Confirmations)

// wait for RBF tx to kick in
hashes = utils.WaitOutboundTracker(r.Ctx, r.CctxClient, r.GetBitcoinChainID(), nonce, 2, r.Logger, 3*time.Minute)
txHashRBF, err := chainhash.NewHashFromStr(hashes[1])
require.NoError(r, err)
r.Logger.Info("got 2nd tracker hash: %s", txHashRBF)

// resume block mining
stop := r.MineBlocksIfLocalBitcoin()
defer stop()

// waiting for CCTX to be mined
cctx = utils.WaitCctxMinedByInboundHash(r.Ctx, receipt.TxHash.Hex(), r.CctxClient, r.Logger, r.CctxTimeout)
utils.RequireCCTXStatus(r, cctx, crosschaintypes.CctxStatus_OutboundMined)

// ensure the original tx is dropped
utils.MustHaveDroppedTx(r.Ctx, r.BtcRPCClient, txHash)

// ensure the RBF tx is mined
rawResult := utils.MustHaveMinedTx(r.Ctx, r.BtcRPCClient, txHashRBF)

// ensure RBF fee rate > old rate
params := cctx.GetCurrentOutboundParam()
oldRate, err := strconv.ParseInt(params.GasPrice, 10, 64)
require.NoError(r, err)

_, newRate, err := rpc.GetTransactionFeeAndRate(r.BtcRPCClient, rawResult)
require.NoError(r, err)
require.Greater(r, newRate, oldRate, "RBF fee rate should be higher than the original tx")
}
48 changes: 48 additions & 0 deletions e2e/utils/bitcoin.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
package utils

import (
"context"

"github.com/btcsuite/btcd/btcjson"
"github.com/btcsuite/btcd/chaincfg/chainhash"
"github.com/btcsuite/btcd/rpcclient"
"github.com/stretchr/testify/require"
)

// MustHaveDroppedTx ensures the given tx has been dropped
func MustHaveDroppedTx(ctx context.Context, client *rpcclient.Client, txHash *chainhash.Hash) {
t := TestingFromContext(ctx)

// dropped tx has negative confirmations
txResult, err := client.GetTransaction(txHash)
if err == nil {
require.Negative(t, txResult.Confirmations)
}

// dropped tx should be removed from mempool
entry, err := client.GetMempoolEntry(txHash.String())
require.Error(t, err)
require.Nil(t, entry)

// dropped tx won't exist in blockchain
// -5: No such mempool or blockchain transaction
rawTx, err := client.GetRawTransaction(txHash)
require.Error(t, err)
require.Nil(t, rawTx)
}

// MustHaveMinedTx ensures the given tx has been mined
func MustHaveMinedTx(ctx context.Context, client *rpcclient.Client, txHash *chainhash.Hash) *btcjson.TxRawResult {
t := TestingFromContext(ctx)

// positive confirmations
txResult, err := client.GetTransaction(txHash)
require.NoError(t, err)
require.Positive(t, txResult.Confirmations)

// tx exists in blockchain
rawResult, err := client.GetRawTransactionVerbose(txHash)
require.NoError(t, err)

return rawResult
}
69 changes: 69 additions & 0 deletions e2e/utils/zetacore.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package utils

import (
"context"
"fmt"
"time"

rpchttp "github.com/cometbft/cometbft/rpc/client/http"
Expand All @@ -28,6 +29,24 @@ const (
DefaultCctxTimeout = 8 * time.Minute
)

// GetCCTXByInboundHash gets cctx by inbound hash
func GetCCTXByInboundHash(
ctx context.Context,
inboundHash string,
client crosschaintypes.QueryClient,
) *crosschaintypes.CrossChainTx {
t := TestingFromContext(ctx)

// query cctx by inbound hash
in := &crosschaintypes.QueryInboundHashToCctxDataRequest{InboundHash: inboundHash}
res, err := client.InTxHashToCctxData(ctx, in)

require.NoError(t, err)
require.Len(t, res.CrossChainTxs, 1)

return &res.CrossChainTxs[0]
}

// WaitCctxMinedByInboundHash waits until cctx is mined; returns the cctxIndex (the last one)
func WaitCctxMinedByInboundHash(
ctx context.Context,
Expand Down Expand Up @@ -187,6 +206,56 @@ func WaitCCTXMinedByIndex(
}
}

// WaitOutboundTracker wait for outbound tracker to be filled with 'hashCount' hashes
func WaitOutboundTracker(
ctx context.Context,
client crosschaintypes.QueryClient,
chainID int64,
nonce uint64,
hashCount int,
logger infoLogger,
timeout time.Duration,
) []string {
if timeout == 0 {
timeout = DefaultCctxTimeout
}

t := TestingFromContext(ctx)
startTime := time.Now()
in := &crosschaintypes.QueryAllOutboundTrackerByChainRequest{Chain: chainID}

for {
require.False(
t,
time.Since(startTime) > timeout,
fmt.Sprintf("waiting outbound tracker timeout, chainID: %d, nonce: %d", chainID, nonce),
)
time.Sleep(5 * time.Second)

outboundTracker, err := client.OutboundTrackerAllByChain(ctx, in)
require.NoError(t, err)

// loop through all outbound trackers
for i, tracker := range outboundTracker.OutboundTracker {
if tracker.Nonce == nonce {
logger.Info("Tracker[%d]:\n", i)
logger.Info(" ChainId: %d\n", tracker.ChainId)
logger.Info(" Nonce: %d\n", tracker.Nonce)
logger.Info(" HashList:\n")

hashes := []string{}
for j, hash := range tracker.HashList {
hashes = append(hashes, hash.TxHash)
logger.Info(" hash[%d]: %s\n", j, hash.TxHash)
}
if len(hashes) >= hashCount {
return hashes
}
}
}
}
}
ws4charlie marked this conversation as resolved.
Show resolved Hide resolved

type WaitOpts func(c *waitConfig)

// MatchStatus is the WaitOpts that matches CCTX with the given status.
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ require (
github.com/davidlazar/go-crypto v0.0.0-20200604182044-b73af7476f6c // indirect
github.com/deckarep/golang-set v1.8.0 // indirect
github.com/decred/dcrd/dcrec/edwards/v2 v2.0.0 // indirect
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0 // indirect
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0
github.com/desertbit/timer v0.0.0-20180107155436-c41aec40b27f // indirect
github.com/dgraph-io/badger/v4 v4.2.0 // indirect
github.com/dgraph-io/ristretto v0.1.1 // indirect
Expand Down
Loading
Loading