From c03795a5082c1ff5be4809622ef58bed633747e2 Mon Sep 17 00:00:00 2001 From: Mark Smith Date: Tue, 13 Apr 2021 12:02:10 +0100 Subject: [PATCH 01/12] adding new FeeQuotes to simplify handling of fees --- bscript/address.go | 4 +- fees.go | 146 +++++++++++++++++++++++++++++++++++---------- fees_test.go | 33 +++++----- txchange.go | 45 ++++++-------- txchange_test.go | 16 ++--- txsign_test.go | 4 +- 6 files changed, 159 insertions(+), 89 deletions(-) diff --git a/bscript/address.go b/bscript/address.go index 701395d4..78a416bd 100644 --- a/bscript/address.go +++ b/bscript/address.go @@ -77,7 +77,7 @@ func NewAddressFromPublicKeyHash(hash []byte, mainnet bool) (*Address, error) { if !mainnet { bb[0] = 111 } - + // nolint: makezero // because is why bb = append(bb, hash...) return &Address{ @@ -99,7 +99,7 @@ func NewAddressFromPublicKey(pubKey *bsvec.PublicKey, mainnet bool) (*Address, e if !mainnet { bb[0] = 111 } - + // nolint: makezero // because is why bb = append(bb, hash...) return &Address{ diff --git a/fees.go b/fees.go index eab55756..821a7ae5 100644 --- a/fees.go +++ b/fees.go @@ -2,6 +2,9 @@ package bt import ( "errors" + "fmt" + "sync" + "time" ) // FeeType is used to specify which @@ -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 { + 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 { + 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 { + 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 @@ -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{ @@ -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{ @@ -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") -} diff --git a/fees_test.go b/fees_test.go index 3d0e379f..50f0cfaf 100644 --- a/fees_test.go +++ b/fees_test.go @@ -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) @@ -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) @@ -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) diff --git a/txchange.go b/txchange.go index 6778dbd1..1c681b59 100644 --- a/txchange.go +++ b/txchange.go @@ -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 @@ -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() @@ -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 } @@ -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 } @@ -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 } diff --git a/txchange_test.go b/txchange_test.go index 2431c70b..e249f1c8 100644 --- a/txchange_test.go +++ b/txchange_test.go @@ -47,7 +47,7 @@ func TestTx_ChangeToAddress(t *testing.T) { 4000000) assert.NoError(t, err) - err = tx.ChangeToAddress("1GHMW7ABrFma2NSwiVe9b9bZxkMB7tuPZi", bt.DefaultFees()) + err = tx.ChangeToAddress("1GHMW7ABrFma2NSwiVe9b9bZxkMB7tuPZi", bt.NewFeeQuote()) assert.NoError(t, err) assert.Equal(t, 1, tx.OutputCount()) @@ -73,7 +73,7 @@ func TestTx_Change(t *testing.T) { 4000000) assert.NoError(t, err) - err = tx.ChangeToAddress("mwV3YgnowbJJB3LcyCuqiKpdivvNNFiK7M", bt.DefaultFees()) + err = tx.ChangeToAddress("mwV3YgnowbJJB3LcyCuqiKpdivvNNFiK7M", bt.NewFeeQuote()) assert.NoError(t, err) var wif *bsvutil.WIF @@ -99,7 +99,7 @@ func TestTx_Change(t *testing.T) { 4000000) assert.NoError(t, err) - err = tx.ChangeToAddress("mwV3YgnowbJJB3LcyCuqiKpdivvNNFiK7M", bt.DefaultFees()) + err = tx.ChangeToAddress("mwV3YgnowbJJB3LcyCuqiKpdivvNNFiK7M", bt.NewFeeQuote()) assert.NoError(t, err) var wif *bsvutil.WIF @@ -141,7 +141,7 @@ func TestTx_Change(t *testing.T) { err = tx.AddOpReturnPartsOutput([][]byte{[]byte("hi"), []byte("how"), []byte("are"), []byte("you")}) assert.NoError(t, err) - err = tx.ChangeToAddress("1D7gaZJo3vPn2Ks3PH694W9P8UVYLNh2jY", bt.DefaultFees()) + err = tx.ChangeToAddress("1D7gaZJo3vPn2Ks3PH694W9P8UVYLNh2jY", bt.NewFeeQuote()) assert.NoError(t, err) var wif *bsvutil.WIF @@ -178,7 +178,7 @@ func TestTx_Change(t *testing.T) { 4000000) assert.NoError(t, err) - err = tx.ChangeToAddress("mwV3YgnowbJJB3LcyCuqiKpdivvNNFiK7M", bt.DefaultFees()) + err = tx.ChangeToAddress("mwV3YgnowbJJB3LcyCuqiKpdivvNNFiK7M", bt.NewFeeQuote()) assert.NoError(t, err) var wif *bsvutil.WIF @@ -211,7 +211,7 @@ func TestTx_Change(t *testing.T) { err = tx.PayTo("mxAoAyZFXX6LZBWhoam3vjm6xt9NxPQ15f", 3000000) assert.NoError(t, err) - err = tx.ChangeToAddress("mwV3YgnowbJJB3LcyCuqiKpdivvNNFiK7M", bt.DefaultFees()) + err = tx.ChangeToAddress("mwV3YgnowbJJB3LcyCuqiKpdivvNNFiK7M", bt.NewFeeQuote()) assert.NoError(t, err) var wif *bsvutil.WIF @@ -245,7 +245,7 @@ func TestTx_Change(t *testing.T) { err = tx.PayTo("mxAoAyZFXX6LZBWhoam3vjm6xt9NxPQ15f", 3000000) assert.NoError(t, err) - err = tx.ChangeToAddress("mwV3YgnowbJJB3LcyCuqiKpdivvNNFiK7M", bt.DefaultFees()) + err = tx.ChangeToAddress("mwV3YgnowbJJB3LcyCuqiKpdivvNNFiK7M", bt.NewFeeQuote()) assert.NoError(t, err) var wif *bsvutil.WIF @@ -282,7 +282,7 @@ func TestTx_Change(t *testing.T) { 5689) assert.NoError(t, err) - err = tx.ChangeToAddress("1BxGFoRPSFgYxoAStEncL6HuELqPkV3JVj", bt.DefaultFees()) + err = tx.ChangeToAddress("1BxGFoRPSFgYxoAStEncL6HuELqPkV3JVj", bt.NewFeeQuote()) assert.NoError(t, err) var wif *bsvutil.WIF diff --git a/txsign_test.go b/txsign_test.go index 36b62010..0800115b 100644 --- a/txsign_test.go +++ b/txsign_test.go @@ -26,7 +26,7 @@ func TestTx_SignAuto(t *testing.T) { 4000000) assert.NoError(t, err) - err = tx.ChangeToAddress("mwV3YgnowbJJB3LcyCuqiKpdivvNNFiK7M", bt.DefaultFees()) + err = tx.ChangeToAddress("mwV3YgnowbJJB3LcyCuqiKpdivvNNFiK7M", bt.NewFeeQuote()) assert.NoError(t, err) var wif *bsvutil.WIF @@ -65,7 +65,7 @@ func TestTx_SignAuto(t *testing.T) { 4000000) assert.NoError(t, err) - err = tx.ChangeToAddress("mwV3YgnowbJJB3LcyCuqiKpdivvNNFiK7M", bt.DefaultFees()) + err = tx.ChangeToAddress("mwV3YgnowbJJB3LcyCuqiKpdivvNNFiK7M", bt.NewFeeQuote()) assert.NoError(t, err) var wif *bsvutil.WIF From 6fe49e7f89e25427ac59c443bcdf253454ac1d99 Mon Sep 17 00:00:00 2001 From: Mark Smith Date: Tue, 13 Apr 2021 12:12:54 +0100 Subject: [PATCH 02/12] better nolint message --- bscript/address.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bscript/address.go b/bscript/address.go index 78a416bd..7d4fcc06 100644 --- a/bscript/address.go +++ b/bscript/address.go @@ -77,7 +77,7 @@ func NewAddressFromPublicKeyHash(hash []byte, mainnet bool) (*Address, error) { if !mainnet { bb[0] = 111 } - // nolint: makezero // because is why + // nolint: makezero // we need to setup the array with 1 bb = append(bb, hash...) return &Address{ @@ -99,7 +99,7 @@ func NewAddressFromPublicKey(pubKey *bsvec.PublicKey, mainnet bool) (*Address, e if !mainnet { bb[0] = 111 } - // nolint: makezero // because is why + // nolint: makezero // we need to setup the array with 1 bb = append(bb, hash...) return &Address{ From 07abf960b08aea69cf3935b2819246bed778534a Mon Sep 17 00:00:00 2001 From: Mark Date: Fri, 30 Apr 2021 15:58:38 +0100 Subject: [PATCH 03/12] adding new FeeQuotes type in, to be used to contain a selection of feeQuotes per miner. Added addtional tests --- fees.go | 115 +++++++++++++++++++++++++++++++++++------- fees_test.go | 137 ++++++++++++++++++++++++++++++++++++++++++++------- txchange.go | 16 +++--- 3 files changed, 224 insertions(+), 44 deletions(-) diff --git a/fees.go b/fees.go index 821a7ae5..c616ff3e 100644 --- a/fees.go +++ b/fees.go @@ -2,11 +2,17 @@ package bt import ( "errors" - "fmt" "sync" "time" ) +var ( + ErrFeeQuotesNotInit = errors.New("feeQuotes have not been setup, call NewFeeQuotes") + ErrMinerNoQuotes = errors.New("miner has no quotes stored") + ErrFeeTypeNotFound = errors.New("feetype not found") + ErrFeeQuoteNotInit = errors.New("feeQuote has not been initialized, call NewFeeQuote()") +) + // FeeType is used to specify which // type of fee is used depending on // the type of tx data (eg: standard @@ -21,14 +27,88 @@ const ( FeeTypeData FeeType = "data" ) -// FeeQuotes contains a thread safe map of fees for standard and data -// fees as well as an expiry time. +// FeeQuotes contains a list of miners and the current fees for each miner as well as their expiry. +type FeeQuotes struct { + mu sync.RWMutex + quotes map[string]*FeeQuote +} + +// NewFeeQuotes will setup default feeQuotes for the minerName supplied, ie TAAL etc. +func NewFeeQuotes(minerName string) *FeeQuotes { + return &FeeQuotes{ + mu: sync.RWMutex{}, + quotes: map[string]*FeeQuote{minerName: NewFeeQuote()}, + } +} + +// AddMinerWithDefault will add a new miner to the quotes map with default fees & immediate expiry. +func (f *FeeQuotes) AddMinerWithDefault(minerName string) *FeeQuotes { + f.mu.Lock() + defer f.mu.Unlock() + f.quotes[minerName] = NewFeeQuote() + return f +} + +// AddMiner will add a new miner to the quotes map with the provided fees. +// If you just want to add default fees use the AddMinerWithDefault method. +func (f *FeeQuotes) AddMiner(minerName string, quote *FeeQuote) *FeeQuotes { + f.mu.Lock() + defer f.mu.Unlock() + f.quotes[minerName] = quote + return f +} + +// Quote will return all fees for a miner. +// If no fees are found a ErrMinerNoQuotes error is returned. +func (f *FeeQuotes) Quote(minerName string) (*FeeQuote, error) { + if f == nil { + return nil, ErrFeeQuotesNotInit + } + f.mu.RLock() + defer f.mu.RUnlock() + q, ok := f.quotes[minerName] + if !ok { + return nil, ErrMinerNoQuotes + } + return q, nil +} + +// Fees is a convenience method for quickly getting a fee by type and miner name. +// If the miner has no fees an ErrMinerNoQuotes error will be returned. +// If the feeType cannot be found an ErrFeeTypeNotFound error will be returned. +func (f *FeeQuotes) Fees(minerName string, feeType FeeType) (*Fee, error) { + if f == nil { + return nil, ErrFeeQuotesNotInit + } + f.mu.RLock() + defer f.mu.RUnlock() + m := f.quotes[minerName] + if m == nil { + return nil, ErrMinerNoQuotes + } + return m.Fee(feeType) +} + +// UpdateMinerFees a convenience method to update a fee quote from a FeeQuotes struct directly. +// This will update the miner feeType with the provided fee. Useful after receiving new quotes from mapi. +func (f *FeeQuotes) UpdateMinerFees(minerName string, feeType FeeType, fee *Fee) (*FeeQuote, error) { + f.mu.RLock() + defer f.mu.RUnlock() + m := f.quotes[minerName] + if m == nil { + return nil, ErrMinerNoQuotes + } + return m.AddQuote(feeType, fee), nil +} + +// FeeQuote contains a thread safe map of fees for standard and data +// fees as well as an expiry time for a specific miner. // -// NewFeeQuote() should be called to get a +// NewFeeQuote(minerName) 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 { +type FeeQuote struct { mu sync.RWMutex fees map[FeeType]*Fee expiryTime time.Time @@ -37,6 +117,8 @@ type FeeQuotes struct { // 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. +// If you are only getting quotes from one miner you can use this directly +// instead of using the NewFeeQuotes() method which is for storing multiple miner quotes. // // fq := NewFeeQuote() // @@ -76,35 +158,34 @@ type FeeQuotes struct { // } // 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 { - fq := &FeeQuotes{ +func NewFeeQuote() *FeeQuote { + fq := &FeeQuote{ fees: map[FeeType]*Fee{}, expiryTime: time.Now().UTC(), - mu: sync.RWMutex{}, + 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()") +func (f *FeeQuote) Fee(t FeeType) (*Fee, error) { + if f == nil { + return nil, ErrFeeQuoteNotInit } 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) + if fee == nil || !ok { + return nil, ErrFeeTypeNotFound } 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 { +func (f *FeeQuote) AddQuote(ft FeeType, fee *Fee) *FeeQuote { f.mu.Lock() defer f.mu.Unlock() f.fees[ft] = fee @@ -114,7 +195,7 @@ func (f *FeeQuotes) AddQuote(ft FeeType, fee *Fee) *FeeQuotes { // 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) { +func (f *FeeQuote) UpdateExpiry(exp time.Time) { f.mu.Lock() defer f.mu.Unlock() f.expiryTime = exp @@ -122,7 +203,7 @@ func (f *FeeQuotes) UpdateExpiry(exp time.Time) { // 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 { +func (f *FeeQuote) Expired() bool { f.mu.Lock() defer f.mu.Unlock() return f.expiryTime.Before(time.Now().UTC()) diff --git a/fees_test.go b/fees_test.go index 50f0cfaf..536b542c 100644 --- a/fees_test.go +++ b/fees_test.go @@ -1,19 +1,20 @@ -package bt_test +package bt import ( + "sync" "testing" + "time" - "github.com/libsv/go-bt" "github.com/stretchr/testify/assert" ) func TestExtractDataFee(t *testing.T) { t.Run("get valid data fee", func(t *testing.T) { - fees := bt.NewFeeQuote() - fee, err := fees.Fee(bt.FeeTypeData) + fees := NewFeeQuote() + fee, err := fees.Fee(FeeTypeData) assert.NotNil(t, fee) assert.NoError(t, err) - assert.Equal(t, bt.FeeTypeData, fee.FeeType) + assert.Equal(t, FeeTypeData, fee.FeeType) assert.Equal(t, 25, fee.MiningFee.Satoshis) assert.Equal(t, 100, fee.MiningFee.Bytes) assert.Equal(t, 25, fee.RelayFee.Satoshis) @@ -21,9 +22,9 @@ func TestExtractDataFee(t *testing.T) { }) t.Run("no data fee found", func(t *testing.T) { - fees := bt.NewFeeQuote() - fees.AddQuote(bt.FeeTypeData, nil) - fee, err := fees.Fee(bt.FeeTypeData) + fees := NewFeeQuote() + fees.AddQuote(FeeTypeData, nil) + fee, err := fees.Fee(FeeTypeData) assert.Nil(t, fee) assert.Error(t, err) }) @@ -31,11 +32,11 @@ func TestExtractDataFee(t *testing.T) { func TestExtractStandardFee(t *testing.T) { t.Run("get valid standard fee", func(t *testing.T) { - fees := bt.NewFeeQuote() - fee, err := fees.Fee(bt.FeeTypeStandard) + fees := NewFeeQuote() + fee, err := fees.Fee(FeeTypeStandard) assert.NoError(t, err) assert.NotNil(t, fee) - assert.Equal(t, bt.FeeTypeStandard, fee.FeeType) + assert.Equal(t, FeeTypeStandard, fee.FeeType) assert.Equal(t, 5, fee.MiningFee.Satoshis) assert.Equal(t, 10, fee.MiningFee.Bytes) assert.Equal(t, 5, fee.RelayFee.Satoshis) @@ -43,24 +44,122 @@ func TestExtractStandardFee(t *testing.T) { }) t.Run("no standard fee found", func(t *testing.T) { - fees := bt.NewFeeQuote() - fees.AddQuote(bt.FeeTypeStandard, nil) - fee, err := fees.Fee(bt.FeeTypeStandard) + fees := NewFeeQuote() + fees.AddQuote(FeeTypeStandard, nil) + fee, err := fees.Fee(FeeTypeStandard) assert.Error(t, err) assert.Nil(t, fee) }) } func TestDefaultFees(t *testing.T) { - fees := bt.NewFeeQuote() + fees := NewFeeQuote() - fee, err := fees.Fee( bt.FeeTypeData) + fee, err := fees.Fee(FeeTypeData) assert.NoError(t, err) assert.NotNil(t, fee) - assert.Equal(t, bt.FeeTypeData, fee.FeeType) + assert.Equal(t, FeeTypeData, fee.FeeType) - fee, err = fees.Fee( bt.FeeTypeStandard) + fee, err = fees.Fee(FeeTypeStandard) assert.NoError(t, err) assert.NotNil(t, fee) - assert.Equal(t, bt.FeeTypeStandard, fee.FeeType) + assert.Equal(t, FeeTypeStandard, fee.FeeType) +} + +func TestFeeQuotes_New(t *testing.T) { + fq := NewFeeQuote() + assert.NotNil(t, fq.fees) + assert.NotEmpty(t, fq.expiryTime) +} + +func TestFeeQuotes_Expired(t *testing.T) { + // should always be true as setup sets up a time for now. + fq := NewFeeQuote() + time.Sleep(1 * time.Millisecond) + assert.True(t, fq.Expired()) +} + +func TestFeeQuotes_AddQuote(t *testing.T) { + std := &Fee{ + FeeType: FeeTypeStandard, + MiningFee: FeeUnit{ + Satoshis: 1234, + Bytes: 5, + }, + RelayFee: FeeUnit{ + Satoshis: 1234, + Bytes: 2, + }, + } + data := &Fee{ + FeeType: FeeTypeData, + MiningFee: FeeUnit{ + Satoshis: 5678, + Bytes: 10, + }, + RelayFee: FeeUnit{ + Satoshis: 5678, + Bytes: 4, + }, + } + // should always be true as setup sets up a time for now. + fq := NewFeeQuote(). + AddQuote(FeeTypeStandard, std). + AddQuote(FeeTypeData, data) + sdFee, _ := fq.Fee(FeeTypeStandard) + assert.Equal(t, std, sdFee) + dFee, _ := fq.Fee(FeeTypeData) + assert.Equal(t, data, dFee) +} + +func TestFeeQuotes_Concurrent(t *testing.T) { + std := &Fee{ + FeeType: FeeTypeStandard, + MiningFee: FeeUnit{ + Satoshis: 1234, + Bytes: 5, + }, + RelayFee: FeeUnit{ + Satoshis: 1234, + Bytes: 2, + }, + } + data := &Fee{ + FeeType: FeeTypeData, + MiningFee: FeeUnit{ + Satoshis: 5678, + Bytes: 10, + }, + RelayFee: FeeUnit{ + Satoshis: 5678, + Bytes: 4, + }, + } + fq := NewFeeQuote() + wg := sync.WaitGroup{} + // spin up go routines each reading and writing. + for i := 0; i < 1000; i++ { + wg.Add(1) + go func() { + defer wg.Done() + fq.AddQuote(FeeTypeStandard, std). + AddQuote(FeeTypeData, data) + sdFee, _ := fq.Fee(FeeTypeStandard) + assert.Equal(t, std, sdFee) + dFee, _ := fq.Fee(FeeTypeData) + assert.Equal(t, data, dFee) + }() + } + // wait to finish - should not cause race condition + wg.Wait() + sdFee, _ := fq.Fee(FeeTypeStandard) + assert.Equal(t, std, sdFee) + dFee, _ := fq.Fee(FeeTypeData) + assert.Equal(t, data, dFee) +} + +func TestFeeQuotes_UpdateExpiry(t *testing.T) { + fq := NewFeeQuote() + fq.UpdateExpiry(time.Now().Add(1 * time.Minute)) + assert.False(t, fq.Expired()) } diff --git a/txchange.go b/txchange.go index 1c681b59..7010bbcf 100644 --- a/txchange.go +++ b/txchange.go @@ -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 *FeeQuotes) error { +func (tx *Tx) ChangeToAddress(addr string, f *FeeQuote) error { s, err := bscript.NewP2PKHFromAddress(addr) if err != nil { return err @@ -19,7 +19,7 @@ func (tx *Tx) ChangeToAddress(addr string, f *FeeQuotes) 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 *FeeQuotes) error { +func (tx *Tx) Change(s *bscript.Script, f *FeeQuote) error { inputAmount := tx.TotalInputSatoshis() outputAmount := tx.TotalOutputSatoshis() @@ -30,7 +30,7 @@ func (tx *Tx) Change(s *bscript.Script, f *FeeQuotes) error { available := inputAmount - outputAmount standardFees, err := f.Fee(FeeTypeStandard) - if err != nil{ + if err != nil { return errors.New("standard fees not found") } if !tx.canAddChange(available, standardFees) { @@ -72,17 +72,17 @@ func (tx *Tx) canAddChange(available uint64, standardFees *Fee) bool { return available >= changeOutputFee } -func (tx *Tx) getPreSignedFeeRequired(f *FeeQuotes) (uint64, error) { +func (tx *Tx) getPreSignedFeeRequired(f *FeeQuote) (uint64, error) { standardBytes, dataBytes := tx.getStandardAndDataBytes() standardFee, err := f.Fee(FeeTypeStandard) - if err != nil{ + if err != nil { return 0, err } fr := standardBytes * standardFee.MiningFee.Satoshis / standardFee.MiningFee.Bytes dataFee, err := f.Fee(FeeTypeData) - if err != nil{ + if err != nil { return 0, err } @@ -91,9 +91,9 @@ func (tx *Tx) getPreSignedFeeRequired(f *FeeQuotes) (uint64, error) { return uint64(fr), nil } -func (tx *Tx) getExpectedUnlockingScriptFees(f *FeeQuotes) (uint64, error) { +func (tx *Tx) getExpectedUnlockingScriptFees(f *FeeQuote) (uint64, error) { standardFee, err := f.Fee(FeeTypeStandard) - if err != nil{ + if err != nil { return 0, errors.New("standard fee not found") } var expectedBytes int From 884e23842745e060df7914ddcb0abc2f2d9a22c0 Mon Sep 17 00:00:00 2001 From: Mark Date: Tue, 4 May 2021 14:21:01 +0100 Subject: [PATCH 04/12] working on additional tests for fees --- fees_test.go | 92 +++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 87 insertions(+), 5 deletions(-) diff --git a/fees_test.go b/fees_test.go index 536b542c..c10a4671 100644 --- a/fees_test.go +++ b/fees_test.go @@ -66,20 +66,20 @@ func TestDefaultFees(t *testing.T) { assert.Equal(t, FeeTypeStandard, fee.FeeType) } -func TestFeeQuotes_New(t *testing.T) { +func TestFeeQuote_New(t *testing.T) { fq := NewFeeQuote() assert.NotNil(t, fq.fees) assert.NotEmpty(t, fq.expiryTime) } -func TestFeeQuotes_Expired(t *testing.T) { +func TestFeeQuote_Expired(t *testing.T) { // should always be true as setup sets up a time for now. fq := NewFeeQuote() time.Sleep(1 * time.Millisecond) assert.True(t, fq.Expired()) } -func TestFeeQuotes_AddQuote(t *testing.T) { +func TestFeeQuote_AddQuote(t *testing.T) { std := &Fee{ FeeType: FeeTypeStandard, MiningFee: FeeUnit{ @@ -112,7 +112,7 @@ func TestFeeQuotes_AddQuote(t *testing.T) { assert.Equal(t, data, dFee) } -func TestFeeQuotes_Concurrent(t *testing.T) { +func TestFeeQuote_Concurrent(t *testing.T) { std := &Fee{ FeeType: FeeTypeStandard, MiningFee: FeeUnit{ @@ -158,8 +158,90 @@ func TestFeeQuotes_Concurrent(t *testing.T) { assert.Equal(t, data, dFee) } -func TestFeeQuotes_UpdateExpiry(t *testing.T) { +func TestFeeQuote_UpdateExpiry(t *testing.T) { fq := NewFeeQuote() fq.UpdateExpiry(time.Now().Add(1 * time.Minute)) assert.False(t, fq.Expired()) } + +func TestFeeQuotes_New(t *testing.T) { + fq := NewFeeQuotes("test") + assert.NotEmpty(t, fq.quotes) + assert.NotNil(t, fq.quotes["test"]) +} + +func TestFeeQuotes_AddMinerWithDefault(t *testing.T) { + fq := NewFeeQuotes("test") + quotes := fq.AddMinerWithDefault("test2") + quote, err := quotes.Quote("test2") + assert.NoError(t, err) + assert.NotNil(t, quote) + fees, err := quote.Fee(FeeTypeStandard) + assert.NoError(t, err) + assert.Equal(t, defaultStandardFee(), fees) +} + +func TestFeeQuotes_AddMiner(t *testing.T) { + tests := map[string]struct { + fee *FeeQuote + minername string + }{ + "adding a miner with custom fee should return custom fee": { + fee: &FeeQuote{ + mu: sync.RWMutex{}, + fees: map[FeeType]*Fee{"test": { + MiningFee: FeeUnit{ + Satoshis: 100, + Bytes: 10, + }, + RelayFee: FeeUnit{ + Satoshis: 1000, + Bytes: 55, + }, + }, + }, + }, + minername: "test", + }, "adding miners with custom fees should return correct fee": { + fee: &FeeQuote{ + mu: sync.RWMutex{}, + fees: map[FeeType]*Fee{ + "test": { + MiningFee: FeeUnit{ + Satoshis: 100, + Bytes: 10, + }, + RelayFee: FeeUnit{ + Satoshis: 1000, + Bytes: 55, + }, + }, + "test2": { + MiningFee: FeeUnit{ + Satoshis: 99, + Bytes: 20, + }, + RelayFee: FeeUnit{ + Satoshis: 999, + Bytes: 11, + }, + }, + }, + }, + minername: "test", + }, + } + for name, test := range tests { + t.Run(name, func(t *testing.T) { + fq := NewFeeQuotes("test") + fq.AddMiner(test.minername, test.fee) + q, err := fq.Quote(test.minername) + assert.NoError(t, err) + assert.Equal(t, test.fee, q) + }) + } +} + +func TestFeeQuotes_UpdateMinerFees(t *testing.T) { + +} From e811209f34223446853dfedbf7bce8dea5d08a14 Mon Sep 17 00:00:00 2001 From: Mark Smith Date: Thu, 6 May 2021 16:08:42 +0100 Subject: [PATCH 05/12] adding new cheapestFees method --- fees.go | 47 ++++++++++++++++++++++++++++++++++++++++++----- fees_test.go | 2 +- 2 files changed, 43 insertions(+), 6 deletions(-) diff --git a/fees.go b/fees.go index c616ff3e..47241147 100644 --- a/fees.go +++ b/fees.go @@ -28,6 +28,10 @@ const ( ) // FeeQuotes contains a list of miners and the current fees for each miner as well as their expiry. +// +// This can be used when getting fees from multiple miners and you want to use the cheapest for example. +// +// Useage setup should be calling NewFeeQuotes(minerName). type FeeQuotes struct { mu sync.RWMutex quotes map[string]*FeeQuote @@ -49,7 +53,7 @@ func (f *FeeQuotes) AddMinerWithDefault(minerName string) *FeeQuotes { return f } -// AddMiner will add a new miner to the quotes map with the provided fees. +// AddMiner will add a new miner to the quotes map with the provided feeQuote. // If you just want to add default fees use the AddMinerWithDefault method. func (f *FeeQuotes) AddMiner(minerName string, quote *FeeQuote) *FeeQuotes { f.mu.Lock() @@ -59,7 +63,7 @@ func (f *FeeQuotes) AddMiner(minerName string, quote *FeeQuote) *FeeQuotes { } // Quote will return all fees for a miner. -// If no fees are found a ErrMinerNoQuotes error is returned. +// If no fees are found an ErrMinerNoQuotes error is returned. func (f *FeeQuotes) Quote(minerName string) (*FeeQuote, error) { if f == nil { return nil, ErrFeeQuotesNotInit @@ -92,8 +96,8 @@ func (f *FeeQuotes) Fees(minerName string, feeType FeeType) (*Fee, error) { // UpdateMinerFees a convenience method to update a fee quote from a FeeQuotes struct directly. // This will update the miner feeType with the provided fee. Useful after receiving new quotes from mapi. func (f *FeeQuotes) UpdateMinerFees(minerName string, feeType FeeType, fee *Fee) (*FeeQuote, error) { - f.mu.RLock() - defer f.mu.RUnlock() + f.mu.Lock() + defer f.mu.Unlock() m := f.quotes[minerName] if m == nil { return nil, ErrMinerNoQuotes @@ -101,10 +105,43 @@ func (f *FeeQuotes) UpdateMinerFees(minerName string, feeType FeeType, fee *Fee) return m.AddQuote(feeType, fee), nil } +// CheapestFee will search all cached mining feeQuotes for the cheapest fee for a given fee type. +// string will be the miner name, this will allow you to broadcast the transaction +// to the cheapest miner via mAPI. +// Fee contains the quote which you can supply to the tx when calculating the change output / fees. +func (f *FeeQuotes) CheapestFee(feeType FeeType) (string, *Fee, error) { + f.mu.RLock() + defer f.mu.RUnlock() + var fee *Fee + var miner string + for m, q := range f.quotes { + f, err := q.Fee(feeType) + if err != nil { + return "", nil, err + } + if fee == nil { + miner = m + fee = f + } + if f.MiningFee.Satoshis/f.MiningFee.Bytes < fee.MiningFee.Satoshis/fee.MiningFee.Bytes { + fee = f + miner = m + } + } + return miner, fee, nil +} + // FeeQuote contains a thread safe map of fees for standard and data // fees as well as an expiry time for a specific miner. // -// NewFeeQuote(minerName) should be called to get a +// This can be used if you are only dealing with a single miner and know you +// will always be using a single miner. +// FeeQuote will store the fees for a single miner and can be passed to transactions +// to calculate fees when creating change outputs. +// +// If you are dealing with quotes from multiple miners, use the FeeQuotes structure above. +// +// NewFeeQuote() should be called to get a new instance of a FeeQuote. // // When expiry expires ie Expired() == true then you should fetch // new quotes from a MAPI server and call AddQuote with the fee information. diff --git a/fees_test.go b/fees_test.go index c10a4671..1103f301 100644 --- a/fees_test.go +++ b/fees_test.go @@ -233,7 +233,7 @@ func TestFeeQuotes_AddMiner(t *testing.T) { } for name, test := range tests { t.Run(name, func(t *testing.T) { - fq := NewFeeQuotes("test") + fq := NewFeeQuotes(test.minername) fq.AddMiner(test.minername, test.fee) q, err := fq.Quote(test.minername) assert.NoError(t, err) From 6b29ca954ab5b7c149fbf87ccaac6d0e942050d6 Mon Sep 17 00:00:00 2001 From: Mark Smith Date: Thu, 6 May 2021 16:09:35 +0100 Subject: [PATCH 06/12] fixing tests before adding more --- fees.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/fees.go b/fees.go index 47241147..b6c5d003 100644 --- a/fees.go +++ b/fees.go @@ -6,6 +6,7 @@ import ( "time" ) +// Sentinel errors reported by the fees. var ( ErrFeeQuotesNotInit = errors.New("feeQuotes have not been setup, call NewFeeQuotes") ErrMinerNoQuotes = errors.New("miner has no quotes stored") @@ -31,7 +32,7 @@ const ( // // This can be used when getting fees from multiple miners and you want to use the cheapest for example. // -// Useage setup should be calling NewFeeQuotes(minerName). +// Usage setup should be calling NewFeeQuotes(minerName). type FeeQuotes struct { mu sync.RWMutex quotes map[string]*FeeQuote From 03f60ff8b469f5b772bc7c7b7b8a095db2b26039 Mon Sep 17 00:00:00 2001 From: Mark Smith Date: Thu, 6 May 2021 17:00:37 +0100 Subject: [PATCH 07/12] added unit tests for feequotes --- fees.go | 10 +- fees_test.go | 388 +++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 395 insertions(+), 3 deletions(-) diff --git a/fees.go b/fees.go index b6c5d003..4c300c61 100644 --- a/fees.go +++ b/fees.go @@ -12,6 +12,7 @@ var ( ErrMinerNoQuotes = errors.New("miner has no quotes stored") ErrFeeTypeNotFound = errors.New("feetype not found") ErrFeeQuoteNotInit = errors.New("feeQuote has not been initialized, call NewFeeQuote()") + ErrEmptyValues = errors.New("empty value or values passed, all arguments are required and cannot be empty") ) // FeeType is used to specify which @@ -78,10 +79,10 @@ func (f *FeeQuotes) Quote(minerName string) (*FeeQuote, error) { return q, nil } -// Fees is a convenience method for quickly getting a fee by type and miner name. +// Fee is a convenience method for quickly getting a fee by type and miner name. // If the miner has no fees an ErrMinerNoQuotes error will be returned. // If the feeType cannot be found an ErrFeeTypeNotFound error will be returned. -func (f *FeeQuotes) Fees(minerName string, feeType FeeType) (*Fee, error) { +func (f *FeeQuotes) Fee(minerName string, feeType FeeType) (*Fee, error) { if f == nil { return nil, ErrFeeQuotesNotInit } @@ -99,6 +100,9 @@ func (f *FeeQuotes) Fees(minerName string, feeType FeeType) (*Fee, error) { func (f *FeeQuotes) UpdateMinerFees(minerName string, feeType FeeType, fee *Fee) (*FeeQuote, error) { f.mu.Lock() defer f.mu.Unlock() + if minerName == "" || feeType == "" || fee == nil { + return nil, errors.New("empty value or values passed, all arguments are required and cannot be empty") + } m := f.quotes[minerName] if m == nil { return nil, ErrMinerNoQuotes @@ -124,7 +128,7 @@ func (f *FeeQuotes) CheapestFee(feeType FeeType) (string, *Fee, error) { miner = m fee = f } - if f.MiningFee.Satoshis/f.MiningFee.Bytes < fee.MiningFee.Satoshis/fee.MiningFee.Bytes { + if float64(f.MiningFee.Satoshis)/float64(f.MiningFee.Bytes) < float64(fee.MiningFee.Satoshis)/float64(fee.MiningFee.Bytes) { fee = f miner = m } diff --git a/fees_test.go b/fees_test.go index 1103f301..e33f401a 100644 --- a/fees_test.go +++ b/fees_test.go @@ -243,5 +243,393 @@ func TestFeeQuotes_AddMiner(t *testing.T) { } func TestFeeQuotes_UpdateMinerFees(t *testing.T) { + t.Parallel() + tests := map[string]struct { + feeQuotes *FeeQuotes + minerName string + feeType FeeType + fee *Fee + expFeeQuote *FeeQuote + err error + }{ + "Updating existing miner fee should return correct quote": { + feeQuotes: NewFeeQuotes("test"), + minerName: "test", + feeType: FeeTypeStandard, + fee: &Fee{ + FeeType: FeeTypeStandard, + MiningFee: FeeUnit{ + Satoshis: 100, + Bytes: 15, + }, + RelayFee: FeeUnit{ + Satoshis: 100, + Bytes: 25, + }, + }, + expFeeQuote: &FeeQuote{ + fees: map[FeeType]*Fee{ + FeeTypeStandard: { + FeeType: FeeTypeStandard, + MiningFee: FeeUnit{ + Satoshis: 100, + Bytes: 15, + }, + RelayFee: FeeUnit{ + Satoshis: 100, + Bytes: 25, + }, + }, + }, + expiryTime: time.Time{}, + }, + err: nil, + }, "Updating existing miner fee with multiple fees stored should return correct quote": { + feeQuotes: func() *FeeQuotes { + fq := NewFeeQuotes("test"). + AddMinerWithDefault("test2"). + AddMinerWithDefault("test3"). + AddMinerWithDefault("test4") + return fq + }(), + minerName: "test3", + feeType: FeeTypeStandard, + fee: &Fee{ + FeeType: FeeTypeStandard, + MiningFee: FeeUnit{ + Satoshis: 100, + Bytes: 15, + }, + RelayFee: FeeUnit{ + Satoshis: 100, + Bytes: 25, + }, + }, + expFeeQuote: &FeeQuote{ + fees: map[FeeType]*Fee{ + FeeTypeStandard: { + FeeType: FeeTypeStandard, + MiningFee: FeeUnit{ + Satoshis: 100, + Bytes: 15, + }, + RelayFee: FeeUnit{ + Satoshis: 100, + Bytes: 25, + }, + }, + }, + expiryTime: time.Time{}, + }, + err: nil, + }, "Updating miner that doesn't exist should return ErrMinerNoQuotes": { + feeQuotes: NewFeeQuotes("test"), + minerName: "test2", + feeType: FeeTypeStandard, + fee: &Fee{ + FeeType: FeeTypeStandard, + MiningFee: FeeUnit{ + Satoshis: 100, + Bytes: 15, + }, + RelayFee: FeeUnit{ + Satoshis: 100, + Bytes: 25, + }, + }, + err: ErrMinerNoQuotes, + }, + } + for name, test := range tests { + t.Run(name, func(t *testing.T) { + fq, err := test.feeQuotes.UpdateMinerFees(test.minerName, test.feeType, test.fee) + if test.err != nil { + assert.Error(t, err) + assert.Nil(t, fq) + assert.EqualError(t, err, test.err.Error()) + return + } + assert.NoError(t, err) + assert.NotNil(t, fq) + assert.Equal(t, test.expFeeQuote.fees[test.feeType], fq.fees[test.feeType]) + }) + } +} + +func TestFeeQuotes_UpdateMinerFeesConcurrent(t *testing.T) { + fq := NewFeeQuotes("test") + wg := sync.WaitGroup{} + for i := 0; i < 100000; i++ { + go func() { + wg.Add(1) + defer wg.Done() + _, _ = fq.UpdateMinerFees("test", FeeTypeStandard, &Fee{ + FeeType: FeeTypeStandard, + MiningFee: FeeUnit{ + Satoshis: 100, + Bytes: 15, + }, + RelayFee: FeeUnit{ + Satoshis: 100, + Bytes: 25, + }, + }) + fq.AddMinerWithDefault("test") + _, _ = fq.Quote("test") + }() + } + wg.Wait() +} + +func TestFeeQuotes_Quote(t *testing.T) { + t.Parallel() + tests := map[string]struct { + fq *FeeQuotes + minerName string + expQuote *FeeQuote + err error + }{ + "single miner and default quote is requested should return quote": { + fq: NewFeeQuotes("test"), + minerName: "test", + expQuote: NewFeeQuote(), + err: nil, + }, + "multiple miners with default quote is requested should return quote": { + fq: func() *FeeQuotes { + fq := NewFeeQuotes("test"). + AddMinerWithDefault("test2"). + AddMinerWithDefault("test3"). + AddMinerWithDefault("test4") + return fq + }(), + minerName: "test2", + expQuote: NewFeeQuote(), + err: nil, + }, "multiple miners with differing quotes requested should return correct quote": { + fq: func() *FeeQuotes { + fq := NewFeeQuotes("test"). + AddMinerWithDefault("test2"). + AddMinerWithDefault("test3"). + AddMinerWithDefault("test4") + + _, err := fq.UpdateMinerFees("test2", FeeTypeStandard, &Fee{ + FeeType: FeeTypeStandard, + MiningFee: FeeUnit{ + Satoshis: 1000, + Bytes: 200, + }, + RelayFee: FeeUnit{ + Satoshis: 500, + Bytes: 10, + }, + }) + assert.NoError(t, err) + return fq + }(), + minerName: "test2", + expQuote: func() *FeeQuote { + fq := NewFeeQuote() + fq.AddQuote(FeeTypeStandard, &Fee{ + FeeType: FeeTypeStandard, + MiningFee: FeeUnit{ + Satoshis: 1000, + Bytes: 200, + }, + RelayFee: FeeUnit{ + Satoshis: 500, + Bytes: 10, + }, + }) + return fq + }(), + err: nil, + }, + } + for name, test := range tests { + t.Run(name, func(t *testing.T) { + fq, err := test.fq.Quote(test.minerName) + if test.err != nil { + assert.Error(t, err) + assert.Nil(t, fq) + assert.EqualError(t, err, test.err.Error()) + return + } + assert.NoError(t, err) + assert.NotNil(t, fq) + assert.Equal(t, test.expQuote.fees, fq.fees) + }) + } +} + +func TestFeeQuotes_Fee(t *testing.T) { + t.Parallel() + tests := map[string]struct { + fq *FeeQuotes + minerName string + feeType FeeType + expFee *Fee + err error + }{ + "miner and fee present, should return correct fee": { + fq: NewFeeQuotes("test"), + minerName: "test", + feeType: FeeTypeStandard, + expFee: defaultStandardFee(), + err: nil, + }, "multiple miners and fee present, should return correct fee": { + fq: func() *FeeQuotes { + fq := NewFeeQuotes("test"). + AddMinerWithDefault("test2"). + AddMinerWithDefault("test3"). + AddMinerWithDefault("test4") + _, err := fq.UpdateMinerFees("test2", FeeTypeStandard, &Fee{ + FeeType: FeeTypeStandard, + MiningFee: FeeUnit{ + Satoshis: 1000, + Bytes: 200, + }, + RelayFee: FeeUnit{ + Satoshis: 500, + Bytes: 10, + }, + }) + assert.NoError(t, err) + return fq + }(), + minerName: "test2", + feeType: FeeTypeStandard, + expFee: &Fee{ + FeeType: FeeTypeStandard, + MiningFee: FeeUnit{ + Satoshis: 1000, + Bytes: 200, + }, + RelayFee: FeeUnit{ + Satoshis: 500, + Bytes: 10, + }, + }, + err: nil, + }, "miner no quotes should return error": { + fq: NewFeeQuotes("test"), + minerName: "test2", + feeType: FeeTypeStandard, + err: ErrMinerNoQuotes, + }, "feeType not found should return error": { + fq: NewFeeQuotes("test"), + minerName: "test", + feeType: "dontexist", + err: ErrFeeTypeNotFound, + }, + } + for name, test := range tests { + t.Run(name, func(t *testing.T) { + fee, err := test.fq.Fee(test.minerName, test.feeType) + if test.err != nil { + assert.Error(t, err) + assert.Nil(t, fee) + assert.EqualError(t, err, test.err.Error()) + return + } + assert.NoError(t, err) + assert.NotNil(t, fee) + assert.Equal(t, test.expFee, fee) + }) + } +} + +func TestFeeQuotes_CheapestFee(t *testing.T) { + t.Parallel() + tests := map[string]struct { + fq *FeeQuotes + feeType FeeType + expMinerNames []string + expFee *Fee + err error + }{ + "single quote added, should return": { + fq: NewFeeQuotes("test1"), + feeType: FeeTypeData, + expMinerNames: []string{"test1"}, + expFee: defaultDataFee(), + err: nil, + }, "multiple quotes added, should return cheapest": { + fq: func() *FeeQuotes { + fq := NewFeeQuotes("test"). + AddMinerWithDefault("test2"). + AddMinerWithDefault("test3"). + AddMinerWithDefault("test4") + + _, err := fq.UpdateMinerFees("test2", FeeTypeStandard, &Fee{ + FeeType: FeeTypeStandard, + MiningFee: FeeUnit{ + Satoshis: 5, + Bytes: 200, + }, + RelayFee: FeeUnit{ + Satoshis: 5, + Bytes: 200, + }, + }) + assert.NoError(t, err) + return fq + }(), + feeType: FeeTypeStandard, + expMinerNames: []string{"test2"}, + expFee: &Fee{ + FeeType: FeeTypeStandard, + MiningFee: FeeUnit{ + Satoshis: 5, + Bytes: 200, + }, + RelayFee: FeeUnit{ + Satoshis: 5, + Bytes: 200, + }, + }, + err: nil, + }, "multiple quotes added with 3 same, should return one of cheapest": { + fq: func() *FeeQuotes { + fq := NewFeeQuotes("test1"). + AddMinerWithDefault("test2"). + AddMinerWithDefault("test3"). + AddMinerWithDefault("test4") + + _, err := fq.UpdateMinerFees("test2", FeeTypeStandard, &Fee{ + FeeType: FeeTypeStandard, + MiningFee: FeeUnit{ + Satoshis: 500, + Bytes: 200, + }, + RelayFee: FeeUnit{ + Satoshis: 500, + Bytes: 200, + }, + }) + assert.NoError(t, err) + return fq + }(), + feeType: FeeTypeStandard, + expMinerNames: []string{"test1", "test3", "test4"}, + expFee: defaultStandardFee(), + err: nil, + }, + } + for name, test := range tests { + t.Run(name, func(t *testing.T) { + minerName, fee, err := test.fq.CheapestFee(test.feeType) + if test.err != nil { + assert.Error(t, err) + assert.Nil(t, fee) + assert.EqualError(t, err, test.err.Error()) + return + } + assert.NoError(t, err) + assert.NotNil(t, fee) + assert.Equal(t, test.expFee, fee) + assert.Contains(t, test.expMinerNames, minerName) + }) + } } From 08aed1d7b6dd673a0579d1666e32ce4db01be040 Mon Sep 17 00:00:00 2001 From: Mark Smith Date: Thu, 6 May 2021 17:01:47 +0100 Subject: [PATCH 08/12] move wg add before go routine to prevent race --- fees.go | 3 ++- fees_test.go | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/fees.go b/fees.go index 4c300c61..f14087c2 100644 --- a/fees.go +++ b/fees.go @@ -128,7 +128,8 @@ func (f *FeeQuotes) CheapestFee(feeType FeeType) (string, *Fee, error) { miner = m fee = f } - if float64(f.MiningFee.Satoshis)/float64(f.MiningFee.Bytes) < float64(fee.MiningFee.Satoshis)/float64(fee.MiningFee.Bytes) { + if float64(f.MiningFee.Satoshis)/float64(f.MiningFee.Bytes) < + float64(fee.MiningFee.Satoshis)/float64(fee.MiningFee.Bytes) { fee = f miner = m } diff --git a/fees_test.go b/fees_test.go index e33f401a..74ea487e 100644 --- a/fees_test.go +++ b/fees_test.go @@ -360,8 +360,8 @@ func TestFeeQuotes_UpdateMinerFeesConcurrent(t *testing.T) { fq := NewFeeQuotes("test") wg := sync.WaitGroup{} for i := 0; i < 100000; i++ { + wg.Add(1) go func() { - wg.Add(1) defer wg.Done() _, _ = fq.UpdateMinerFees("test", FeeTypeStandard, &Fee{ FeeType: FeeTypeStandard, From da9defe4f72c5f771041b91f019a6be8878fa3bc Mon Sep 17 00:00:00 2001 From: Mark Smith Date: Thu, 6 May 2021 17:04:20 +0100 Subject: [PATCH 09/12] oh dear, git actions doesn't like too many go routine running --- fees_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fees_test.go b/fees_test.go index 74ea487e..e9454e02 100644 --- a/fees_test.go +++ b/fees_test.go @@ -359,7 +359,7 @@ func TestFeeQuotes_UpdateMinerFees(t *testing.T) { func TestFeeQuotes_UpdateMinerFeesConcurrent(t *testing.T) { fq := NewFeeQuotes("test") wg := sync.WaitGroup{} - for i := 0; i < 100000; i++ { + for i := 0; i < 10000; i++ { wg.Add(1) go func() { defer wg.Done() From 835f0a1f5eb040c9cf1b02d7a8cd76e84da4b989 Mon Sep 17 00:00:00 2001 From: Mark Smith Date: Wed, 12 May 2021 16:49:52 +0100 Subject: [PATCH 10/12] memoize cheapest fees to reduce compute time when retrieving --- fees.go | 25 +++++++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/fees.go b/fees.go index 9e3abe38..7a154143 100644 --- a/fees.go +++ b/fees.go @@ -35,8 +35,14 @@ const ( // // Usage setup should be calling NewFeeQuotes(minerName). type FeeQuotes struct { - mu sync.RWMutex - quotes map[string]*FeeQuote + mu sync.RWMutex + quotes map[string]*FeeQuote + cheapest map[FeeType]*memoizedFee +} + +type memoizedFee struct { + fee *Fee + miner string } // NewFeeQuotes will setup default feeQuotes for the minerName supplied, ie TAAL etc. @@ -107,6 +113,8 @@ func (f *FeeQuotes) UpdateMinerFees(minerName string, feeType FeeType, fee *Fee) if m == nil { return nil, ErrMinerNoQuotes } + // clear memoized cheapest fees + f.cheapest = nil return m.AddQuote(feeType, fee), nil } @@ -117,6 +125,11 @@ func (f *FeeQuotes) UpdateMinerFees(minerName string, feeType FeeType, fee *Fee) func (f *FeeQuotes) CheapestFee(feeType FeeType) (string, *Fee, error) { f.mu.RLock() defer f.mu.RUnlock() + if f.cheapest != nil { + if memoizedFee, ok := f.cheapest[feeType]; ok { + return memoizedFee.miner, memoizedFee.fee, nil + } + } var fee *Fee var miner string for m, q := range f.quotes { @@ -134,6 +147,14 @@ func (f *FeeQuotes) CheapestFee(feeType FeeType) (string, *Fee, error) { miner = m } } + // memoizeFee for later retrieval; cheap fees are cleared when we update a feequote. + if f.cheapest == nil { + f.cheapest = map[FeeType]*memoizedFee{} + } + f.cheapest[feeType] = &memoizedFee{ + fee: fee, + miner: miner, + } return miner, fee, nil } From 69762bf46a7b2ba133f64c7924dc24578525400d Mon Sep 17 00:00:00 2001 From: Mark Smith Date: Tue, 1 Jun 2021 12:39:47 +0100 Subject: [PATCH 11/12] Revert "memoize cheapest fees to reduce compute time when retrieving" This reverts commit 835f0a1f --- fees.go | 25 ++----------------------- 1 file changed, 2 insertions(+), 23 deletions(-) diff --git a/fees.go b/fees.go index 7a154143..9e3abe38 100644 --- a/fees.go +++ b/fees.go @@ -35,14 +35,8 @@ const ( // // Usage setup should be calling NewFeeQuotes(minerName). type FeeQuotes struct { - mu sync.RWMutex - quotes map[string]*FeeQuote - cheapest map[FeeType]*memoizedFee -} - -type memoizedFee struct { - fee *Fee - miner string + mu sync.RWMutex + quotes map[string]*FeeQuote } // NewFeeQuotes will setup default feeQuotes for the minerName supplied, ie TAAL etc. @@ -113,8 +107,6 @@ func (f *FeeQuotes) UpdateMinerFees(minerName string, feeType FeeType, fee *Fee) if m == nil { return nil, ErrMinerNoQuotes } - // clear memoized cheapest fees - f.cheapest = nil return m.AddQuote(feeType, fee), nil } @@ -125,11 +117,6 @@ func (f *FeeQuotes) UpdateMinerFees(minerName string, feeType FeeType, fee *Fee) func (f *FeeQuotes) CheapestFee(feeType FeeType) (string, *Fee, error) { f.mu.RLock() defer f.mu.RUnlock() - if f.cheapest != nil { - if memoizedFee, ok := f.cheapest[feeType]; ok { - return memoizedFee.miner, memoizedFee.fee, nil - } - } var fee *Fee var miner string for m, q := range f.quotes { @@ -147,14 +134,6 @@ func (f *FeeQuotes) CheapestFee(feeType FeeType) (string, *Fee, error) { miner = m } } - // memoizeFee for later retrieval; cheap fees are cleared when we update a feequote. - if f.cheapest == nil { - f.cheapest = map[FeeType]*memoizedFee{} - } - f.cheapest[feeType] = &memoizedFee{ - fee: fee, - miner: miner, - } return miner, fee, nil } From 770bf642e05394d23c85535d2baf6f9e540c64ac Mon Sep 17 00:00:00 2001 From: Mark Smith Date: Tue, 1 Jun 2021 12:41:40 +0100 Subject: [PATCH 12/12] stripping off cheapest fee method --- .golangci.yml | 1 + bscript/script.go | 57 ++++++- fees.go | 27 ---- fees_test.go | 94 ------------ input.go | 65 ++++++++ output.go | 74 +++++++++ tx.go | 65 +++++++- tx_test.go | 376 ++++++++++++++++++++++++++++++++++++++++++++++ txchange.go | 4 +- txchange_test.go | 2 +- txoutput_test.go | 3 +- 11 files changed, 633 insertions(+), 135 deletions(-) diff --git a/.golangci.yml b/.golangci.yml index 08b90dd0..f8cf1584 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -353,6 +353,7 @@ linters: - gocritic # use this for very opinionated linting - gochecknoglobals - whitespace + - gci - wsl - goerr113 - godot diff --git a/bscript/script.go b/bscript/script.go index 04c72dd8..69e3fe6a 100644 --- a/bscript/script.go +++ b/bscript/script.go @@ -18,6 +18,15 @@ var ( ErrNotP2PKH = errors.New("not a P2PKH") ) +// ScriptKey types. +const ( + ScriptTypePubKey = "pubkey" + ScriptTypePubKeyHash = "pubkeyhash" + ScriptTypeNonStandard = "nonstandard" + ScriptTypeMultiSig = "multisig" + ScriptTypeNullData = "nulldata" +) + // Script type type Script []byte @@ -87,7 +96,7 @@ func NewP2PKHFromPubKeyHash(pubKeyHash []byte) (*Script, error) { b := []byte{ OpDUP, OpHASH160, - 0x14, + OpDATA20, } b = append(b, pubKeyHash...) b = append(b, OpEQUALVERIFY) @@ -223,7 +232,7 @@ func (s *Script) IsP2PKH() bool { return len(b) == 25 && b[0] == OpDUP && b[1] == OpHASH160 && - b[2] == 0x14 && + b[2] == OpDATA20 && b[23] == OpEQUALVERIFY && b[24] == OpCHECKSIG } @@ -255,7 +264,7 @@ func (s *Script) IsP2SH() bool { return len(b) == 23 && b[0] == OpHASH160 && - b[1] == 0x14 && + b[1] == OpDATA20 && b[22] == OpEQUAL } @@ -264,8 +273,8 @@ func (s *Script) IsP2SH() bool { func (s *Script) IsData() bool { b := []byte(*s) - return b[0] == 0x6a || - b[0] == 0x00 && b[1] == 0x6a + return b[0] == OpRETURN || + b[0] == OpFALSE && b[1] == OpRETURN } // IsMultiSigOut returns true if this is a multisig output script. @@ -303,7 +312,7 @@ func (s *Script) PublicKeyHash() ([]byte, error) { return nil, ErrEmptyScript } - if (*s)[0] != 0x76 || (*s)[1] != 0xa9 { + if (*s)[0] != OpDUP || (*s)[1] != OpHASH160 { return nil, ErrNotP2PKH } @@ -315,6 +324,42 @@ func (s *Script) PublicKeyHash() ([]byte, error) { return parts[0], nil } +// ScriptType returns the type of script this is as a string. +func (s *Script) ScriptType() string { + if s.IsP2PKH() { + return ScriptTypePubKeyHash + } + if s.IsP2PK() { + return ScriptTypePubKey + } + if s.IsMultiSigOut() { + return ScriptTypeMultiSig + } + if s.IsData() { + return ScriptTypeNullData + } + return ScriptTypeNonStandard +} + +// Addresses will return all addresses found in the script, if any. +func (s *Script) Addresses() ([]string, error) { + addresses := make([]string, 0) + if s.IsP2PKH() { + pkh, err := s.PublicKeyHash() + if err != nil { + return nil, err + } + a, err := NewAddressFromPublicKeyHash(pkh, true) + if err != nil { + return nil, err + } + addresses = []string{a.AddressString} + } + // TODO: handle multisig, and other outputs + // https://github.com/libsv/go-bt/issues/6 + return addresses, nil +} + // Equals will compare the script to b and return true if they match. func (s *Script) Equals(b *Script) bool { return bytes.Equal(*s, *b) diff --git a/fees.go b/fees.go index 9e3abe38..29a76ffb 100644 --- a/fees.go +++ b/fees.go @@ -110,33 +110,6 @@ func (f *FeeQuotes) UpdateMinerFees(minerName string, feeType FeeType, fee *Fee) return m.AddQuote(feeType, fee), nil } -// CheapestFee will search all cached mining feeQuotes for the cheapest fee for a given fee type. -// string will be the miner name, this will allow you to broadcast the transaction -// to the cheapest miner via mAPI. -// Fee contains the quote which you can supply to the tx when calculating the change output / fees. -func (f *FeeQuotes) CheapestFee(feeType FeeType) (string, *Fee, error) { - f.mu.RLock() - defer f.mu.RUnlock() - var fee *Fee - var miner string - for m, q := range f.quotes { - f, err := q.Fee(feeType) - if err != nil { - return "", nil, err - } - if fee == nil { - miner = m - fee = f - } - if float64(f.MiningFee.Satoshis)/float64(f.MiningFee.Bytes) < - float64(fee.MiningFee.Satoshis)/float64(fee.MiningFee.Bytes) { - fee = f - miner = m - } - } - return miner, fee, nil -} - // FeeQuote contains a thread safe map of fees for standard and data // fees as well as an expiry time for a specific miner. // diff --git a/fees_test.go b/fees_test.go index e9454e02..3bbd0d31 100644 --- a/fees_test.go +++ b/fees_test.go @@ -539,97 +539,3 @@ func TestFeeQuotes_Fee(t *testing.T) { }) } } - -func TestFeeQuotes_CheapestFee(t *testing.T) { - t.Parallel() - tests := map[string]struct { - fq *FeeQuotes - feeType FeeType - expMinerNames []string - expFee *Fee - err error - }{ - "single quote added, should return": { - fq: NewFeeQuotes("test1"), - feeType: FeeTypeData, - expMinerNames: []string{"test1"}, - expFee: defaultDataFee(), - err: nil, - }, "multiple quotes added, should return cheapest": { - fq: func() *FeeQuotes { - fq := NewFeeQuotes("test"). - AddMinerWithDefault("test2"). - AddMinerWithDefault("test3"). - AddMinerWithDefault("test4") - - _, err := fq.UpdateMinerFees("test2", FeeTypeStandard, &Fee{ - FeeType: FeeTypeStandard, - MiningFee: FeeUnit{ - Satoshis: 5, - Bytes: 200, - }, - RelayFee: FeeUnit{ - Satoshis: 5, - Bytes: 200, - }, - }) - assert.NoError(t, err) - return fq - }(), - feeType: FeeTypeStandard, - expMinerNames: []string{"test2"}, - expFee: &Fee{ - FeeType: FeeTypeStandard, - MiningFee: FeeUnit{ - Satoshis: 5, - Bytes: 200, - }, - RelayFee: FeeUnit{ - Satoshis: 5, - Bytes: 200, - }, - }, - err: nil, - }, "multiple quotes added with 3 same, should return one of cheapest": { - fq: func() *FeeQuotes { - fq := NewFeeQuotes("test1"). - AddMinerWithDefault("test2"). - AddMinerWithDefault("test3"). - AddMinerWithDefault("test4") - - _, err := fq.UpdateMinerFees("test2", FeeTypeStandard, &Fee{ - FeeType: FeeTypeStandard, - MiningFee: FeeUnit{ - Satoshis: 500, - Bytes: 200, - }, - RelayFee: FeeUnit{ - Satoshis: 500, - Bytes: 200, - }, - }) - assert.NoError(t, err) - return fq - }(), - feeType: FeeTypeStandard, - expMinerNames: []string{"test1", "test3", "test4"}, - expFee: defaultStandardFee(), - err: nil, - }, - } - for name, test := range tests { - t.Run(name, func(t *testing.T) { - minerName, fee, err := test.fq.CheapestFee(test.feeType) - if test.err != nil { - assert.Error(t, err) - assert.Nil(t, fee) - assert.EqualError(t, err, test.err.Error()) - return - } - assert.NoError(t, err) - assert.NotNil(t, fee) - assert.Equal(t, test.expFee, fee) - assert.Contains(t, test.expMinerNames, minerName) - }) - } -} diff --git a/input.go b/input.go index 899ca6e0..27c06e3e 100644 --- a/input.go +++ b/input.go @@ -2,6 +2,7 @@ package bt import ( "encoding/hex" + "encoding/json" "fmt" "github.com/libsv/go-bt/bscript" @@ -35,6 +36,70 @@ type Input struct { SequenceNumber uint32 } +// inputJSON is used to covnert an input to and from json. +// Script is duplicated as we have our own name for unlockingScript +// but want to be compatible with node json also. +type inputJSON struct { + UnlockingScript *struct { + Asm string `json:"asm"` + Hex string `json:"hex"` + } `json:"unlockingScript,omitempty"` + ScriptSig *struct { + Asm string `json:"asm"` + Hex string `json:"hex"` + } `json:"scriptSig,omitempty"` + TxID string `json:"txid"` + Vout uint32 `json:"vout"` + Sequence uint32 `json:"sequence"` +} + +// MarshalJSON will convert an input to json, expanding upon the +// input struct to add additional fields. +func (i *Input) MarshalJSON() ([]byte, error) { + asm, err := i.UnlockingScript.ToASM() + if err != nil { + return nil, err + } + input := &inputJSON{ + TxID: hex.EncodeToString(i.PreviousTxIDBytes), + Vout: i.PreviousTxOutIndex, + UnlockingScript: &struct { + Asm string `json:"asm"` + Hex string `json:"hex"` + }{ + Asm: asm, + Hex: i.UnlockingScript.String(), + }, + Sequence: i.SequenceNumber, + } + return json.Marshal(input) +} + +// UnmarshalJSON will convert a JSON input to an input. +func (i *Input) UnmarshalJSON(b []byte) error { + var ij inputJSON + if err := json.Unmarshal(b, &ij); err != nil { + return err + } + ptxID, err := hex.DecodeString(ij.TxID) + if err != nil { + return err + } + sig := ij.UnlockingScript + if sig == nil { + sig = ij.ScriptSig + } + s, err := bscript.NewFromHexString(sig.Hex) + if err != nil { + return err + } + i.UnlockingScript = s + i.PreviousTxIDBytes = ptxID + i.PreviousTxOutIndex = ij.Vout + i.SequenceNumber = ij.Sequence + return nil +} + // PreviousTxIDStr returns the Previous TxID as a hex string. func (i *Input) PreviousTxIDStr() string { return hex.EncodeToString(i.PreviousTxIDBytes) diff --git a/output.go b/output.go index 4a2af50d..2c4d6262 100644 --- a/output.go +++ b/output.go @@ -3,6 +3,7 @@ package bt import ( "encoding/binary" "encoding/hex" + "encoding/json" "fmt" "github.com/libsv/go-bt/bscript" @@ -24,6 +25,79 @@ Txout-script / scriptPubKey Script 0 { + o.Satoshis = oj.Satoshis + } else { + o.Satoshis = uint64(oj.Value * 100000000) + } + o.index = oj.Index + o.LockingScript = s + return nil } // LockingScriptHexString returns the locking script diff --git a/tx.go b/tx.go index 7e080ab7..70e86d88 100644 --- a/tx.go +++ b/tx.go @@ -4,6 +4,8 @@ import ( "bytes" "encoding/binary" "encoding/hex" + "encoding/json" + "errors" "fmt" "github.com/libsv/go-bk/crypto" @@ -36,10 +38,64 @@ lock_time if non-zero and sequence numbers are < 0xFFFFFFFF: block height // DO NOT CHANGE ORDER - Optimised memory via malign // type Tx struct { - Inputs []*Input - Outputs []*Output - Version uint32 - LockTime uint32 + Inputs []*Input `json:"vin"` + Outputs []*Output `json:"vout"` + Version uint32 `json:"version"` + LockTime uint32 `json:"locktime"` +} + +type txJSON struct { + Version uint32 `json:"version"` + LockTime uint32 `json:"locktime"` + TxID string `json:"txid"` + Hash string `json:"hash"` + Size int `json:"size"` + Hex string `json:"hex"` + Inputs []*Input `json:"vin"` + Outputs []*Output `json:"vout"` +} + +// MarshalJSON will serialise a transaction to json. +func (tx *Tx) MarshalJSON() ([]byte, error) { + if tx == nil { + return nil, errors.New("tx is nil so cannot be marshalled") + } + for i, o := range tx.Outputs { + o.index = i + } + txj := txJSON{ + Version: tx.Version, + LockTime: tx.LockTime, + Inputs: tx.Inputs, + Outputs: tx.Outputs, + TxID: tx.TxID(), + Hash: tx.TxID(), + Size: len(tx.ToBytes()), + Hex: tx.String(), + } + return json.Marshal(txj) +} + +// UnmarshalJSON will unmarshall a transaction that has been marshalled with this library. +func (tx *Tx) UnmarshalJSON(b []byte) error { + var txj txJSON + if err := json.Unmarshal(b, &txj); err != nil { + return err + } + // quick convert + if txj.Hex != "" { + t, err := NewTxFromString(txj.Hex) + if err != nil { + return err + } + *tx = *t + return nil + } + tx.Inputs = txj.Inputs + tx.Outputs = txj.Outputs + tx.LockTime = txj.LockTime + tx.Version = txj.Version + return nil } // NewTx creates a new transaction object with default values. @@ -115,6 +171,7 @@ func NewTxFromStream(b []byte) (*Tx, int, error) { if err != nil { return nil, 0, err } + output.index = int(i) offset += size t.Outputs = append(t.Outputs, output) } diff --git a/tx_test.go b/tx_test.go index 331e09e7..b1216dc1 100644 --- a/tx_test.go +++ b/tx_test.go @@ -3,9 +3,12 @@ package bt_test import ( "context" "encoding/hex" + "encoding/json" + "fmt" "reflect" "testing" + "github.com/libsv/go-bk/wif" . "github.com/libsv/go-bk/wif" "github.com/libsv/go-bt" "github.com/libsv/go-bt/bscript" @@ -271,3 +274,376 @@ func TestTx_HasDataOutputs(t *testing.T) { assert.Equal(t, false, tx.HasDataOutputs()) }) } + +func TestTx_ToJson(t *testing.T) { + tx, _ := bt.NewTxFromString("0100000001abad53d72f342dd3f338e5e3346b492440f8ea821f8b8800e318f461cc5ea5a2010000006a4730440220042edc1302c5463e8397120a56b28ea381c8f7f6d9bdc1fee5ebca00c84a76e2022077069bbdb7ed701c4977b7db0aba80d41d4e693112256660bb5d674599e390cf41210294639d6e4249ea381c2e077e95c78fc97afe47a52eb24e1b1595cd3fdd0afdf8ffffffff02000000000000000008006a0548656c6c6f7f030000000000001976a914b85524abf8202a961b847a3bd0bc89d3d4d41cc588ac00000000") + + bb, err := json.MarshalIndent(tx, "", "\t") + assert.NoError(t, err) + fmt.Println(string(bb)) +} + +func TestTx_JSON(t *testing.T) { + tests := map[string]struct { + tx *bt.Tx + err error + }{ + "standard tx should marshal and unmarshall correctly": { + tx: func() *bt.Tx { + tx := bt.NewTx() + assert.NoError(t, tx.From( + "3c8edde27cb9a9132c22038dac4391496be9db16fd21351565cc1006966fdad5", + 0, + "76a914eb0bd5edba389198e73f8efabddfc61666969ff788ac", + 2000000)) + assert.NoError(t, tx.PayTo("n2wmGVP89x3DsLNqk3NvctfQy9m9pvt7mk", 1000)) + var wif *WIF + wif, err := DecodeWIF("KznvCNc6Yf4iztSThoMH6oHWzH9EgjfodKxmeuUGPq5DEX5maspS") + assert.NoError(t, err) + assert.NotNil(t, wif) + + _, err = tx.SignAuto(context.Background(), &bt.LocalSigner{PrivateKey: wif.PrivKey}) + assert.NoError(t, err) + return tx + }(), + }, "data tx should marshall correctly": { + tx: func() *bt.Tx { + tx := bt.NewTx() + assert.NoError(t, tx.From( + "3c8edde27cb9a9132c22038dac4391496be9db16fd21351565cc1006966fdad5", + 0, + "76a914eb0bd5edba389198e73f8efabddfc61666969ff788ac", + 2000000)) + assert.NoError(t, tx.PayTo("n2wmGVP89x3DsLNqk3NvctfQy9m9pvt7mk", 1000)) + var wif *WIF + wif, err := DecodeWIF("KznvCNc6Yf4iztSThoMH6oHWzH9EgjfodKxmeuUGPq5DEX5maspS") + assert.NoError(t, err) + assert.NotNil(t, wif) + s := &bscript.Script{} + assert.NoError(t, s.AppendPushDataString("test")) + tx.AddOutput(&bt.Output{ + LockingScript: s, + }) + _, err = tx.SignAuto(context.Background(), &bt.LocalSigner{PrivateKey: wif.PrivKey}) + assert.NoError(t, err) + return tx + }(), + }, + } + for name, test := range tests { + t.Run(name, func(t *testing.T) { + bb, err := json.Marshal(test.tx) + assert.NoError(t, err) + if err != nil { + return + } + var tx *bt.Tx + assert.NoError(t, json.Unmarshal(bb, &tx)) + assert.Equal(t, test.tx.String(), tx.String()) + }) + } +} + +func TestTx_MarshallJSON(t *testing.T) { + tests := map[string]struct { + tx *bt.Tx + expJSON string + }{ + "transaction with 1 input 1 p2pksh output 1 data output should create valid json": { + tx: func() *bt.Tx { + tx, err := bt.NewTxFromString("0100000001abad53d72f342dd3f338e5e3346b492440f8ea821f8b8800e318f461cc5ea5a2010000006a4730440220042edc1302c5463e8397120a56b28ea381c8f7f6d9bdc1fee5ebca00c84a76e2022077069bbdb7ed701c4977b7db0aba80d41d4e693112256660bb5d674599e390cf41210294639d6e4249ea381c2e077e95c78fc97afe47a52eb24e1b1595cd3fdd0afdf8ffffffff02000000000000000008006a0548656c6c6f7f030000000000001976a914b85524abf8202a961b847a3bd0bc89d3d4d41cc588ac00000000") + assert.NoError(t, err) + return tx + }(), + expJSON: `{ + "version": 1, + "locktime": 0, + "txid": "aec245f27b7640c8b1865045107731bfb848115c573f7da38166074b1c9e475d", + "hash": "aec245f27b7640c8b1865045107731bfb848115c573f7da38166074b1c9e475d", + "size": 208, + "hex": "0100000001abad53d72f342dd3f338e5e3346b492440f8ea821f8b8800e318f461cc5ea5a2010000006a4730440220042edc1302c5463e8397120a56b28ea381c8f7f6d9bdc1fee5ebca00c84a76e2022077069bbdb7ed701c4977b7db0aba80d41d4e693112256660bb5d674599e390cf41210294639d6e4249ea381c2e077e95c78fc97afe47a52eb24e1b1595cd3fdd0afdf8ffffffff02000000000000000008006a0548656c6c6f7f030000000000001976a914b85524abf8202a961b847a3bd0bc89d3d4d41cc588ac00000000", + "vin": [ + { + "unlockingScript": { + "asm": "30440220042edc1302c5463e8397120a56b28ea381c8f7f6d9bdc1fee5ebca00c84a76e2022077069bbdb7ed701c4977b7db0aba80d41d4e693112256660bb5d674599e390cf41 0294639d6e4249ea381c2e077e95c78fc97afe47a52eb24e1b1595cd3fdd0afdf8", + "hex": "4730440220042edc1302c5463e8397120a56b28ea381c8f7f6d9bdc1fee5ebca00c84a76e2022077069bbdb7ed701c4977b7db0aba80d41d4e693112256660bb5d674599e390cf41210294639d6e4249ea381c2e077e95c78fc97afe47a52eb24e1b1595cd3fdd0afdf8" + }, + "txid": "a2a55ecc61f418e300888b1f82eaf84024496b34e3e538f3d32d342fd753adab", + "vout": 1, + "sequence": 4294967295 + } + ], + "vout": [ + { + "value": 0, + "satoshis": 0, + "n": 0, + "lockingScript": { + "asm": "OP_FALSE OP_RETURN 48656c6c6f", + "hex": "006a0548656c6c6f", + "type": "nulldata" + } + }, + { + "value": 0.00000895, + "satoshis": 895, + "n": 1, + "lockingScript": { + "asm": "OP_DUP OP_HASH160 b85524abf8202a961b847a3bd0bc89d3d4d41cc5 OP_EQUALVERIFY OP_CHECKSIG", + "hex": "76a914b85524abf8202a961b847a3bd0bc89d3d4d41cc588ac", + "reqSigs": 1, + "type": "pubkeyhash" + } + } + ] +}`, + }, "transaction with multiple inputs": { + tx: func() *bt.Tx { + tx := bt.NewTx() + assert.NoError(t, tx.From( + "3c8edde27cb9a9132c22038dac4391496be9db16fd21351565cc1006966fdad5", + 0, + "76a914eb0bd5edba389198e73f8efabddfc61666969ff788ac", + 10000)) + assert.NoError(t, tx.From( + "3c8edde27cb9a9132c22038dac4391496be9db16fd21351565cc1006966fdad5", + 2, + "76a914eb0bd5edba389198e73f8efabddfc61666969ff788ac", + 10000)) + assert.NoError(t, tx.From( + "3c8edde27cb9a9132c22038dac4391496be9db16fd21351565cc1006966fdad5", + 114, + "76a914eb0bd5edba389198e73f8efabddfc61666969ff788ac", + 10000)) + assert.NoError(t, tx.PayTo("n2wmGVP89x3DsLNqk3NvctfQy9m9pvt7mk", 1000)) + var w *wif.WIF + w, err := wif.DecodeWIF("KznvCNc6Yf4iztSThoMH6oHWzH9EgjfodKxmeuUGPq5DEX5maspS") + assert.NoError(t, err) + assert.NotNil(t, w) + _, err = tx.SignAuto(context.Background(), &bt.LocalSigner{PrivateKey: w.PrivKey}) + assert.NoError(t, err) + return tx + }(), + expJSON: `{ + "version": 1, + "locktime": 0, + "txid": "41741af6fb64839c69f2385987eb3770c55c42eb6f7900fa2af9d667c42ceb20", + "hash": "41741af6fb64839c69f2385987eb3770c55c42eb6f7900fa2af9d667c42ceb20", + "size": 486, + "hex": "0100000003d5da6f960610cc65153521fd16dbe96b499143ac8d03222c13a9b97ce2dd8e3c000000006b48304502210081214df575da1e9378f1d5a29dfd6811e93466a7222fb010b7c50dd2d44d7f2e0220399bb396336d2e294049e7db009926b1b30018ac834ee0cbca20b9d99f488038412102798913bc057b344de675dac34faafe3dc2f312c758cd9068209f810877306d66ffffffffd5da6f960610cc65153521fd16dbe96b499143ac8d03222c13a9b97ce2dd8e3c0200000069463043021f7059426d6aeb7d74275e52819a309b2bf903bd18b2b4d942d0e8e037681df702203f851f8a45aabfefdca5822f457609600f5d12a173adc09c6e7e2d4fdff7620a412102798913bc057b344de675dac34faafe3dc2f312c758cd9068209f810877306d66ffffffffd5da6f960610cc65153521fd16dbe96b499143ac8d03222c13a9b97ce2dd8e3c720000006b483045022100e7b3837f2818fe00a05293e0f90e9005d59b0c5c8890f22bd31c36190a9b55e9022027de4b77b78139ea21b9fd30876a447bbf29662bd19d7914028c607bccd772e4412102798913bc057b344de675dac34faafe3dc2f312c758cd9068209f810877306d66ffffffff01e8030000000000001976a914eb0bd5edba389198e73f8efabddfc61666969ff788ac00000000", + "vin": [ + { + "unlockingScript": { + "asm": "304502210081214df575da1e9378f1d5a29dfd6811e93466a7222fb010b7c50dd2d44d7f2e0220399bb396336d2e294049e7db009926b1b30018ac834ee0cbca20b9d99f48803841 02798913bc057b344de675dac34faafe3dc2f312c758cd9068209f810877306d66", + "hex": "48304502210081214df575da1e9378f1d5a29dfd6811e93466a7222fb010b7c50dd2d44d7f2e0220399bb396336d2e294049e7db009926b1b30018ac834ee0cbca20b9d99f488038412102798913bc057b344de675dac34faafe3dc2f312c758cd9068209f810877306d66" + }, + "txid": "3c8edde27cb9a9132c22038dac4391496be9db16fd21351565cc1006966fdad5", + "vout": 0, + "sequence": 4294967295 + }, + { + "unlockingScript": { + "asm": "3043021f7059426d6aeb7d74275e52819a309b2bf903bd18b2b4d942d0e8e037681df702203f851f8a45aabfefdca5822f457609600f5d12a173adc09c6e7e2d4fdff7620a41 02798913bc057b344de675dac34faafe3dc2f312c758cd9068209f810877306d66", + "hex": "463043021f7059426d6aeb7d74275e52819a309b2bf903bd18b2b4d942d0e8e037681df702203f851f8a45aabfefdca5822f457609600f5d12a173adc09c6e7e2d4fdff7620a412102798913bc057b344de675dac34faafe3dc2f312c758cd9068209f810877306d66" + }, + "txid": "3c8edde27cb9a9132c22038dac4391496be9db16fd21351565cc1006966fdad5", + "vout": 2, + "sequence": 4294967295 + }, + { + "unlockingScript": { + "asm": "3045022100e7b3837f2818fe00a05293e0f90e9005d59b0c5c8890f22bd31c36190a9b55e9022027de4b77b78139ea21b9fd30876a447bbf29662bd19d7914028c607bccd772e441 02798913bc057b344de675dac34faafe3dc2f312c758cd9068209f810877306d66", + "hex": "483045022100e7b3837f2818fe00a05293e0f90e9005d59b0c5c8890f22bd31c36190a9b55e9022027de4b77b78139ea21b9fd30876a447bbf29662bd19d7914028c607bccd772e4412102798913bc057b344de675dac34faafe3dc2f312c758cd9068209f810877306d66" + }, + "txid": "3c8edde27cb9a9132c22038dac4391496be9db16fd21351565cc1006966fdad5", + "vout": 114, + "sequence": 4294967295 + } + ], + "vout": [ + { + "value": 0.00001, + "satoshis": 1000, + "n": 0, + "lockingScript": { + "asm": "OP_DUP OP_HASH160 eb0bd5edba389198e73f8efabddfc61666969ff7 OP_EQUALVERIFY OP_CHECKSIG", + "hex": "76a914eb0bd5edba389198e73f8efabddfc61666969ff788ac", + "reqSigs": 1, + "type": "pubkeyhash" + } + } + ] +}`, + }, + } + for name, test := range tests { + t.Run(name, func(t *testing.T) { + bb, err := json.MarshalIndent(test.tx, "", "\t") + assert.NoError(t, err) + assert.Equal(t, test.expJSON, string(bb)) + }) + } +} + +func TestTx_UnmarshalJSON(t *testing.T) { + t.Parallel() + tests := map[string]struct { + json string + expTX *bt.Tx + }{ + "our json with hex should map correctly": { + json: `{ + "version": 1, + "locktime": 0, + "txid": "aec245f27b7640c8b1865045107731bfb848115c573f7da38166074b1c9e475d", + "hash": "aec245f27b7640c8b1865045107731bfb848115c573f7da38166074b1c9e475d", + "size": 208, + "hex": "0100000001abad53d72f342dd3f338e5e3346b492440f8ea821f8b8800e318f461cc5ea5a2010000006a4730440220042edc1302c5463e8397120a56b28ea381c8f7f6d9bdc1fee5ebca00c84a76e2022077069bbdb7ed701c4977b7db0aba80d41d4e693112256660bb5d674599e390cf41210294639d6e4249ea381c2e077e95c78fc97afe47a52eb24e1b1595cd3fdd0afdf8ffffffff02000000000000000008006a0548656c6c6f7f030000000000001976a914b85524abf8202a961b847a3bd0bc89d3d4d41cc588ac00000000", + "vin": [ + { + "unlockingScript": { + "asm": "30440220042edc1302c5463e8397120a56b28ea381c8f7f6d9bdc1fee5ebca00c84a76e2022077069bbdb7ed701c4977b7db0aba80d41d4e693112256660bb5d674599e390cf41 0294639d6e4249ea381c2e077e95c78fc97afe47a52eb24e1b1595cd3fdd0afdf8", + "hex": "4730440220042edc1302c5463e8397120a56b28ea381c8f7f6d9bdc1fee5ebca00c84a76e2022077069bbdb7ed701c4977b7db0aba80d41d4e693112256660bb5d674599e390cf41210294639d6e4249ea381c2e077e95c78fc97afe47a52eb24e1b1595cd3fdd0afdf8" + }, + "txid": "a2a55ecc61f418e300888b1f82eaf84024496b34e3e538f3d32d342fd753adab", + "vout": 1, + "sequence": 4294967295 + } + ], + "vout": [ + { + "value": 0, + "satoshis": 0, + "n": 0, + "lockingScript": { + "asm": "OP_FALSE OP_RETURN 48656c6c6f", + "hex": "006a0548656c6c6f", + "type": "nulldata" + } + }, + { + "value": 0.00000895, + "satoshis": 895, + "n": 1, + "lockingScript": { + "asm": "OP_DUP OP_HASH160 b85524abf8202a961b847a3bd0bc89d3d4d41cc5 OP_EQUALVERIFY OP_CHECKSIG", + "hex": "76a914b85524abf8202a961b847a3bd0bc89d3d4d41cc588ac", + "reqSigs": 1, + "type": "pubkeyhash" + } + } + ] + }`, + expTX: func() *bt.Tx { + tx, err := bt.NewTxFromString("0100000001abad53d72f342dd3f338e5e3346b492440f8ea821f8b8800e318f461cc5ea5a2010000006a4730440220042edc1302c5463e8397120a56b28ea381c8f7f6d9bdc1fee5ebca00c84a76e2022077069bbdb7ed701c4977b7db0aba80d41d4e693112256660bb5d674599e390cf41210294639d6e4249ea381c2e077e95c78fc97afe47a52eb24e1b1595cd3fdd0afdf8ffffffff02000000000000000008006a0548656c6c6f7f030000000000001976a914b85524abf8202a961b847a3bd0bc89d3d4d41cc588ac00000000") + assert.NoError(t, err) + return tx + }(), + }, "ONLY hex should map correctly": { + json: `{ + "hex": "0100000001abad53d72f342dd3f338e5e3346b492440f8ea821f8b8800e318f461cc5ea5a2010000006a4730440220042edc1302c5463e8397120a56b28ea381c8f7f6d9bdc1fee5ebca00c84a76e2022077069bbdb7ed701c4977b7db0aba80d41d4e693112256660bb5d674599e390cf41210294639d6e4249ea381c2e077e95c78fc97afe47a52eb24e1b1595cd3fdd0afdf8ffffffff02000000000000000008006a0548656c6c6f7f030000000000001976a914b85524abf8202a961b847a3bd0bc89d3d4d41cc588ac00000000" + }`, + expTX: func() *bt.Tx { + tx, err := bt.NewTxFromString("0100000001abad53d72f342dd3f338e5e3346b492440f8ea821f8b8800e318f461cc5ea5a2010000006a4730440220042edc1302c5463e8397120a56b28ea381c8f7f6d9bdc1fee5ebca00c84a76e2022077069bbdb7ed701c4977b7db0aba80d41d4e693112256660bb5d674599e390cf41210294639d6e4249ea381c2e077e95c78fc97afe47a52eb24e1b1595cd3fdd0afdf8ffffffff02000000000000000008006a0548656c6c6f7f030000000000001976a914b85524abf8202a961b847a3bd0bc89d3d4d41cc588ac00000000") + assert.NoError(t, err) + return tx + }(), + }, "Node json with hex should map correctly": { + json: `{ + "version": 1, + "locktime": 0, + "txid": "aec245f27b7640c8b1865045107731bfb848115c573f7da38166074b1c9e475d", + "hash": "aec245f27b7640c8b1865045107731bfb848115c573f7da38166074b1c9e475d", + "size": 208, + "hex": "0100000001abad53d72f342dd3f338e5e3346b492440f8ea821f8b8800e318f461cc5ea5a2010000006a4730440220042edc1302c5463e8397120a56b28ea381c8f7f6d9bdc1fee5ebca00c84a76e2022077069bbdb7ed701c4977b7db0aba80d41d4e693112256660bb5d674599e390cf41210294639d6e4249ea381c2e077e95c78fc97afe47a52eb24e1b1595cd3fdd0afdf8ffffffff02000000000000000008006a0548656c6c6f7f030000000000001976a914b85524abf8202a961b847a3bd0bc89d3d4d41cc588ac00000000", + "vin": [ + { + "scriptSig": { + "asm": "30440220042edc1302c5463e8397120a56b28ea381c8f7f6d9bdc1fee5ebca00c84a76e2022077069bbdb7ed701c4977b7db0aba80d41d4e693112256660bb5d674599e390cf41 0294639d6e4249ea381c2e077e95c78fc97afe47a52eb24e1b1595cd3fdd0afdf8", + "hex": "4730440220042edc1302c5463e8397120a56b28ea381c8f7f6d9bdc1fee5ebca00c84a76e2022077069bbdb7ed701c4977b7db0aba80d41d4e693112256660bb5d674599e390cf41210294639d6e4249ea381c2e077e95c78fc97afe47a52eb24e1b1595cd3fdd0afdf8" + }, + "txid": "a2a55ecc61f418e300888b1f82eaf84024496b34e3e538f3d32d342fd753adab", + "vout": 1, + "sequence": 4294967295 + } + ], + "vout": [ + { + "value": 0, + "n": 0, + "scriptPubKey": { + "asm": "OP_FALSE OP_RETURN 48656c6c6f", + "hex": "006a0548656c6c6f", + "type": "nulldata" + } + }, + { + "value": 0.00000895, + "n": 1, + "scriptPubKey": { + "asm": "OP_DUP OP_HASH160 b85524abf8202a961b847a3bd0bc89d3d4d41cc5 OP_EQUALVERIFY OP_CHECKSIG", + "hex": "76a914b85524abf8202a961b847a3bd0bc89d3d4d41cc588ac", + "reqSigs": 1, + "type": "pubkeyhash" + } + } + ] + }`, + expTX: func() *bt.Tx { + tx, err := bt.NewTxFromString("0100000001abad53d72f342dd3f338e5e3346b492440f8ea821f8b8800e318f461cc5ea5a2010000006a4730440220042edc1302c5463e8397120a56b28ea381c8f7f6d9bdc1fee5ebca00c84a76e2022077069bbdb7ed701c4977b7db0aba80d41d4e693112256660bb5d674599e390cf41210294639d6e4249ea381c2e077e95c78fc97afe47a52eb24e1b1595cd3fdd0afdf8ffffffff02000000000000000008006a0548656c6c6f7f030000000000001976a914b85524abf8202a961b847a3bd0bc89d3d4d41cc588ac00000000") + assert.NoError(t, err) + return tx + }(), + }, "Node json without hex should map correctly": { + json: `{ + "version": 1, + "locktime": 0, + "txid": "aec245f27b7640c8b1865045107731bfb848115c573f7da38166074b1c9e475d", + "hash": "aec245f27b7640c8b1865045107731bfb848115c573f7da38166074b1c9e475d", + "size": 208, + "vin": [{ + "scriptSig": { + "asm": "30440220042edc1302c5463e8397120a56b28ea381c8f7f6d9bdc1fee5ebca00c84a76e2022077069bbdb7ed701c4977b7db0aba80d41d4e693112256660bb5d674599e390cf41 0294639d6e4249ea381c2e077e95c78fc97afe47a52eb24e1b1595cd3fdd0afdf8", + "hex": "4730440220042edc1302c5463e8397120a56b28ea381c8f7f6d9bdc1fee5ebca00c84a76e2022077069bbdb7ed701c4977b7db0aba80d41d4e693112256660bb5d674599e390cf41210294639d6e4249ea381c2e077e95c78fc97afe47a52eb24e1b1595cd3fdd0afdf8" + }, + "txid": "a2a55ecc61f418e300888b1f82eaf84024496b34e3e538f3d32d342fd753adab", + "vout": 1, + "sequence": 4294967295 + }], + "vout": [{ + "value": 0, + "n": 0, + "scriptPubKey": { + "asm": "OP_FALSE OP_RETURN 48656c6c6f", + "hex": "006a0548656c6c6f", + "type": "nulldata" + } + }, + { + "value": 0.00000895, + "n": 1, + "scriptPubKey": { + "asm": "OP_DUP OP_HASH160 b85524abf8202a961b847a3bd0bc89d3d4d41cc5 OP_EQUALVERIFY OP_CHECKSIG", + "hex": "76a914b85524abf8202a961b847a3bd0bc89d3d4d41cc588ac", + "reqSigs": 1, + "type": "pubkeyhash" + } + } + ] +}`, + expTX: func() *bt.Tx { + tx, err := bt.NewTxFromString("0100000001abad53d72f342dd3f338e5e3346b492440f8ea821f8b8800e318f461cc5ea5a2010000006a4730440220042edc1302c5463e8397120a56b28ea381c8f7f6d9bdc1fee5ebca00c84a76e2022077069bbdb7ed701c4977b7db0aba80d41d4e693112256660bb5d674599e390cf41210294639d6e4249ea381c2e077e95c78fc97afe47a52eb24e1b1595cd3fdd0afdf8ffffffff02000000000000000008006a0548656c6c6f7f030000000000001976a914b85524abf8202a961b847a3bd0bc89d3d4d41cc588ac00000000") + assert.NoError(t, err) + return tx + }(), + }, + } + for name, test := range tests { + t.Run(name, func(t *testing.T) { + var tx *bt.Tx + err := json.Unmarshal([]byte(test.json), &tx) + assert.NoError(t, err) + assert.Equal(t, test.expTX, tx) + }) + } +} diff --git a/txchange.go b/txchange.go index 6fd3c36c..238d7f22 100644 --- a/txchange.go +++ b/txchange.go @@ -31,9 +31,9 @@ func (tx *Tx) Change(s *bscript.Script, f *FeeQuote) error { return nil } -// ChangeToOutput will calculate fees and add them to an output at the index specified (0 based). +// ChangeToExistingOutput will calculate fees and add them to an output at the index specified (0 based). // If an invalid index is supplied and error is returned. -func (tx *Tx) ChangeToOutput(index uint, f *FeeQuote) error { +func (tx *Tx) ChangeToExistingOutput(index uint, f *FeeQuote) error { if int(index) > len(tx.Outputs)-1 { return errors.New("index is greater than number of inputs in transaction") } diff --git a/txchange_test.go b/txchange_test.go index 27ca4cd3..5c630416 100644 --- a/txchange_test.go +++ b/txchange_test.go @@ -380,7 +380,7 @@ func TestTx_ChangeToOutput(t *testing.T) { } for name, test := range tests { t.Run(name, func(t *testing.T) { - err := test.tx.ChangeToOutput(test.index, test.fees) + err := test.tx.ChangeToExistingOutput(test.index, test.fees) if test.err != nil { assert.Error(t, err) assert.Equal(t, test.err, err) diff --git a/txoutput_test.go b/txoutput_test.go index 587dcd58..d0e9ca76 100644 --- a/txoutput_test.go +++ b/txoutput_test.go @@ -5,9 +5,10 @@ import ( "fmt" "testing" + "github.com/stretchr/testify/assert" + "github.com/libsv/go-bt" "github.com/libsv/go-bt/bscript" - "github.com/stretchr/testify/assert" ) func TestNewP2PKHOutputFromPubKeyHashStr(t *testing.T) {