Skip to content

Commit

Permalink
Add mempool.space alternative blockchain fees provider
Browse files Browse the repository at this point in the history
  • Loading branch information
martinboehm committed Feb 13, 2024
1 parent 0ed95dc commit 5f47f3c
Show file tree
Hide file tree
Showing 6 changed files with 329 additions and 85 deletions.
68 changes: 68 additions & 0 deletions bchain/coins/btc/alternativefeeprovider.go
@@ -0,0 +1,68 @@
package btc

import (
"fmt"
"math/big"
"sync"
"time"

"github.com/golang/glog"
"github.com/juju/errors"
"github.com/trezor/blockbook/bchain"
)

type alternativeFeeProviderFee struct {
blocks int
feePerKB int
}

type alternativeFeeProvider struct {
fees []alternativeFeeProviderFee
lastSync time.Time
chain bchain.BlockChain
mux sync.Mutex
}

type alternativeFeeProviderInterface interface {
compareToDefault()
estimateFee(blocks int) (big.Int, error)
}

func (p *alternativeFeeProvider) compareToDefault() {
output := ""
for _, fee := range p.fees {
conservative, err := p.chain.(*BitcoinRPC).blockchainEstimateSmartFee(fee.blocks, true)
if err != nil {
glog.Error(err)
return
}
economical, err := p.chain.(*BitcoinRPC).blockchainEstimateSmartFee(fee.blocks, false)
if err != nil {
glog.Error(err)
return
}
output += fmt.Sprintf("Blocks %d: alternative %d, conservative %s, economical %s\n", fee.blocks, fee.feePerKB, conservative.String(), economical.String())
}
glog.Info("alternativeFeeProviderCompareToDefault\n", output)
}

