Skip to content

wallet: ensure VSP payment doesn't create unmixable change#2595

Open
matthawkins90 wants to merge 1 commit intodecred:masterfrom
matthawkins90:unmixable_change
Open

wallet: ensure VSP payment doesn't create unmixable change#2595
matthawkins90 wants to merge 1 commit intodecred:masterfrom
matthawkins90:unmixable_change

Conversation

@matthawkins90
Copy link
Copy Markdown
Contributor

@matthawkins90 matthawkins90 commented Dec 6, 2025

This adds a MinimumChangeSource interface to txauthor as an opt-in extension of ChangeSource. When implemented, NewUnsignedTransaction drops change below the specified minimum instead of creating a change output, and correctly recalculates the fee estimate without the change output. Existing ChangeSource implementations that don't implement it are unaffected (the minimum defaults to zero).

p2PKHChangeSource implements the new interface. In mixedSplit, the change source now carries minChange: smallestMixChange(relayFee), so sub-threshold change is dropped at construction time inside NewUnsignedTransaction rather than being nil'd out after the fact. This also fixes a minor fee underestimation in the old code, where the fee was computed assuming a change output that was then removed.

CreateVspPayment is reworked to proactively reserve enough UTXOs for mixable change. The reservation target is now:

fee + minMixableChange + estimatedTxFee

and if the actual input count drives the real transaction fee above the initial 1-input estimate, additional outputs are reserved iteratively until change >= minMixableChange. If no more outputs can be reserved, the function proceeds with what it has and drops sub-threshold change to miner fees rather than creating an unmixable UTXO.

The iterative approach was motivated by my own real wallet data. I used dcrctl/dcrctlw to decode all VSP fee payment transactions from a mixing wallet (listtransactions filtered to regular sends, minus split transactions identified via each ticket's vin[0].txid).

83% of my VSP fee payments used more than 2 inputs, with a median of ~10 and a maximum of 51 inputs. If the code only reserved 1 input, then in the majority of cases, it would drop the change entirely and fragment the account further.

The commands I used to figure this out are:

dcrctlw listtransactions "*" 2000 0 true | jq '[.[] | select(.txtype == "regular" and .category == "send")] | unique_by(.txid) | length'
dcrctlw listtransactions "*" 2000 0 true > /tmp/txlist.json

jq -r '[.[] | select(.txtype == "ticket")] | unique_by(.txid) | sort_by(.time) | reverse | .[].txid' /tmp/txlist.json | while read txid; do hex=$(dcrctlw gettransaction "$txid" | jq -r '.hex') dcrctl decoderawtransaction "$hex" | jq -r '.vin[0].txid' done > /tmp/split_txids.txt
jq -r '[.[] | select(.txtype == "regular" and .category == "send")] | unique_by(.txid) | sort_by(.time) | reverse | .[].txid' /tmp/txlist.json | grep -v -F -f /tmp/split_txids.txt | while read txid; do hex=$(dcrctlw gettransaction "$txid" | jq -r '.hex') count=$(dcrctl decoderawtransaction "$hex" | jq '.vin | length') echo "$count $txid" done | tee /tmp/fee_inputs.txt
awk '{print $1}' /tmp/fee_inputs.txt | sort -n | uniq -c | sort -rn
awk '$1 > 2' /tmp/fee_inputs.txt | wc -l

The change output guard (change >= minMixableChange) is also applied to the pre-populated tx.TxIn path, so callers that supply their own inputs also avoid creating unmixable change. The maximum value absorbed into miner fees in this case is ~0.00265 DCR.

@matthawkins90 matthawkins90 changed the title wallet: ensure VSP payent doesn't create unmixable change wallet: ensure VSP payment doesn't create unmixable change Dec 6, 2025
@matthawkins90
Copy link
Copy Markdown
Contributor Author

matthawkins90 commented Mar 19, 2026

I've been using this branch to buy VSP tickets for a bit now, and it's been working, but something I noticed is this:

If the wallet uses a single input of 0.00262144 (smallest mix output) to pay the VSP fee (for example, 0.088%, or 0.00077579), then the remaining amount of 0.00184565 is just given to miners since it is unmixable.

For the user, this effectively means that the "fee to purchase a ticket via a VSP" is 0.00262144, or ~0.30%, which is about 3.3x more than intended.

Here's a link to an example, where the transaction had a fee rate of 854 atoms/byte instead of the normal 10 atoms/byte: https://dcrdata.decred.org/tx/2c1dbddf185ed7c4e5fe567007628d46f07e2f4b19cce8979a4fc542d897492b

I believe this is actually worse than leaving toxic, unmixable change from these VSP fees in the wallet as dust, because....the DCR is actually now gone instead of just sitting in the wallet as unmixable (and difficult-to-spend if the wallet doesn't allow sending from unmixed accounts).

Ranking the possible outcomes:

  1. Best outcome: VSP fee is paid, and the change is mixable, wasting minimal amounts of DCR after mixing is complete.
  2. Previous behavior: VSP fee is paid, and the change is unmixable, but is kept in the wallet. Can manually combine this toxic "dust" into mixable amounts (although those transactions are toxic until mixed).
  3. This commit's behavior: VSP fee is paid, and the change is unmixable, so it is donated to miners.

A single input of the smallest mix denom is guaranteed to fail the mixability check. Two inputs of the smallest denoms are also going to be mixed, but mixing is going to donate the remaining amount to miners as well, so more inputs doesn't necessarily solve the issue.

I don't know how to get to Outcome 1 where the least amount of DCR is wasted. Open to ideas!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant