diff --git a/wallet/addresses.go b/wallet/addresses.go index 6b28bf378..882570b88 100644 --- a/wallet/addresses.go +++ b/wallet/addresses.go @@ -1,4 +1,4 @@ -// Copyright (c) 2017-2025 The Decred developers +// Copyright (c) 2017-2026 The Decred developers // Use of this source code is governed by an ISC // license that can be found in the LICENSE file. @@ -948,6 +948,7 @@ type p2PKHChangeSource struct { wallet *Wallet ctx context.Context gapPolicy gapPolicy + minChange dcrutil.Amount } func (src *p2PKHChangeSource) Script() ([]byte, uint16, error) { @@ -965,6 +966,10 @@ func (src *p2PKHChangeSource) ScriptSize() int { return txsizes.P2PKHPkScriptSize } +func (src *p2PKHChangeSource) MinimumChange() dcrutil.Amount { + return src.minChange +} + // p2PKHTreasuryChangeSource is the change source that shall be used when there // is change on an OP_TADD treasury send. type p2PKHTreasuryChangeSource struct { diff --git a/wallet/createtx.go b/wallet/createtx.go index d1d8068ca..7ada5e97f 100644 --- a/wallet/createtx.go +++ b/wallet/createtx.go @@ -1,5 +1,5 @@ // Copyright (c) 2013-2016 The btcsuite developers -// Copyright (c) 2015-2025 The Decred developers +// Copyright (c) 2015-2026 The Decred developers // Use of this source code is governed by an ISC // license that can be found in the LICENSE file. @@ -1030,6 +1030,7 @@ func (w *Wallet) mixedSplit(ctx context.Context, req *PurchaseTicketsRequest, ne wallet: w, ctx: ctx, gapPolicy: gapPolicyIgnore, + minChange: smallestMixChange(relayFee), } var err error atx, err = txauthor.NewUnsignedTransaction(mixOut, relayFee, @@ -1058,9 +1059,6 @@ func (w *Wallet) mixedSplit(ctx context.Context, req *PurchaseTicketsRequest, ne if atx.ChangeIndex >= 0 { change = atx.Tx.TxOut[atx.ChangeIndex] } - if change != nil && dcrutil.Amount(change.Value) < smallestMixChange(relayFee) { - change = nil - } gen := w.makeGen(ctx, req.MixedSplitAccount, req.MixedAccountBranch) expires := w.dicemixExpiry(ctx) cj := mixclient.NewCoinJoin(gen, change, int64(neededPerTicket), expires, uint32(req.Count)) diff --git a/wallet/txauthor/author.go b/wallet/txauthor/author.go index 8c11152ba..4eaacfa4c 100644 --- a/wallet/txauthor/author.go +++ b/wallet/txauthor/author.go @@ -1,5 +1,5 @@ // Copyright (c) 2016 The btcsuite developers -// Copyright (c) 2016-2024 The Decred developers +// Copyright (c) 2016-2026 The Decred developers // Use of this source code is governed by an ISC // license that can be found in the LICENSE file. @@ -62,6 +62,14 @@ type ChangeSource interface { ScriptSize() int } +// MinimumChangeSource is an optional extension of ChangeSource that specifies +// a minimum change amount. When the change would be less than this minimum, +// it is dropped (added to the fee) instead of creating a change output. +type MinimumChangeSource interface { + ChangeSource + MinimumChange() dcrutil.Amount +} + func sumOutputValues(outputs []*wire.TxOut) (totalOutput dcrutil.Amount) { for _, txOut := range outputs { totalOutput += dcrutil.Amount(txOut.Value) @@ -99,6 +107,10 @@ func NewUnsignedTransaction(outputs []*wire.TxOut, relayFeePerKb dcrutil.Amount, return nil, errors.E(op, err) } changeScriptSize := fetchChange.ScriptSize() + var minChange dcrutil.Amount + if mcs, ok := fetchChange.(MinimumChangeSource); ok { + minChange = mcs.MinimumChange() + } maxSignedSize := txsizes.EstimateSerializeSize(scriptSizes, outputs, changeScriptSize) targetFee := txrules.FeeForSerializeSize(relayFeePerKb, maxSignedSize) @@ -137,8 +149,8 @@ func NewUnsignedTransaction(outputs []*wire.TxOut, relayFeePerKb dcrutil.Amount, } changeIndex := -1 changeAmount := inputDetail.Amount - targetAmount - maxRequiredFee - if changeAmount != 0 && !txrules.IsDustAmount(changeAmount, - changeScriptSize, relayFeePerKb) { + if changeAmount != 0 && changeAmount >= minChange && + !txrules.IsDustAmount(changeAmount, changeScriptSize, relayFeePerKb) { if len(changeScript) > txscript.MaxScriptElementSize { return nil, errors.E(errors.Invalid, "script size exceed maximum bytes "+ "pushable to the stack") diff --git a/wallet/txauthor/author_test.go b/wallet/txauthor/author_test.go index bd5eb855d..891543922 100644 --- a/wallet/txauthor/author_test.go +++ b/wallet/txauthor/author_test.go @@ -1,5 +1,5 @@ // Copyright (c) 2016 The btcsuite developers -// Copyright (c) 2016 The Decred developers +// Copyright (c) 2016-2026 The Decred developers // Use of this source code is governed by an ISC // license that can be found in the LICENSE file. @@ -237,3 +237,86 @@ func TestNewUnsignedTransaction(t *testing.T) { } } } + +type testMinimumChangeSource struct { + AuthorTestChangeSource + minChange dcrutil.Amount +} + +func (src testMinimumChangeSource) MinimumChange() dcrutil.Amount { + return src.minChange +} + +func TestNewUnsignedTransactionMinimumChange(t *testing.T) { + const relayFee dcrutil.Amount = 1e3 + const minChange dcrutil.Amount = 1e6 + + changeSource := testMinimumChangeSource{minChange: minChange} + + // Compute fee for a 1-in/1-out transaction with change output. + feeWithChange := txrules.FeeForSerializeSize(relayFee, + txsizes.EstimateSerializeSize( + []int{txsizes.RedeemP2PKHSigScriptSize}, + p2pkhOutputs(0), + txsizes.P2PKHPkScriptSize)) + + // Compute fee for a 1-in/1-out transaction without change output. + feeWithoutChange := txrules.FeeForSerializeSize(relayFee, + txsizes.EstimateSerializeSize( + []int{txsizes.RedeemP2PKHSigScriptSize}, + p2pkhOutputs(0), + 0)) + + tests := []struct { + name string + unspentOutputs []*wire.TxOut + outputs []*wire.TxOut + changeAmount dcrutil.Amount + }{ + { + name: "change above minimum is included", + unspentOutputs: p2pkhOutputs(2e6 + feeWithChange), + outputs: p2pkhOutputs(1e6), + changeAmount: 1e6, + }, + { + name: "change below minimum is dropped (despite not being dust)", + unspentOutputs: p2pkhOutputs(1e6 + minChange/2 + feeWithoutChange), + outputs: p2pkhOutputs(1e6), + changeAmount: 0, + }, + { + name: "change exactly at minimum is included", + unspentOutputs: p2pkhOutputs(1e6 + minChange + feeWithChange), + outputs: p2pkhOutputs(1e6), + changeAmount: minChange, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + inputSource := makeInputSource(test.unspentOutputs) + tx, err := txauthor.NewUnsignedTransaction(test.outputs, relayFee, + inputSource, changeSource, chaincfg.MainNetParams().MaxTxSize) + if err != nil { + t.Fatal(err) + } + + if test.changeAmount == 0 { + if tx.ChangeIndex >= 0 { + t.Errorf("expected no change output, got change %v", + dcrutil.Amount(tx.Tx.TxOut[tx.ChangeIndex].Value)) + } + } else { + if tx.ChangeIndex < 0 { + t.Fatalf("expected change output with amount %v, got none", + test.changeAmount) + } + got := dcrutil.Amount(tx.Tx.TxOut[tx.ChangeIndex].Value) + if got != test.changeAmount { + t.Errorf("got change amount %v, expected %v", got, test.changeAmount) + } + } + }) + } +} diff --git a/wallet/wallet.go b/wallet/wallet.go index dbbb9096b..9af545167 100644 --- a/wallet/wallet.go +++ b/wallet/wallet.go @@ -1,5 +1,5 @@ // Copyright (c) 2013-2016 The btcsuite developers -// Copyright (c) 2015-2025 The Decred developers +// Copyright (c) 2015-2026 The Decred developers // Use of this source code is governed by an ISC // license that can be found in the LICENSE file. @@ -4663,23 +4663,67 @@ func (s sigDataSource) GetScript(a stdaddr.Address) ([]byte, error) { return s.s func (w *Wallet) CreateVspPayment(ctx context.Context, tx *wire.MsgTx, fee dcrutil.Amount, feeAddr stdaddr.Address, feeAcct uint32, changeAcct uint32) error { + feeRate := w.RelayFee() + minMixableChange := smallestMixChange(feeRate) + estimatedTxFee := txrules.FeeForSerializeSize(feeRate, + txsizes.EstimateSerializeSizeFromScriptSizes( + []int{txsizes.RedeemP2PKHSigScriptSize}, + []int{txsizes.P2PKHPkScriptSize}, + txsizes.P2PKHPkScriptSize)) + // Reserve new outputs to pay the fee if outputs have not already been // reserved. This will be the case for fee payments that were begun on // already purchased tickets, where the caller did not ensure that fee // outputs would already be reserved. if len(tx.TxIn) == 0 { const minconf = 1 - inputs, err := w.ReserveOutputsForAmount(ctx, feeAcct, fee, minconf) - if err != nil { - return fmt.Errorf("unable to reserve outputs: %w", err) - } - for _, in := range inputs { - tx.AddTxIn(wire.NewTxIn(&in.OutPoint, in.PrevOut.Value, nil)) + var allInputs []Input + + // Reserve enough for the fee, minimum mixable change, and an + // initial transaction fee estimate (based on 1 input). If the + // actual input count makes the real fee higher, iterate to + // reserve additional outputs. + target := fee + minMixableChange + estimatedTxFee + for { + inputs, err := w.ReserveOutputsForAmount(ctx, feeAcct, target, minconf) + if err != nil { + if len(allInputs) > 0 { + break // Can't reserve more; proceed with what we have. + } + return fmt.Errorf("unable to reserve outputs: %w", err) + } + allInputs = append(allInputs, inputs...) + for _, in := range inputs { + tx.AddTxIn(wire.NewTxIn(&in.OutPoint, in.PrevOut.Value, nil)) + } + + // Recalculate the transaction fee with the actual input count. + scriptSizes := slices.Repeat([]int{txsizes.RedeemP2PKHSigScriptSize}, len(tx.TxIn)) + actualTxFee := txrules.FeeForSerializeSize(feeRate, + txsizes.EstimateSerializeSizeFromScriptSizes( + scriptSizes, + []int{txsizes.P2PKHPkScriptSize}, + txsizes.P2PKHPkScriptSize)) + + var totalInput dcrutil.Amount + for _, in := range tx.TxIn { + totalInput += dcrutil.Amount(in.ValueIn) + } + + change := totalInput - fee - actualTxFee + if change >= minMixableChange { + break + } + + // Reserve additional outputs for the shortfall, plus a buffer + // for the fee increase from the new inputs. + target = minMixableChange - change + estimatedTxFee } + // The transaction will be added to the wallet in an unpublished // state, so there is no need to leave the outputs locked. defer func() { - for _, in := range inputs { + for _, in := range allInputs { w.UnlockOutpoint(&in.OutPoint.Hash, in.OutPoint.Index) } }() @@ -4716,16 +4760,13 @@ func (w *Wallet) CreateVspPayment(ctx context.Context, tx *wire.MsgTx, fee dcrut Version: vers, PkScript: feeScript, }) - feeRate := w.RelayFee() - scriptSizes := make([]int, len(tx.TxIn)) - for i := range scriptSizes { - scriptSizes[i] = txsizes.RedeemP2PKHSigScriptSize - } + scriptSizes := slices.Repeat([]int{txsizes.RedeemP2PKHSigScriptSize}, len(tx.TxIn)) est := txsizes.EstimateSerializeSize(scriptSizes, tx.TxOut, txsizes.P2PKHPkScriptSize) change := input change -= tx.TxOut[0].Value change -= int64(txrules.FeeForSerializeSize(feeRate, est)) - if !txrules.IsDustAmount(dcrutil.Amount(change), txsizes.P2PKHPkScriptSize, feeRate) { + if dcrutil.Amount(change) >= minMixableChange && + !txrules.IsDustAmount(dcrutil.Amount(change), txsizes.P2PKHPkScriptSize, feeRate) { changeOut.Value = change tx.TxOut = append(tx.TxOut, changeOut) txauthor.RandomizeOutputPosition(tx.TxOut, len(tx.TxOut)-1)