Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion wallet/addresses.go
Original file line number Diff line number Diff line change
@@ -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.

Expand Down Expand Up @@ -948,6 +948,7 @@ type p2PKHChangeSource struct {
wallet *Wallet
ctx context.Context
gapPolicy gapPolicy
minChange dcrutil.Amount
}

func (src *p2PKHChangeSource) Script() ([]byte, uint16, error) {
Expand All @@ -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 {
Expand Down
6 changes: 2 additions & 4 deletions wallet/createtx.go
Original file line number Diff line number Diff line change
@@ -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.

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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))
Expand Down
18 changes: 15 additions & 3 deletions wallet/txauthor/author.go
Original file line number Diff line number Diff line change
@@ -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.

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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")
Expand Down
85 changes: 84 additions & 1 deletion wallet/txauthor/author_test.go
Original file line number Diff line number Diff line change
@@ -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.

Expand Down Expand Up @@ -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)
}
}
})
}
}
69 changes: 55 additions & 14 deletions wallet/wallet.go
Original file line number Diff line number Diff line change
@@ -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.

Expand Down Expand Up @@ -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)
}
}()
Expand Down Expand Up @@ -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)
Expand Down
Loading