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

Enhancement/fee quotes #18

Merged
merged 17 commits into from
Aug 18, 2021
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
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
4 changes: 2 additions & 2 deletions bscript/address.go
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ func NewAddressFromPublicKeyHash(hash []byte, mainnet bool) (*Address, error) {
if !mainnet {
bb[0] = 111
}

// nolint: makezero // we need to setup the array with 1
bb = append(bb, hash...)

return &Address{
Expand All @@ -99,7 +99,7 @@ func NewAddressFromPublicKey(pubKey *bsvec.PublicKey, mainnet bool) (*Address, e
if !mainnet {
bb[0] = 111
}

// nolint: makezero // we need to setup the array with 1
bb = append(bb, hash...)

return &Address{
Expand Down
146 changes: 114 additions & 32 deletions fees.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@ package bt

import (
"errors"
"fmt"
"sync"
"time"
)

// FeeType is used to specify which
Expand All @@ -18,6 +21,113 @@ const (
FeeTypeData FeeType = "data"
)

// FeeQuotes contains a thread safe map of fees for standard and data
// fees as well as an expiry time.
//
// NewFeeQuote() should be called to get a
//
// When expiry expires ie Expired() == true then you should fetch
// new quotes from a MAPI server and call AddQuote with the fee information.
type FeeQuotes struct {
theflyingcodr marked this conversation as resolved.
Show resolved Hide resolved
mu sync.RWMutex
fees map[FeeType]*Fee
expiryTime time.Time
}

// NewFeeQuote will setup and return a new FeeQuotes struct which
// contains default fees when initially setup. You would then pass this
// data structure to a singleton struct via injection for reading.
//
// fq := NewFeeQuote()
//
// The fees have an expiry time which, when initially setup, has an
// expiry of now.UTC. This allows you to check for fq.Expired() and if true
// fetch a new set of fees from a MAPI server. This means the first check
// will always fetch the latest fees. If you want to just use default fees
// always, you can ignore the expired method and simply call the fq.Fee() method.
// https://github.com/bitcoin-sv-specs/brfc-merchantapi#payload
//
// A basic example of usage is shown below:
//
// func Fee(ft bt.FeeType) *bt.Fee{
// // you would not call this every time - this is just an example
// // you'd call this at app startup and store it / pass to a struct
// fq := NewFeeQuote()
//
// // fq setup with defaultFees
// if !fq.Expired() {
// // not expired, just return fee we have cached
// return fe.Fee(ft)
// }
//
// // cache expired, fetch new quotes
// var stdFee *bt.Fee
// var dataFee *bt.Fee
//
// // fetch quotes from MAPI server
//
// fq.AddQuote(bt.FeeTypeStandard, stdFee)
// fq.AddQuote(bt.FeeTypeData, dataFee)
//
// // MAPI returns a quote expiry
// exp, _ := time.Parse(time.RFC3339, resp.Quote.ExpirationTime)
// fq.UpdateExpiry(exp)
// return fe.Fee(ft)
// }
// It will set the expiry time to now.UTC which when expires
// will indicate that new quotes should be fetched from a MAPI server.
func NewFeeQuote() *FeeQuotes {
jadwahab marked this conversation as resolved.
Show resolved Hide resolved
fq := &FeeQuotes{
fees: map[FeeType]*Fee{},
expiryTime: time.Now().UTC(),
mu: sync.RWMutex{},
}
fq.AddQuote(FeeTypeStandard, defaultStandardFee()).
AddQuote(FeeTypeData, defaultDataFee())
return fq
}


// Fee will return a fee by type if found, nil and an error if not.
func (f *FeeQuotes) Fee(t FeeType) (*Fee, error) {
if f == nil{
return nil, errors.New("feeQuotes have not been initialized, call NewFeeQuote()")
}
f.mu.RLock()
defer f.mu.RUnlock()
fee, ok := f.fees[t]
if fee == nil || !ok{
return nil, fmt.Errorf("feetype %s not found", t)
}
return fee, nil
}

// AddQuote will add new set of quotes for a feetype or update an existing
// quote if it already exists.
func (f *FeeQuotes) AddQuote(ft FeeType, fee *Fee) *FeeQuotes {
theflyingcodr marked this conversation as resolved.
Show resolved Hide resolved
f.mu.Lock()
defer f.mu.Unlock()
f.fees[ft] = fee
return f
}

// UpdateExpiry will update the expiry time of the quotes, this will be
// used when you fetch a fresh set of quotes from a MAPI server which
// should return an expiration time.
func (f *FeeQuotes) UpdateExpiry(exp time.Time) {
f.mu.Lock()
defer f.mu.Unlock()
f.expiryTime = exp
}

// Expired will return true if the expiry time is before UTC now, this
// means we need to fetch fresh quotes from a MAPI server.
func (f *FeeQuotes) Expired() bool {
f.mu.Lock()
defer f.mu.Unlock()
return f.expiryTime.Before(time.Now().UTC())
}

// FeeUnit displays the amount of Satoshis needed
// for a specific amount of Bytes in a transaction
// see https://github.com/bitcoin-sv-specs/brfc-misc/tree/master/feespec
Expand All @@ -35,9 +145,9 @@ type Fee struct {
RelayFee FeeUnit `json:"relayFee"` // Fee for retaining Tx in secondary mempool
}

// DefaultStandardFee returns the default
// defaultStandardFee returns the default
// standard fees offered by most miners.
func DefaultStandardFee() *Fee {
func defaultStandardFee() *Fee {
return &Fee{
FeeType: FeeTypeStandard,
MiningFee: FeeUnit{
Expand All @@ -51,9 +161,9 @@ func DefaultStandardFee() *Fee {
}
}

// DefaultDataFee returns the default
// defaultDataFee returns the default
// data fees offered by most miners.
func DefaultDataFee() *Fee {
func defaultDataFee() *Fee {
return &Fee{
FeeType: FeeTypeData,
MiningFee: FeeUnit{
Expand All @@ -66,31 +176,3 @@ func DefaultDataFee() *Fee {
},
}
}

// DefaultFees returns an array of the default
// standard and data fees offered by most miners.
func DefaultFees() (f []*Fee) {
f = append(f, DefaultStandardFee())
f = append(f, DefaultDataFee())
return
}

// ExtractStandardFee returns the standard fee in the fees array supplied.
func ExtractStandardFee(fees []*Fee) (*Fee, error) {
return extractFeeType(FeeTypeStandard, fees)
}

// ExtractDataFee returns the data fee in the fees array supplied.
func ExtractDataFee(fees []*Fee) (*Fee, error) {
return extractFeeType(FeeTypeData, fees)
}

func extractFeeType(ft FeeType, fees []*Fee) (*Fee, error) {
for _, f := range fees {
if f.FeeType == ft {
return f, nil
}
}

return nil, errors.New("no " + string(ft) + " fee supplied")
}
33 changes: 15 additions & 18 deletions fees_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,10 @@ import (

func TestExtractDataFee(t *testing.T) {
t.Run("get valid data fee", func(t *testing.T) {
fees := []*bt.Fee{bt.DefaultDataFee()}
fee, err := bt.ExtractDataFee(fees)
assert.NoError(t, err)
fees := bt.NewFeeQuote()
fee, err := fees.Fee(bt.FeeTypeData)
assert.NotNil(t, fee)
assert.NoError(t, err)
assert.Equal(t, bt.FeeTypeData, fee.FeeType)
assert.Equal(t, 25, fee.MiningFee.Satoshis)
assert.Equal(t, 100, fee.MiningFee.Bytes)
Expand All @@ -21,19 +21,18 @@ func TestExtractDataFee(t *testing.T) {
})

t.Run("no data fee found", func(t *testing.T) {
wrongFee := bt.DefaultDataFee()
wrongFee.FeeType = "unknown"
fees := []*bt.Fee{wrongFee}
fee, err := bt.ExtractDataFee(fees)
assert.Error(t, err)
fees := bt.NewFeeQuote()
fees.AddQuote(bt.FeeTypeData, nil)
fee, err := fees.Fee(bt.FeeTypeData)
assert.Nil(t, fee)
assert.Error(t, err)
})
}

func TestExtractStandardFee(t *testing.T) {
t.Run("get valid standard fee", func(t *testing.T) {
fees := []*bt.Fee{bt.DefaultStandardFee()}
fee, err := bt.ExtractStandardFee(fees)
fees := bt.NewFeeQuote()
fee, err := fees.Fee(bt.FeeTypeStandard)
assert.NoError(t, err)
assert.NotNil(t, fee)
assert.Equal(t, bt.FeeTypeStandard, fee.FeeType)
Expand All @@ -44,25 +43,23 @@ func TestExtractStandardFee(t *testing.T) {
})

t.Run("no standard fee found", func(t *testing.T) {
wrongFee := bt.DefaultStandardFee()
wrongFee.FeeType = "unknown"
fees := []*bt.Fee{wrongFee}
fee, err := bt.ExtractStandardFee(fees)
fees := bt.NewFeeQuote()
fees.AddQuote(bt.FeeTypeStandard, nil)
fee, err := fees.Fee(bt.FeeTypeStandard)
assert.Error(t, err)
assert.Nil(t, fee)
})
}

func TestDefaultFees(t *testing.T) {
fees := bt.DefaultFees()
assert.Equal(t, 2, len(fees))
fees := bt.NewFeeQuote()

fee, err := bt.ExtractDataFee(fees)
fee, err := fees.Fee( bt.FeeTypeData)
assert.NoError(t, err)
assert.NotNil(t, fee)
assert.Equal(t, bt.FeeTypeData, fee.FeeType)

fee, err = bt.ExtractStandardFee(fees)
fee, err = fees.Fee( bt.FeeTypeStandard)
assert.NoError(t, err)
assert.NotNil(t, fee)
assert.Equal(t, bt.FeeTypeStandard, fee.FeeType)
Expand Down
45 changes: 18 additions & 27 deletions txchange.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import (

// ChangeToAddress calculates the amount of fees needed to cover the transaction
// and adds the left over change in a new P2PKH output using the address provided.
func (tx *Tx) ChangeToAddress(addr string, f []*Fee) error {
func (tx *Tx) ChangeToAddress(addr string, f *FeeQuotes) error {
s, err := bscript.NewP2PKHFromAddress(addr)
if err != nil {
return err
Expand All @@ -19,8 +19,7 @@ func (tx *Tx) ChangeToAddress(addr string, f []*Fee) error {

// Change calculates the amount of fees needed to cover the transaction
// and adds the left over change in a new output using the script provided.
func (tx *Tx) Change(s *bscript.Script, f []*Fee) error {

func (tx *Tx) Change(s *bscript.Script, f *FeeQuotes) error {
inputAmount := tx.TotalInputSatoshis()
outputAmount := tx.TotalOutputSatoshis()

Expand All @@ -30,24 +29,22 @@ func (tx *Tx) Change(s *bscript.Script, f []*Fee) error {

available := inputAmount - outputAmount

standardFees, err := ExtractStandardFee(f)
if err != nil {
return err
standardFees, err := f.Fee(FeeTypeStandard)
if err != nil{
return errors.New("standard fees not found")
}

if !tx.canAddChange(available, standardFees) {
return nil
}

tx.AddOutput(&Output{Satoshis: 0, LockingScript: s})

var preSignedFeeRequired uint64
if preSignedFeeRequired, err = tx.getPreSignedFeeRequired(f); err != nil {
preSignedFeeRequired, err := tx.getPreSignedFeeRequired(f)
if err != nil {
return err
}

var expectedUnlockingScriptFees uint64
if expectedUnlockingScriptFees, err = tx.getExpectedUnlockingScriptFees(f); err != nil {
expectedUnlockingScriptFees, err := tx.getExpectedUnlockingScriptFees(f)
if err != nil {
return err
}

Expand Down Expand Up @@ -75,19 +72,17 @@ func (tx *Tx) canAddChange(available uint64, standardFees *Fee) bool {
return available >= changeOutputFee
}

func (tx *Tx) getPreSignedFeeRequired(f []*Fee) (uint64, error) {

func (tx *Tx) getPreSignedFeeRequired(f *FeeQuotes) (uint64, error) {
standardBytes, dataBytes := tx.getStandardAndDataBytes()

standardFee, err := ExtractStandardFee(f)
if err != nil {
standardFee, err := f.Fee(FeeTypeStandard)
if err != nil{
return 0, err
}

fr := standardBytes * standardFee.MiningFee.Satoshis / standardFee.MiningFee.Bytes

var dataFee *Fee
if dataFee, err = ExtractDataFee(f); err != nil {
dataFee, err := f.Fee(FeeTypeData)
if err != nil{
return 0, err
}

Expand All @@ -96,22 +91,18 @@ func (tx *Tx) getPreSignedFeeRequired(f []*Fee) (uint64, error) {
return uint64(fr), nil
}

func (tx *Tx) getExpectedUnlockingScriptFees(f []*Fee) (uint64, error) {

standardFee, err := ExtractStandardFee(f)
if err != nil {
return 0, err
func (tx *Tx) getExpectedUnlockingScriptFees(f *FeeQuotes) (uint64, error) {
standardFee, err := f.Fee(FeeTypeStandard)
if err != nil{
return 0, errors.New("standard fee not found")
}

var expectedBytes int

for _, in := range tx.Inputs {
if !in.PreviousTxScript.IsP2PKH() {
return 0, errors.New("non-P2PKH input used in the tx - unsupported")
}
expectedBytes += 109 // = 1 oppushdata + 70-73 sig + 1 sighash + 1 oppushdata + 33 public key
}

return uint64(expectedBytes * standardFee.MiningFee.Satoshis / standardFee.MiningFee.Bytes), nil
}

Expand Down
Loading