func (p *alternativeFeeProvider) estimateFee(blocks int) (big.Int, error) {
var r big.Int
p.mux.Lock()
defer p.mux.Unlock()
if len(p.fees) == 0 {
return r, errors.New("alternativeFeeProvider: no fees")
}
if p.lastSync.Before(time.Now().Add(time.Duration(-10) * time.Minute)) {
return r, errors.Errorf("alternativeFeeProvider: Missing recent value, last sync at %v", p.lastSync)
}
for i := range p.fees {
if p.fees[i].blocks >= blocks {
r = *big.NewInt(int64(p.fees[i].feePerKB))
return r, nil
}
}
// use the last value as fallback
r = *big.NewInt(int64(p.fees[len(p.fees)-1].feePerKB))
return r, nil
}
69 changes: 48 additions & 21 deletions bchain/coins/btc/bitcoinrpc.go
Expand Up @@ -22,19 +22,20 @@ import (
// BitcoinRPC is an interface to JSON-RPC bitcoind service.
type BitcoinRPC struct {
*bchain.BaseChain
client http.Client
rpcURL string
user string
password string
Mempool *bchain.MempoolBitcoinType
ParseBlocks bool
pushHandler func(bchain.NotificationType)
mq *bchain.MQ
ChainConfig *Configuration
RPCMarshaler RPCMarshaler
mempoolGolombFilterP uint8
mempoolFilterScripts string
mempoolUseZeroedKey bool
client http.Client
rpcURL string
user string
password string
Mempool *bchain.MempoolBitcoinType
ParseBlocks bool
pushHandler func(bchain.NotificationType)
mq *bchain.MQ
ChainConfig *Configuration
RPCMarshaler RPCMarshaler
mempoolGolombFilterP uint8
mempoolFilterScripts string
mempoolUseZeroedKey bool
alternativeFeeProvider alternativeFeeProviderInterface
}

// Configuration represents json config file
Expand Down Expand Up @@ -145,10 +146,16 @@ func (b *BitcoinRPC) Initialize() error {
glog.Info("rpc: block chain ", params.Name)

if b.ChainConfig.AlternativeEstimateFee == "whatthefee" {
if err = InitWhatTheFee(b, b.ChainConfig.AlternativeEstimateFeeParams); err != nil {
glog.Error("InitWhatTheFee error ", err, " Reverting to default estimateFee functionality")
if b.alternativeFeeProvider, err = NewWhatTheFee(b, b.ChainConfig.AlternativeEstimateFeeParams); err != nil {
glog.Error("NewWhatTheFee error ", err, " Reverting to default estimateFee functionality")
// disable AlternativeEstimateFee logic
b.ChainConfig.AlternativeEstimateFee = ""
b.alternativeFeeProvider = nil
}
} else if b.ChainConfig.AlternativeEstimateFee == "mempoolspace" {
if b.alternativeFeeProvider, err = NewMempoolSpaceFee(b, b.ChainConfig.AlternativeEstimateFeeParams); err != nil {
glog.Error("MempoolSpaceFee error ", err, " Reverting to default estimateFee functionality")
// disable AlternativeEstimateFee logic
b.alternativeFeeProvider = nil
}
}

Expand Down Expand Up @@ -774,8 +781,7 @@ func (b *BitcoinRPC) getRawTransaction(txid string) (json.RawMessage, error) {
return res.Result, nil
}

// EstimateSmartFee returns fee estimation
func (b *BitcoinRPC) EstimateSmartFee(blocks int, conservative bool) (big.Int, error) {
func (b *BitcoinRPC) blockchainEstimateSmartFee(blocks int, conservative bool) (big.Int, error) {
// use EstimateFee if EstimateSmartFee is not supported
if !b.ChainConfig.SupportsEstimateSmartFee && b.ChainConfig.SupportsEstimateFee {
return b.EstimateFee(blocks)
Expand All @@ -792,7 +798,6 @@ func (b *BitcoinRPC) EstimateSmartFee(blocks int, conservative bool) (big.Int, e
req.Params.EstimateMode = "ECONOMICAL"
}
err := b.Call(&req, &res)

var r big.Int
if err != nil {
return r, err
Expand All @@ -807,8 +812,31 @@ func (b *BitcoinRPC) EstimateSmartFee(blocks int, conservative bool) (big.Int, e
return r, nil
}

// EstimateSmartFee returns fee estimation
func (b *BitcoinRPC) EstimateSmartFee(blocks int, conservative bool) (big.Int, error) {
// use alternative estimator if enabled
if b.alternativeFeeProvider != nil {
r, err := b.alternativeFeeProvider.estimateFee(blocks)
// in case of error, fallback to default estimator
if err == nil {
return r, nil
}
}
return b.blockchainEstimateSmartFee(blocks, conservative)
}

// EstimateFee returns fee estimation.
func (b *BitcoinRPC) EstimateFee(blocks int) (big.Int, error) {
var r big.Int
var err error
// use alternative estimator if enabled
if b.alternativeFeeProvider != nil {
r, err = b.alternativeFeeProvider.estimateFee(blocks)
// in case of error, fallback to default estimator
if err == nil {
return r, nil
}
}
// use EstimateSmartFee if EstimateFee is not supported
if !b.ChainConfig.SupportsEstimateFee && b.ChainConfig.SupportsEstimateSmartFee {
return b.EstimateSmartFee(blocks, true)
Expand All @@ -819,9 +847,8 @@ func (b *BitcoinRPC) EstimateFee(blocks int) (big.Int, error) {
res := ResEstimateFee{}
req := CmdEstimateFee{Method: "estimatefee"}
req.Params.Blocks = blocks
err := b.Call(&req, &res)
err = b.Call(&req, &res)

var r big.Int
if err != nil {
return r, err
}
Expand Down
135 changes: 135 additions & 0 deletions bchain/coins/btc/mempoolspace.go
@@ -0,0 +1,135 @@
package btc

import (
"bytes"
"encoding/json"
"net/http"
"strconv"
"time"

"github.com/golang/glog"
"github.com/juju/errors"
"github.com/trezor/blockbook/bchain"
)

// https://mempool.space/api/v1/fees/recommended returns
// {"fastestFee":41,"halfHourFee":39,"hourFee":36,"economyFee":36,"minimumFee":20}

type mempoolSpaceFeeResult struct {
FastestFee int `json:"fastestFee"`
HalfHourFee int `json:"halfHourFee"`
HourFee int `json:"hourFee"`
EconomyFee int `json:"economyFee"`
MinimumFee int `json:"minimumFee"`
}

type mempoolSpaceFeeParams struct {
URL string `json:"url"`
PeriodSeconds int `periodSeconds:"url"`
}

type mempoolSpaceFeeProvider struct {
*alternativeFeeProvider
params mempoolSpaceFeeParams
}

// NewMempoolSpaceFee initializes https://mempool.space provider
func NewMempoolSpaceFee(chain bchain.BlockChain, params string) (alternativeFeeProviderInterface, error) {
p := &mempoolSpaceFeeProvider{alternativeFeeProvider: &alternativeFeeProvider{}}
err := json.Unmarshal([]byte(params), &p.params)
if err != nil {
return nil, err
}
if p.params.URL == "" || p.params.PeriodSeconds == 0 {
return nil, errors.New("NewWhatTheFee: Missing parameters")
}
p.chain = chain
go p.mempoolSpaceFeeDownloader()
return p, nil
}

func (p *mempoolSpaceFeeProvider) mempoolSpaceFeeDownloader() {
period := time.Duration(p.params.PeriodSeconds) * time.Second
timer := time.NewTimer(period)
counter := 0
for {
var data mempoolSpaceFeeResult
err := p.mempoolSpaceFeeGetData(&data)
if err != nil {
glog.Error("mempoolSpaceFeeGetData ", err)
} else {
if p.mempoolSpaceFeeProcessData(&data) {
if counter%60 == 0 {
p.compareToDefault()
}
counter++
}
}
<-timer.C
timer.Reset(period)
}
}

func (p *mempoolSpaceFeeProvider) mempoolSpaceFeeProcessData(data *mempoolSpaceFeeResult) bool {
if data.MinimumFee == 0 || data.EconomyFee == 0 || data.HourFee == 0 || data.HalfHourFee == 0 || data.FastestFee == 0 {
glog.Errorf("mempoolSpaceFeeProcessData: invalid data %+v", data)
return false
}
p.mux.Lock()
defer p.mux.Unlock()
p.fees = make([]alternativeFeeProviderFee, 5)
// map mempoool.space fees to blocks

// FastestFee is for 1 block
p.fees[0] = alternativeFeeProviderFee{
blocks: 1,
feePerKB: data.FastestFee * 1000,
}

// HalfHourFee is for 2-5 blocks
p.fees[1] = alternativeFeeProviderFee{
blocks: 5,
feePerKB: data.HalfHourFee * 1000,
}

// HourFee is for 6-18 blocks
p.fees[2] = alternativeFeeProviderFee{
blocks: 18,
feePerKB: data.HourFee * 1000,
}

// EconomyFee is for 19-100 blocks
p.fees[3] = alternativeFeeProviderFee{
blocks: 100,
feePerKB: data.EconomyFee * 1000,
}

// MinimumFee is for over 100 blocks
p.fees[4] = alternativeFeeProviderFee{
blocks: 500,
feePerKB: data.MinimumFee * 1000,
}

p.lastSync = time.Now()
// glog.Infof("mempoolSpaceFees: %+v", p.fees)
return true
}

func (p *mempoolSpaceFeeProvider) mempoolSpaceFeeGetData(res interface{}) error {
var httpData []byte
httpReq, err := http.NewRequest("GET", p.params.URL, bytes.NewBuffer(httpData))
if err != nil {
return err
}
httpRes, err := http.DefaultClient.Do(httpReq)
if httpRes != nil {
defer httpRes.Body.Close()
}
if err != nil {
return err
}
if httpRes.StatusCode != http.StatusOK {
return errors.New(p.params.URL + " returned status " + strconv.Itoa(httpRes.StatusCode))
}
return safeDecodeResponse(httpRes.Body, &res)
}
47 changes: 47 additions & 0 deletions bchain/coins/btc/mempoolspace_test.go
@@ -0,0 +1,47 @@
package btc

import (
"math/big"
"strconv"
"testing"
)

func Test_mempoolSpaceFeeProvider(t *testing.T) {
m := &mempoolSpaceFeeProvider{alternativeFeeProvider: &alternativeFeeProvider{}}
m.mempoolSpaceFeeProcessData(&mempoolSpaceFeeResult{
MinimumFee: 10,
EconomyFee: 20,
HourFee: 30,
HalfHourFee: 40,
FastestFee: 50,
})

tests := []struct {
blocks int
want big.Int
}{
{0, *big.NewInt(50000)},
{1, *big.NewInt(50000)},
{2, *big.NewInt(40000)},
{5, *big.NewInt(40000)},
{6, *big.NewInt(30000)},
{10, *big.NewInt(30000)},
{18, *big.NewInt(30000)},
{19, *big.NewInt(20000)},
{100, *big.NewInt(20000)},
{101, *big.NewInt(10000)},
{500, *big.NewInt(10000)},
{5000000, *big.NewInt(10000)},
}
for _, tt := range tests {
t.Run(strconv.Itoa(tt.blocks), func(t *testing.T) {
got, err := m.estimateFee(tt.blocks)
if err != nil {
t.Error("estimateFee returned error ", err)
}
if got.Cmp(&tt.want) != 0 {
t.Errorf("estimateFee(%d) = %v, want %v", tt.blocks, got, tt.want)
}
})
}
}

0 comments on commit 5f47f3c

Please sign in to comment.