diff --git a/errors.go b/errors.go index c62fb8a6..ed077c2b 100644 --- a/errors.go +++ b/errors.go @@ -66,10 +66,12 @@ var ( // Sentinal errors reported by PSBTs. var ( - ErrDummyInput = errors.New("failed to add dummy input 0") - ErrInsufficientUTXOs = errors.New("need at least 3 utxos") - ErrUTXOInputMismatch = errors.New("utxo and input mismatch") - ErrInvalidSellOffer = errors.New("invalid sell offer (partially signed tx)") - ErrOrdinalOutputNoExist = errors.New("ordinal output expected in index 2 doesn't exist") - ErrOrdinalInputNoExist = errors.New("ordinal input expected in index 2 doesn't exist") + ErrDummyInput = errors.New("failed to add dummy input 0") + ErrInsufficientUTXOs = errors.New("need at least 2 utxos") + ErrInsufficientUTXOValue = errors.New("need at least 1 utxos which is > ordinal price") + ErrUTXOInputMismatch = errors.New("utxo and input mismatch") + ErrInvalidSellOffer = errors.New("invalid sell offer (partially signed tx)") + ErrOrdinalOutputNoExist = errors.New("ordinal output expected in index 2 doesn't exist") + ErrOrdinalInputNoExist = errors.New("ordinal input expected in index 2 doesn't exist") + ErrEmptyScripts = errors.New("at least one of needed scripts is empty") ) diff --git a/ord/list.go b/ord/list.go index 6489dff8..4c6160a6 100644 --- a/ord/list.go +++ b/ord/list.go @@ -93,9 +93,8 @@ type AcceptListingArgs struct { // AcceptOrdinalSaleListing accepts a partially signed Bitcoin // transaction offer to sell an ordinal. When accepting the offer, -// you will need to provide at least 3 UTXOs - with the first 2 -// being dummy utxos that will just pass through, and the rest with -// the required payment and tx fees. +// you will need to provide at least 2 UTXOs - with at least 1 being +// larger than the listed ordinal price. func AcceptOrdinalSaleListing(ctx context.Context, vla *ValidateListingArgs, asoa *AcceptListingArgs) (*bt.Tx, error) { if valid := vla.Validate(asoa.PSTx); !valid { return nil, bt.ErrInvalidSellOffer @@ -103,49 +102,70 @@ func AcceptOrdinalSaleListing(ctx context.Context, vla *ValidateListingArgs, aso sellerOrdinalInput := asoa.PSTx.Inputs[0] sellerOutput := asoa.PSTx.Outputs[0] - if len(asoa.UTXOs) < 3 { + if len(asoa.UTXOs) < 2 { return nil, bt.ErrInsufficientUTXOs } + if asoa.BuyerReceiveOrdinalScript == nil || + asoa.DummyOutputScript == nil || + asoa.ChangeScript == nil { + return nil, bt.ErrEmptyScripts + } + + // check at least 1 utxo is larger than the listed ordinal price + validUTXOFound := false + for i, u := range asoa.UTXOs { + if u.Satoshis > sellerOutput.Satoshis { + // Move the UTXO at index i to the beginning + asoa.UTXOs = append([]*bt.UTXO{asoa.UTXOs[i]}, append(asoa.UTXOs[:i], asoa.UTXOs[i+1:]...)...) + validUTXOFound = true + break + } + } + if !validUTXOFound { + return nil, bt.ErrInsufficientUTXOValue + } + tx := bt.NewTx() - // add dummy inputs - err := tx.FromUTXOs(asoa.UTXOs[0], asoa.UTXOs[1]) + // add first input to pay for ordinal + err := tx.FromUTXOs(asoa.UTXOs[0]) if err != nil { - return nil, fmt.Errorf(`failed to add inputs: %w`, err) + return nil, fmt.Errorf(`failed to add input: %w`, err) } tx.Inputs = append(tx.Inputs, sellerOrdinalInput) - // add payment input(s) - err = tx.FromUTXOs(asoa.UTXOs[2:]...) + // add input(s) to pay for tx fees + err = tx.FromUTXOs(asoa.UTXOs[1:]...) if err != nil { return nil, fmt.Errorf(`failed to add inputs: %w`, err) } - // add dummy output to passthrough dummy inputs + // add dummy output tx.AddOutput(&bt.Output{ LockingScript: asoa.DummyOutputScript, - Satoshis: asoa.UTXOs[0].Satoshis + asoa.UTXOs[1].Satoshis, + Satoshis: asoa.UTXOs[0].Satoshis - sellerOutput.Satoshis, }) + tx.AddOutput(sellerOutput) + // add ordinal receive output tx.AddOutput(&bt.Output{ LockingScript: asoa.BuyerReceiveOrdinalScript, Satoshis: 1, }) - tx.AddOutput(sellerOutput) - err = tx.Change(asoa.ChangeScript, asoa.FQ) if err != nil { return nil, err } + //nolint:dupl // false positive for i, u := range asoa.UTXOs { - // skip 3rd input (ordinals input) + // skip 2nd input (ordinals input) j := i - if i >= 2 { + if i >= 1 { j++ } diff --git a/ord/list2dummies.go b/ord/list2dummies.go new file mode 100644 index 00000000..7cd55b37 --- /dev/null +++ b/ord/list2dummies.go @@ -0,0 +1,88 @@ +package ord + +import ( + "bytes" + "context" + "fmt" + + "github.com/libsv/go-bt/v2" +) + +// AcceptOrdinalSaleListing2Dummies accepts a partially signed Bitcoin +// transaction offer to sell an ordinal. When accepting the offer, +// you will need to provide at least 3 UTXOs - with the first 2 +// being dummy utxos that will just pass through, and the rest with +// the required payment and tx fees. +func AcceptOrdinalSaleListing2Dummies(ctx context.Context, vla *ValidateListingArgs, + asoa *AcceptListingArgs) (*bt.Tx, error) { + + if valid := vla.Validate(asoa.PSTx); !valid { + return nil, bt.ErrInvalidSellOffer + } + sellerOrdinalInput := asoa.PSTx.Inputs[0] + sellerOutput := asoa.PSTx.Outputs[0] + + if len(asoa.UTXOs) < 3 { + return nil, bt.ErrInsufficientUTXOs + } + + tx := bt.NewTx() + + // add dummy inputs + err := tx.FromUTXOs(asoa.UTXOs[0], asoa.UTXOs[1]) + if err != nil { + return nil, fmt.Errorf(`failed to add inputs: %w`, err) + } + + tx.Inputs = append(tx.Inputs, sellerOrdinalInput) + + // add payment input(s) + err = tx.FromUTXOs(asoa.UTXOs[2:]...) + if err != nil { + return nil, fmt.Errorf(`failed to add inputs: %w`, err) + } + + // add dummy output to passthrough dummy inputs + tx.AddOutput(&bt.Output{ + LockingScript: asoa.DummyOutputScript, + Satoshis: asoa.UTXOs[0].Satoshis + asoa.UTXOs[1].Satoshis, + }) + + // add ordinal receive output + tx.AddOutput(&bt.Output{ + LockingScript: asoa.BuyerReceiveOrdinalScript, + Satoshis: 1, + }) + + tx.AddOutput(sellerOutput) + + err = tx.Change(asoa.ChangeScript, asoa.FQ) + if err != nil { + return nil, err + } + + //nolint:dupl // false positive + for i, u := range asoa.UTXOs { + // skip 3rd input (ordinals input) + j := i + if i >= 2 { + j++ + } + + if tx.Inputs[j] == nil { + return nil, fmt.Errorf("input expected at index %d doesn't exist", j) + } + if !(bytes.Equal(u.TxID, tx.Inputs[j].PreviousTxID())) { + return nil, bt.ErrUTXOInputMismatch + } + if *u.Unlocker == nil { + return nil, fmt.Errorf("UTXO unlocker at index %d not found", i) + } + err = tx.FillInput(ctx, *u.Unlocker, bt.UnlockerParams{InputIdx: uint32(j)}) + if err != nil { + return nil, err + } + } + + return tx, nil +} diff --git a/ord/list_test.go b/ord/list_test.go index 05901dd8..2614a01d 100644 --- a/ord/list_test.go +++ b/ord/list_test.go @@ -37,7 +37,7 @@ func TestOfferToSellPSBTNoErrors(t *testing.T) { pstx, CreateListingError := ord.ListOrdinalForSale(context.Background(), &ord.ListOrdinalArgs{ SellerReceiveOutput: &bt.Output{ - Satoshis: 500, + Satoshis: 1000, LockingScript: func() *bscript.Script { s, _ := bscript.NewP2PKHFromAddress("1C3V9TTJefP8Hft96sVf54mQyDJh8Ze4w4") // L1JWiLZtCkkqin41XtQ2Jxo1XGxj1R4ydT2zmxPiaeQfuyUK631D return s @@ -58,8 +58,50 @@ func TestOfferToSellPSBTNoErrors(t *testing.T) { assert.True(t, vla.Validate(pstx)) }) + us := []*bt.UTXO{ + { + TxID: func() []byte { + t, _ := hex.DecodeString("8f027fb1361ae46ac165e1d90e5436ed9c11d4eeaa60669ab90386a3abd9ce6a") + return t + }(), + Vout: uint32(1), + LockingScript: ordPrefixScript, + Satoshis: 953, + Unlocker: &ordUnlocker, + }, + { + TxID: func() []byte { + t, _ := hex.DecodeString("fcc55cd1a4275e5750070381028d3e3edf99b238bdc56199ff8bdc17dfb599d1") + return t + }(), + Vout: uint32(3), + LockingScript: ordPrefixScript, + Satoshis: 27601, + Unlocker: &ordUnlocker, + }, + } + buyerOrdS, _ := bscript.NewP2PKHFromAddress("1HebepswCi6huw1KJ7LvkrgemAV63TyVUs") // KwQq67d4Jds3wxs3kQHB8PPwaoaBQfNKkzAacZeMesb7zXojVYpj + dummyS, _ := bscript.NewP2PKHFromAddress("19NfKd8aTwvb5ngfP29RxgfQzZt8KAYtQo") // L5W2nyKUCsDStVUBwZj2Q3Ph5vcae4bgdzprZDYqDpvZA8AFguFH + changeS, _ := bscript.NewP2PKHFromAddress("19NfKd8aTwvb5ngfP29RxgfQzZt8KAYtQo") // L5W2nyKUCsDStVUBwZj2Q3Ph5vcae4bgdzprZDYqDpvZA8AFguFH + t.Run("no errors when accepting listing", func(t *testing.T) { - us := []*bt.UTXO{ + + _, err := ord.AcceptOrdinalSaleListing(context.Background(), &ord.ValidateListingArgs{ + ListedOrdinalUTXO: ordUTXO, + }, + &ord.AcceptListingArgs{ + PSTx: pstx, + UTXOs: us, + BuyerReceiveOrdinalScript: buyerOrdS, + DummyOutputScript: dummyS, + ChangeScript: changeS, + FQ: bt.NewFeeQuote(), + }) + assert.NoError(t, err) + }) + + t.Run("no errors when accepting listing using 2 dummies", func(t *testing.T) { + us = append([]*bt.UTXO{ { TxID: func() []byte { t, _ := hex.DecodeString("61dfcc313763eb5332c036131facdf92c2ca9d663ffb96e4b997086a0643d635") @@ -80,23 +122,9 @@ func TestOfferToSellPSBTNoErrors(t *testing.T) { Satoshis: 10, Unlocker: &ordUnlocker, }, - { - TxID: func() []byte { - t, _ := hex.DecodeString("8f027fb1361ae46ac165e1d90e5436ed9c11d4eeaa60669ab90386a3abd9ce6a") - return t - }(), - Vout: uint32(1), - LockingScript: ordPrefixScript, - Satoshis: 953, - Unlocker: &ordUnlocker, - }, - } + }, us...) - buyerOrdS, _ := bscript.NewP2PKHFromAddress("1HebepswCi6huw1KJ7LvkrgemAV63TyVUs") // KwQq67d4Jds3wxs3kQHB8PPwaoaBQfNKkzAacZeMesb7zXojVYpj - dummyS, _ := bscript.NewP2PKHFromAddress("19NfKd8aTwvb5ngfP29RxgfQzZt8KAYtQo") // L5W2nyKUCsDStVUBwZj2Q3Ph5vcae4bgdzprZDYqDpvZA8AFguFH - changeS, _ := bscript.NewP2PKHFromAddress("19NfKd8aTwvb5ngfP29RxgfQzZt8KAYtQo") // L5W2nyKUCsDStVUBwZj2Q3Ph5vcae4bgdzprZDYqDpvZA8AFguFH - - _, err := ord.AcceptOrdinalSaleListing(context.Background(), &ord.ValidateListingArgs{ + _, err := ord.AcceptOrdinalSaleListing2Dummies(context.Background(), &ord.ValidateListingArgs{ ListedOrdinalUTXO: ordUTXO, }, &ord.AcceptListingArgs{