Skip to content

Commit

Permalink
feat: bitcoin depositor fee V2 to achieve better dev experience (#2765)
Browse files Browse the repository at this point in the history
* implementation of bitcoin depositor fee V2

* add changelog entry

* attached tracking issue# for TODO; formatted comments to be go idiomatic

* disable live test in CI

* group transaction size related constants to make comments more concise

* reorder bitcoin fee related constants

* replace GetRecentBlockhash with GetLatestBlockhash; add check on Vsize; misc
  • Loading branch information
ws4charlie authored Aug 27, 2024
1 parent c3d1929 commit 36370df
Show file tree
Hide file tree
Showing 10 changed files with 400 additions and 212 deletions.
1 change: 1 addition & 0 deletions changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@
* [2524](https://github.com/zeta-chain/node/pull/2524) - add inscription envelop parsing
* [2560](https://github.com/zeta-chain/node/pull/2560) - add support for Solana SOL token withdraw
* [2533](https://github.com/zeta-chain/node/pull/2533) - parse memo from both OP_RETURN and inscription
* [2765](https://github.com/zeta-chain/node/pull/2765) - bitcoin depositor fee improvement

### Refactor

Expand Down
2 changes: 1 addition & 1 deletion e2e/runner/solana.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ func (r *E2ERunner) CreateSignedTransaction(
privateKey solana.PrivateKey,
) *solana.Transaction {
// get a recent blockhash
recent, err := r.SolanaClient.GetRecentBlockhash(r.Ctx, rpc.CommitmentFinalized)
recent, err := r.SolanaClient.GetLatestBlockhash(r.Ctx, rpc.CommitmentFinalized)
require.NoError(r, err)

// create the initialize transaction
Expand Down
119 changes: 102 additions & 17 deletions zetaclient/chains/bitcoin/fee.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,27 +15,42 @@ import (
"github.com/rs/zerolog"

"github.com/zeta-chain/zetacore/pkg/chains"
"github.com/zeta-chain/zetacore/zetaclient/chains/bitcoin/rpc"
"github.com/zeta-chain/zetacore/zetaclient/chains/interfaces"
clientcommon "github.com/zeta-chain/zetacore/zetaclient/common"
)

const (
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
defaultDepositorFeeRate = 20 // 20 sat/byte is the default depositor fee rate

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

DynamicDepositorFeeHeight = 834500 // DynamicDepositorFeeHeight contains the starting height (Bitcoin mainnet) from which dynamic depositor fee will take effect
// 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

// defaultDepositorFeeRate is the default fee rate for depositor fee, 20 sat/vB
defaultDepositorFeeRate = 20

// defaultTestnetFeeRate is the default fee rate for testnet, 10 sat/vB
defaultTestnetFeeRate = 10

// feeRateCountBackBlocks is the default number of blocks to look back for fee rate estimation
feeRateCountBackBlocks = 2

// DynamicDepositorFeeHeight is the mainnet height from which dynamic depositor fee V1 is applied
DynamicDepositorFeeHeight = 834500

// DynamicDepositorFeeHeightV2 is the mainnet height from which dynamic depositor fee V2 is applied
// Height 863400 is approximately a month away (2024-09-28) from the time of writing, allowing enough time for the upgrade
DynamicDepositorFeeHeightV2 = 863400
)

var (
Expand Down Expand Up @@ -239,3 +254,73 @@ func CalcDepositorFee(

return DepositorFee(feeRate)
}

// CalcDepositorFeeV2 calculates the depositor fee for a given tx result
func CalcDepositorFeeV2(
rpcClient interfaces.BTCRPCClient,
rawResult *btcjson.TxRawResult,
netParams *chaincfg.Params,
) (float64, error) {
// use default fee for regnet
if netParams.Name == chaincfg.RegressionNetParams.Name {
return DefaultDepositorFee, nil
}

// get fee rate of the transaction
_, feeRate, err := rpc.GetTransactionFeeAndRate(rpcClient, rawResult)
if err != nil {
return 0, errors.Wrapf(err, "error getting fee rate for tx %s", rawResult.Txid)
}

// apply gas price multiplier
// #nosec G115 always in range
feeRate = int64(float64(feeRate) * clientcommon.BTCOutboundGasPriceMultiplier)

return DepositorFee(feeRate), nil
}

// 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) {
// should avoid using this method for mainnet
if netParams.Name == chaincfg.MainNetParams.Name {
return 0, errors.New("GetRecentFeeRate should not be used for mainnet")
}

// get the current block number
blockNumber, err := rpcClient.GetBlockCount()
if err != nil {
return 0, err
}

// get the highest fee rate among recent 'countBack' blocks to avoid underestimation
highestRate := int64(0)
for i := int64(0); i < feeRateCountBackBlocks; i++ {
// get the block
hash, err := rpcClient.GetBlockHash(blockNumber - i)
if err != nil {
return 0, err
}
block, err := rpcClient.GetBlockVerboseTx(hash)
if err != nil {
return 0, err
}

// computes the average fee rate of the block and take the higher rate
avgFeeRate, err := CalcBlockAvgFeeRate(block, netParams)
if err != nil {
return 0, err
}
if avgFeeRate > highestRate {
highestRate = avgFeeRate
}
}

// use 10 sat/byte as default estimation if recent fee rate drops to 0
if highestRate == 0 {
highestRate = defaultTestnetFeeRate
}

// #nosec G115 always in range
return uint64(highestRate), nil
}
13 changes: 13 additions & 0 deletions zetaclient/chains/bitcoin/observer/inbound.go
Original file line number Diff line number Diff line change
Expand Up @@ -421,6 +421,19 @@ func GetBtcEvent(
return nil, nil
}

// switch to depositor fee V2 if
// 1. it is bitcoin testnet, or
// 2. it is bitcoin mainnet and upgrade height is reached
// TODO: remove CalcDepositorFeeV1 and below conditions after the upgrade height
// https://github.com/zeta-chain/node/issues/2766
if netParams.Name == chaincfg.TestNet3Params.Name ||
(netParams.Name == chaincfg.MainNetParams.Name && blockNumber >= bitcoin.DynamicDepositorFeeHeightV2) {
depositorFee, err = bitcoin.CalcDepositorFeeV2(rpcClient, &tx, netParams)
if err != nil {
return nil, errors.Wrapf(err, "error calculating depositor fee V2 for inbound: %s", tx.Txid)
}
}

// deposit amount has to be no less than the minimum depositor fee
if vout0.Value < depositorFee {
logger.Info().
Expand Down
3 changes: 1 addition & 2 deletions zetaclient/chains/bitcoin/observer/observer.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,6 @@ import (
observertypes "github.com/zeta-chain/zetacore/x/observer/types"
"github.com/zeta-chain/zetacore/zetaclient/chains/base"
"github.com/zeta-chain/zetacore/zetaclient/chains/bitcoin"
"github.com/zeta-chain/zetacore/zetaclient/chains/bitcoin/rpc"
"github.com/zeta-chain/zetacore/zetaclient/chains/interfaces"
"github.com/zeta-chain/zetacore/zetaclient/db"
"github.com/zeta-chain/zetacore/zetaclient/metrics"
Expand Down Expand Up @@ -628,7 +627,7 @@ func (ob *Observer) specialHandleFeeRate() (uint64, error) {
// hardcode gas price for regnet
return 1, nil
case chains.NetworkType_testnet:
feeRateEstimated, err := rpc.GetRecentFeeRate(ob.btcClient, ob.netParams)
feeRateEstimated, err := bitcoin.GetRecentFeeRate(ob.btcClient, ob.netParams)
if err != nil {
return 0, errors.Wrapf(err, "error GetRecentFeeRate")
}
Expand Down
92 changes: 54 additions & 38 deletions zetaclient/chains/bitcoin/rpc/rpc.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,24 +4,15 @@ import (
"fmt"

"github.com/btcsuite/btcd/btcjson"
"github.com/btcsuite/btcd/chaincfg"
"github.com/btcsuite/btcd/chaincfg/chainhash"
"github.com/btcsuite/btcd/rpcclient"
"github.com/btcsuite/btcutil"
"github.com/pkg/errors"

"github.com/zeta-chain/zetacore/zetaclient/chains/bitcoin"
"github.com/zeta-chain/zetacore/zetaclient/chains/interfaces"
"github.com/zeta-chain/zetacore/zetaclient/config"
)

const (
// feeRateCountBackBlocks is the default number of blocks to look back for fee rate estimation
feeRateCountBackBlocks = 2

// defaultTestnetFeeRate is the default fee rate for testnet, 10 sat/byte
defaultTestnetFeeRate = 10
)

// NewRPCClient creates a new RPC client by the given config.
func NewRPCClient(btcConfig config.BTCConfig) (*rpcclient.Client, error) {
connCfg := &rpcclient.ConnConfig{
Expand Down Expand Up @@ -63,6 +54,20 @@ func GetTxResultByHash(
return hash, txResult, nil
}

// GetTXRawResultByHash gets the raw transaction by hash
func GetRawTxByHash(rpcClient interfaces.BTCRPCClient, txID string) (*btcutil.Tx, error) {
hash, err := chainhash.NewHashFromStr(txID)
if err != nil {
return nil, errors.Wrapf(err, "GetRawTxByHash: error NewHashFromStr: %s", txID)
}

tx, err := rpcClient.GetRawTransaction(hash)
if err != nil {
return nil, errors.Wrapf(err, "GetRawTxByHash: error GetRawTransaction %s", txID)
}
return tx, nil
}

// GetBlockHeightByHash gets the block height by block hash
func GetBlockHeightByHash(
rpcClient interfaces.BTCRPCClient,
Expand Down Expand Up @@ -118,42 +123,53 @@ func GetRawTxResult(
return btcjson.TxRawResult{}, fmt.Errorf("GetRawTxResult: tx %s not included yet", hash)
}

// GetRecentFeeRate gets the highest fee rate from recent blocks
// Note: this method is only used for testnet
func GetRecentFeeRate(rpcClient interfaces.BTCRPCClient, netParams *chaincfg.Params) (uint64, error) {
blockNumber, err := rpcClient.GetBlockCount()
if err != nil {
return 0, err
// GetTransactionFeeAndRate gets the transaction fee and rate for a given tx result
func GetTransactionFeeAndRate(rpcClient interfaces.BTCRPCClient, rawResult *btcjson.TxRawResult) (int64, int64, error) {
var (
totalInputValue int64
totalOutputValue int64
)

// make sure the tx Vsize is not zero (should not happen)
if rawResult.Vsize <= 0 {
return 0, 0, fmt.Errorf("tx %s has non-positive Vsize: %d", rawResult.Txid, rawResult.Vsize)
}

// get the highest fee rate among recent 'countBack' blocks to avoid underestimation
highestRate := int64(0)
for i := int64(0); i < feeRateCountBackBlocks; i++ {
// get the block
hash, err := rpcClient.GetBlockHash(blockNumber - i)
if err != nil {
return 0, err
}
block, err := rpcClient.GetBlockVerboseTx(hash)
// sum up total input value
for _, vin := range rawResult.Vin {
prevTx, err := GetRawTxByHash(rpcClient, vin.Txid)
if err != nil {
return 0, err
return 0, 0, errors.Wrapf(err, "failed to get previous tx: %s", vin.Txid)
}
totalInputValue += prevTx.MsgTx().TxOut[vin.Vout].Value
}

// computes the average fee rate of the block and take the higher rate
avgFeeRate, err := bitcoin.CalcBlockAvgFeeRate(block, netParams)
if err != nil {
return 0, err
}
if avgFeeRate > highestRate {
highestRate = avgFeeRate
}
// query the raw tx
tx, err := GetRawTxByHash(rpcClient, rawResult.Txid)
if err != nil {
return 0, 0, errors.Wrapf(err, "failed to get tx: %s", rawResult.Txid)
}

// use 10 sat/byte as default estimation if recent fee rate drops to 0
if highestRate == 0 {
highestRate = defaultTestnetFeeRate
// sum up total output value
for _, vout := range tx.MsgTx().TxOut {
totalOutputValue += vout.Value
}

// calculate the transaction fee in satoshis
fee := totalInputValue - totalOutputValue
if fee < 0 { // never happens
return 0, 0, fmt.Errorf("got negative fee: %d", fee)
}

// Note: the calculation uses 'Vsize' returned by RPC to simplify dev experience:
// - 1. the devs could use the same value returned by their RPC endpoints to estimate deposit fee.
// - 2. the devs don't have to bother 'Vsize' calculation, even though there is more accurate formula.
// Moreoever, the accurate 'Vsize' is usually an adjusted size (float value) by Bitcoin Core.
// - 3. the 'Vsize' calculation could depend on program language and the library used.
//
// calculate the fee rate in satoshis/vByte
// #nosec G115 always in range
return uint64(highestRate), nil
feeRate := fee / int64(rawResult.Vsize)

return fee, feeRate, nil
}
Loading

0 comments on commit 36370df

Please sign in to comment.