Skip to content

Commit

Permalink
initiate Bitcoin mempool watcher and RBF keysign logic
Browse files Browse the repository at this point in the history
  • Loading branch information
ws4charlie committed Dec 22, 2024
1 parent 3af0e09 commit 1ad6628
Show file tree
Hide file tree
Showing 21 changed files with 1,758 additions and 937 deletions.
60 changes: 25 additions & 35 deletions zetaclient/chains/bitcoin/fee.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import (
"encoding/hex"
"fmt"
"math"
"math/big"

"github.com/btcsuite/btcd/blockchain"
"github.com/btcsuite/btcd/btcjson"
Expand All @@ -20,18 +19,17 @@ import (

const (
// constants related to transaction size calculations
bytesPerKB = 1000
bytesPerInput = 41 // each input is 41 bytes
bytesPerOutputP2TR = 43 // each P2TR output is 43 bytes
bytesPerOutputP2WSH = 43 // each P2WSH output is 43 bytes
bytesPerOutputP2WPKH = 31 // each P2WPKH output is 31 bytes
bytesPerOutputP2SH = 32 // each P2SH output is 32 bytes
bytesPerOutputP2PKH = 34 // each P2PKH output is 34 bytes
bytesPerOutputAvg = 37 // average size of all above types of outputs (36.6 bytes)
bytes1stWitness = 110 // the 1st witness incurs about 110 bytes and it may vary
bytesPerWitness = 108 // each additional witness incurs about 108 bytes and it may vary
OutboundBytesMin = uint64(239) // 239vB == EstimateSegWitTxSize(2, 2, toP2WPKH)
OutboundBytesMax = uint64(1543) // 1543v == EstimateSegWitTxSize(21, 2, toP2TR)
bytesPerInput = 41 // each input is 41 bytes
bytesPerOutputP2TR = 43 // each P2TR output is 43 bytes
bytesPerOutputP2WSH = 43 // each P2WSH output is 43 bytes
bytesPerOutputP2WPKH = 31 // each P2WPKH output is 31 bytes
bytesPerOutputP2SH = 32 // each P2SH output is 32 bytes
bytesPerOutputP2PKH = 34 // each P2PKH output is 34 bytes
bytesPerOutputAvg = 37 // average size of all above types of outputs (36.6 bytes)
bytes1stWitness = 110 // the 1st witness incurs about 110 bytes and it may vary
bytesPerWitness = 108 // each additional witness incurs about 108 bytes and it may vary
OutboundBytesMin = int64(239) // 239vB == EstimateSegWitTxSize(2, 2, toP2WPKH)
OutboundBytesMax = int64(1543) // 1543v == EstimateSegWitTxSize(21, 2, toP2TR)

// defaultDepositorFeeRate is the default fee rate for depositor fee, 20 sat/vB
defaultDepositorFeeRate = 20
Expand Down Expand Up @@ -59,34 +57,27 @@ var (
// DepositorFeeCalculator is a function type to calculate the Bitcoin depositor fee
type DepositorFeeCalculator func(interfaces.BTCRPCClient, *btcjson.TxRawResult, *chaincfg.Params) (float64, error)

// FeeRateToSatPerByte converts a fee rate in BTC/KB to sat/byte.
func FeeRateToSatPerByte(rate float64) *big.Int {
// #nosec G115 always in range
satPerKB := new(big.Int).SetInt64(int64(rate * btcutil.SatoshiPerBitcoin))
return new(big.Int).Div(satPerKB, big.NewInt(bytesPerKB))
}

// WiredTxSize calculates the wired tx size in bytes
func WiredTxSize(numInputs uint64, numOutputs uint64) uint64 {
func WiredTxSize(numInputs uint64, numOutputs uint64) int64 {
// Version 4 bytes + LockTime 4 bytes + Serialized varint size for the
// number of transaction inputs and outputs.
// #nosec G115 always positive
return uint64(8 + wire.VarIntSerializeSize(numInputs) + wire.VarIntSerializeSize(numOutputs))
return int64(8 + wire.VarIntSerializeSize(numInputs) + wire.VarIntSerializeSize(numOutputs))
}

// EstimateOutboundSize estimates the size of an outbound in vBytes
func EstimateOutboundSize(numInputs uint64, payees []btcutil.Address) (uint64, error) {
func EstimateOutboundSize(numInputs int64, payees []btcutil.Address) (int64, error) {
if numInputs == 0 {
return 0, nil
}
// #nosec G115 always positive
numOutputs := 2 + uint64(len(payees))
bytesWiredTx := WiredTxSize(numInputs, numOutputs)
bytesWiredTx := WiredTxSize(uint64(numInputs), numOutputs)
bytesInput := numInputs * bytesPerInput
bytesOutput := uint64(2) * bytesPerOutputP2WPKH // new nonce mark, change
bytesOutput := int64(2) * bytesPerOutputP2WPKH // new nonce mark, change

// calculate the size of the outputs to payees
bytesToPayees := uint64(0)
bytesToPayees := int64(0)
for _, to := range payees {
sizeOutput, err := GetOutputSizeByAddress(to)
if err != nil {
Expand All @@ -104,7 +95,7 @@ func EstimateOutboundSize(numInputs uint64, payees []btcutil.Address) (uint64, e
}

// GetOutputSizeByAddress returns the size of a tx output in bytes by the given address
func GetOutputSizeByAddress(to btcutil.Address) (uint64, error) {
func GetOutputSizeByAddress(to btcutil.Address) (int64, error) {
switch addr := to.(type) {
case *btcutil.AddressTaproot:
if addr == nil {
Expand Down Expand Up @@ -137,16 +128,16 @@ func GetOutputSizeByAddress(to btcutil.Address) (uint64, error) {
}

// OutboundSizeDepositor returns outbound size (68vB) incurred by the depositor
func OutboundSizeDepositor() uint64 {
func OutboundSizeDepositor() int64 {
return bytesPerInput + bytesPerWitness/blockchain.WitnessScaleFactor
}

// OutboundSizeWithdrawer returns outbound size (177vB) incurred by the withdrawer (1 input, 3 outputs)
func OutboundSizeWithdrawer() uint64 {
func OutboundSizeWithdrawer() int64 {
bytesWiredTx := WiredTxSize(1, 3)
bytesInput := uint64(1) * bytesPerInput // nonce mark
bytesOutput := uint64(2) * bytesPerOutputP2WPKH // 2 P2WPKH outputs: new nonce mark, change
bytesOutput += bytesPerOutputAvg // 1 output to withdrawer's address
bytesInput := int64(1) * bytesPerInput // nonce mark
bytesOutput := int64(2) * bytesPerOutputP2WPKH // 2 P2WPKH outputs: new nonce mark, change
bytesOutput += bytesPerOutputAvg // 1 output to withdrawer's address

return bytesWiredTx + bytesInput + bytesOutput + bytes1stWitness/blockchain.WitnessScaleFactor
}
Expand Down Expand Up @@ -246,7 +237,7 @@ func CalcDepositorFee(

// GetRecentFeeRate gets the highest fee rate from recent blocks
// Note: this method should be used for testnet ONLY
func GetRecentFeeRate(rpcClient interfaces.BTCRPCClient, netParams *chaincfg.Params) (uint64, error) {
func GetRecentFeeRate(rpcClient interfaces.BTCRPCClient, netParams *chaincfg.Params) (int64, error) {
// should avoid using this method for mainnet
if netParams.Name == chaincfg.MainNetParams.Name {
return 0, errors.New("GetRecentFeeRate should not be used for mainnet")
Expand Down Expand Up @@ -286,6 +277,5 @@ func GetRecentFeeRate(rpcClient interfaces.BTCRPCClient, netParams *chaincfg.Par
highestRate = defaultTestnetFeeRate
}

// #nosec G115 always in range
return uint64(highestRate), nil
return highestRate, nil
}
20 changes: 10 additions & 10 deletions zetaclient/chains/bitcoin/fee_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -195,9 +195,9 @@ func TestOutboundSize2In3Out(t *testing.T) {

// Estimate the tx size in vByte
// #nosec G115 always positive
vError := uint64(1) // 1 vByte error tolerance
vBytes := uint64(blockchain.GetTransactionWeight(btcutil.NewTx(tx)) / blockchain.WitnessScaleFactor)
vBytesEstimated, err := EstimateOutboundSize(uint64(len(utxosTxids)), []btcutil.Address{payee})
vError := int64(1) // 1 vByte error tolerance
vBytes := blockchain.GetTransactionWeight(btcutil.NewTx(tx)) / blockchain.WitnessScaleFactor
vBytesEstimated, err := EstimateOutboundSize(int64(len(utxosTxids)), []btcutil.Address{payee})
require.NoError(t, err)
if vBytes > vBytesEstimated {
require.True(t, vBytes-vBytesEstimated <= vError)
Expand All @@ -219,9 +219,9 @@ func TestOutboundSize21In3Out(t *testing.T) {

// Estimate the tx size in vByte
// #nosec G115 always positive
vError := uint64(21 / 4) // 5 vBytes error tolerance
vBytes := uint64(blockchain.GetTransactionWeight(btcutil.NewTx(tx)) / blockchain.WitnessScaleFactor)
vBytesEstimated, err := EstimateOutboundSize(uint64(len(exampleTxids)), []btcutil.Address{payee})
vError := int64(21 / 4) // 5 vBytes error tolerance
vBytes := blockchain.GetTransactionWeight(btcutil.NewTx(tx)) / blockchain.WitnessScaleFactor
vBytesEstimated, err := EstimateOutboundSize(int64(len(exampleTxids)), []btcutil.Address{payee})
require.NoError(t, err)
if vBytes > vBytesEstimated {
require.True(t, vBytes-vBytesEstimated <= vError)
Expand All @@ -243,11 +243,11 @@ func TestOutboundSizeXIn3Out(t *testing.T) {

// Estimate the tx size
// #nosec G115 always positive
vError := uint64(
vError := int64(
0.25 + float64(x)/4,
) // 1st witness incurs 0.25 more vByte error than others (which incurs 1/4 vByte per witness)
vBytes := uint64(blockchain.GetTransactionWeight(btcutil.NewTx(tx)) / blockchain.WitnessScaleFactor)
vBytesEstimated, err := EstimateOutboundSize(uint64(len(exampleTxids[:x])), []btcutil.Address{payee})
vBytes := blockchain.GetTransactionWeight(btcutil.NewTx(tx)) / blockchain.WitnessScaleFactor
vBytesEstimated, err := EstimateOutboundSize(int64(len(exampleTxids[:x])), []btcutil.Address{payee})
require.NoError(t, err)
if vBytes > vBytesEstimated {
require.True(t, vBytes-vBytesEstimated <= vError)
Expand Down Expand Up @@ -413,7 +413,7 @@ func TestOutboundSizeBreakdown(t *testing.T) {
}

// add all outbound sizes paying to each address
txSizeTotal := uint64(0)
txSizeTotal := int64(0)
for _, payee := range payees {
sizeOutput, err := EstimateOutboundSize(2, []btcutil.Address{payee})
require.NoError(t, err)
Expand Down
65 changes: 65 additions & 0 deletions zetaclient/chains/bitcoin/observer/db.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
package observer

import (
"github.com/pkg/errors"

"github.com/zeta-chain/node/pkg/chains"
clienttypes "github.com/zeta-chain/node/zetaclient/types"
)

// SaveBroadcastedTx saves successfully broadcasted transaction
func (ob *Observer) SaveBroadcastedTx(txHash string, nonce uint64) {
outboundID := ob.OutboundID(nonce)
ob.Mu().Lock()
ob.broadcastedTx[outboundID] = txHash
ob.Mu().Unlock()

broadcastEntry := clienttypes.ToOutboundHashSQLType(txHash, outboundID)
if err := ob.DB().Client().Save(&broadcastEntry).Error; err != nil {
ob.logger.Outbound.Error().
Err(err).
Msgf("SaveBroadcastedTx: error saving broadcasted txHash %s for outbound %s", txHash, outboundID)
}
ob.logger.Outbound.Info().Msgf("SaveBroadcastedTx: saved broadcasted txHash %s for outbound %s", txHash, outboundID)
}

// LoadLastBlockScanned loads the last scanned block from the database
func (ob *Observer) LoadLastBlockScanned() error {
err := ob.Observer.LoadLastBlockScanned(ob.Logger().Chain)
if err != nil {
return errors.Wrapf(err, "error LoadLastBlockScanned for chain %d", ob.Chain().ChainId)
}

// observer will scan from the last block when 'lastBlockScanned == 0', this happens when:
// 1. environment variable is set explicitly to "latest"
// 2. environment variable is empty and last scanned block is not found in DB
if ob.LastBlockScanned() == 0 {
blockNumber, err := ob.btcClient.GetBlockCount()
if err != nil {
return errors.Wrapf(err, "error GetBlockCount for chain %d", ob.Chain().ChainId)
}
// #nosec G115 always positive
ob.WithLastBlockScanned(uint64(blockNumber))
}

// bitcoin regtest starts from hardcoded block 100
if chains.IsBitcoinRegnet(ob.Chain().ChainId) {
ob.WithLastBlockScanned(RegnetStartBlock)
}
ob.Logger().Chain.Info().Msgf("chain %d starts scanning from block %d", ob.Chain().ChainId, ob.LastBlockScanned())

return nil
}

// LoadBroadcastedTxMap loads broadcasted transactions from the database
func (ob *Observer) LoadBroadcastedTxMap() error {
var broadcastedTransactions []clienttypes.OutboundHashSQLType
if err := ob.DB().Client().Find(&broadcastedTransactions).Error; err != nil {
ob.logger.Chain.Error().Err(err).Msgf("error iterating over db for chain %d", ob.Chain().ChainId)
return err
}
for _, entry := range broadcastedTransactions {
ob.broadcastedTx[entry.Key] = entry.Hash
}
return nil
}
106 changes: 106 additions & 0 deletions zetaclient/chains/bitcoin/observer/gas_price.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
package observer

import (
"context"
"fmt"

"github.com/pkg/errors"

"github.com/zeta-chain/node/pkg/chains"
"github.com/zeta-chain/node/zetaclient/chains/bitcoin"
"github.com/zeta-chain/node/zetaclient/chains/bitcoin/rpc"
clienttypes "github.com/zeta-chain/node/zetaclient/types"
)

// WatchGasPrice watches Bitcoin chain for gas rate and post to zetacore
func (ob *Observer) WatchGasPrice(ctx context.Context) error {
// report gas price right away as the ticker takes time to kick in
err := ob.PostGasPrice(ctx)
if err != nil {
ob.logger.GasPrice.Error().Err(err).Msgf("PostGasPrice error for chain %d", ob.Chain().ChainId)
}

// start gas price ticker
ticker, err := clienttypes.NewDynamicTicker("Bitcoin_WatchGasPrice", ob.ChainParams().GasPriceTicker)
if err != nil {
return errors.Wrapf(err, "NewDynamicTicker error")
}
ob.logger.GasPrice.Info().Msgf("WatchGasPrice started for chain %d with interval %d",
ob.Chain().ChainId, ob.ChainParams().GasPriceTicker)

defer ticker.Stop()
for {
select {
case <-ticker.C():
if !ob.ChainParams().IsSupported {
continue
}
err := ob.PostGasPrice(ctx)
if err != nil {
ob.logger.GasPrice.Error().Err(err).Msgf("PostGasPrice error for chain %d", ob.Chain().ChainId)
}
ticker.UpdateInterval(ob.ChainParams().GasPriceTicker, ob.logger.GasPrice)
case <-ob.StopChannel():
ob.logger.GasPrice.Info().Msgf("WatchGasPrice stopped for chain %d", ob.Chain().ChainId)
return nil
}
}
}

// PostGasPrice posts gas price to zetacore
func (ob *Observer) PostGasPrice(ctx context.Context) error {
var (
err error
feeRateEstimated int64
)

// special handle regnet and testnet gas rate
// regnet: RPC 'EstimateSmartFee' is not available
// testnet: RPC 'EstimateSmartFee' returns unreasonable high gas rate
if ob.Chain().NetworkType != chains.NetworkType_mainnet {
feeRateEstimated, err = ob.specialHandleFeeRate()
if err != nil {
return errors.Wrap(err, "unable to execute specialHandleFeeRate")
}
} else {
feeRateEstimated, err = rpc.GetEstimatedFeeRate(ob.btcClient, 1)
if err != nil {
return errors.Wrap(err, "unable to get estimated fee rate")
}
}

// query the current block number
blockNumber, err := ob.btcClient.GetBlockCount()
if err != nil {
return errors.Wrap(err, "GetBlockCount error")
}

// Bitcoin has no concept of priority fee (like eth)
const priorityFee = 0

// #nosec G115 always positive
_, err = ob.ZetacoreClient().
PostVoteGasPrice(ctx, ob.Chain(), uint64(feeRateEstimated), priorityFee, uint64(blockNumber))
if err != nil {
return errors.Wrap(err, "PostVoteGasPrice error")
}

return nil
}

// specialHandleFeeRate handles the fee rate for regnet and testnet
func (ob *Observer) specialHandleFeeRate() (int64, error) {
switch ob.Chain().NetworkType {
case chains.NetworkType_privnet:
// hardcode gas price for regnet
return 1, nil
case chains.NetworkType_testnet:
feeRateEstimated, err := bitcoin.GetRecentFeeRate(ob.btcClient, ob.netParams)
if err != nil {
return 0, errors.Wrapf(err, "error GetRecentFeeRate")
}
return feeRateEstimated, nil
default:
return 0, fmt.Errorf(" unsupported bitcoin network type %d", ob.Chain().NetworkType)
}
}
Loading

0 comments on commit 1ad6628

Please sign in to comment.