Skip to content

wallet: fix redeemdigidollar failure on wallets with fragmented DGB UTXOs - #439

Open
JohnnyLawDGB wants to merge 1 commit into
DigiByte-Core:developfrom
JohnnyLawDGB:fix/dd-redeem-fee-fragmented-utxos
Open

wallet: fix redeemdigidollar failure on wallets with fragmented DGB UTXOs#439
JohnnyLawDGB wants to merge 1 commit into
DigiByte-Core:developfrom
JohnnyLawDGB:fix/dd-redeem-fee-fragmented-utxos

Conversation

@JohnnyLawDGB

Copy link
Copy Markdown

Problem

redeemdigidollar fails on wallets whose DGB is fragmented into small UTXOs, even when the wallet holds ample spendable DGB:

Failed to build redemption transaction: Insufficient fee inputs for DD redemption fee (-4)

This is reachable in ordinary use — any wallet that has accumulated small payments (mining payouts, tips, faucet drips) can hit it, and the practical workaround has been to consolidate UTXOs by hand first. It is the redemption analogue of the fragmented-UTXO mint bug fixed in #396.

Root cause

A fee UTXO's raw value is not its contribution to the fee, because every input added to a DigiDollar transaction enlarges the transaction the fee is computed from.

At the DD fee rate (35,000,000 sat/kvB) and with the per-input accounting in DigiDollar::EstimateTransactionVSize() (41 base bytes + a flat 110 witness bytes, +35% margin ≈ 92 vB), one extra input costs about 3,220,000 sat (~0.0322 DGB) to spend. Any fee UTXO below that threshold has negative effective value — adding it reduces the amount available to pay the fee.

Three things then interact:

  1. DigiDollarWallet::SelectFeeCoins() sorted smallest-first and stopped as soon as the raw sum reached the target — so it selected precisely the UTXOs that cannot pay for their own spending.
  2. redeemdigidollar asked for a fixed (400 vB × feeRate) + 50% estimate that was never revisited — a hard budget of ~600 vB, exhausted after roughly 3–4 fee inputs.
  3. BuildRedemptionTransaction() recomputed the true fee from the transaction it had actually built and failed when the selection came up short, with no re-selection.

Worked example from the regression test: four 0.0525 DGB UTXOs total 21,000,000 sat against a 21,000,000 sat target, but spending them costs 12,880,000 sat, leaving 8,120,000 sat effective — while an untouched 5 DGB UTXO sat in the same wallet.

Note that simply enlarging the fixed estimate does not fix this: a bigger target just pulls in more uneconomic inputs, and the spend cost scales with it.

Fix

SelectFeeCoins() now prices every candidate by effective value (value - EstimateInputSpendCost(fee_rate)), skips UTXOs that cannot pay for their own spending, and requires the target to be met in effective rather than raw value. selected_total keeps its raw meaning. This applies to all callers, because a selection whose raw sum meets the target but whose effective sum does not is arithmetically wrong on every path. The fee rate is now a parameter so the cost tracks what the transaction will actually pay.

Input ordering stays opt-in: a new minimize_inputs flag, off by default, preserves the smallest-first order DD transfers have always used. Only the redemption path sets it.

DigiDollarWallet::SelectRedemptionFeeCoins() applies the pattern this codebase already uses for DD transfers in PreflightDDTransferCapacity(): build a projected transaction from the actually-selected inputs, derive the fee from EstimateTransactionVSize(), and re-select against the updated fee, bounded to six rounds. It preserves the MIN_DD_TX_FEE floor and the MAX_STANDARD_TX_WEIGHT guard, excludes the collateral outpoint and the DD UTXOs being burned, and reports its outcome as a category so the RPC can distinguish a funding shortfall from a transaction that cannot be built.

Failures now name the numbers and tell the user to consolidate, instead of a bare "Insufficient fee inputs".

MIN_DD_FEE_RATE and MIN_DD_TX_FEE move to txbuilder.h, replacing copies scattered across the wallet and RPC.

No consensus code is touched. Mint is unaffected — it funds itself through TxBuilder::SelectCoins (which, incidentally, already sorts largest-first with the comment "This minimizes the number of inputs needed").

Behaviour change worth calling out

senddigidollar on a fragmented wallet changes: selection that previously came up short now succeeds, using several small inputs and paying a correspondingly larger fee. On the wallet in the regression test that is ~0.249 DGB across five small inputs, where a single large input would have cost ~0.12 DGB.

This is a fail → succeed change, so it is an improvement, but it is a real change and reviewers should see it stated rather than discover it. Flipping the transfer default to minimize_inputs would reduce that fee; it is deliberately left out of this PR to keep the blast radius contained, and can follow separately if maintainers prefer.

Testing

Unit (src/test/digidollar_wallet_tests.cpp, all additions — no existing assertion was modified or removed):

  • fragmented uneconomic UTXO selection (the original repro)
  • redemption fee convergence on a fragmented wallet
  • fee-rate independence at 100,000 sat/kvB
  • the collateral/DD exclusion guard
  • dust-only and empty wallets fail with actionable messages

The exclusion-guard test is mutation-checked: injecting exclude_utxos.clear() fails it 5/5.

Functional: new test/functional/digidollar_redeem_fragmented_fees.py — mints $1000 DD, fragments the wallet into 12 × 0.0525 DGB, matures, and redeems. Reverting the source changes reproduces the original Insufficient fee inputs for DD redemption fee (-4) failure; with the fix it passes.

Full unit suite: 3414/3414 passing. digidollar_redemption_e2e.py also still passes, confirming the ordinary redemption path is unaffected.


Prepared with AI assistance; all changes were human-reviewed, and every test result above was independently reproduced before submission.

DigiDollar redemptions failed with "Insufficient fee inputs for DD
redemption fee" on wallets whose DGB was fragmented into small UTXOs,
even when the wallet held plenty of spendable DGB.

DigiDollarWallet::SelectFeeCoins() treated a fee UTXO's raw value as its
contribution to the fee. It is not. Every input added to a DigiDollar
transaction enlarges the transaction the fee is computed from: at the DD
fee rate (35,000,000 sat/kvB) and with the per-input accounting in
DigiDollar::EstimateTransactionVSize() (41 base bytes plus a flat 110
witness bytes, +35% margin, i.e. ~92 vB), one extra input costs about
3,220,000 sat to spend. Any fee UTXO below ~0.0322 DGB therefore has
negative effective value, and SelectFeeCoins() sorted smallest-first and
stopped as soon as the raw sum reached the target, so it selected
precisely those. The caller compounded it: redeemdigidollar asked for a
fixed (400 vB * feeRate) + 50% estimate that was never revisited, while
BuildRedemptionTransaction() recomputed the true fee from the
transaction it had built and failed when the selection came up short,
with no re-selection.

SelectFeeCoins() now prices every candidate by effective value
(value - EstimateInputSpendCost(fee_rate)), skips UTXOs that cannot pay
for their own spending, and requires the target to be met in effective
value rather than raw value; selected_total keeps its raw meaning. This
applies to all callers, because a selection whose raw sum meets the
target but whose effective sum does not is wrong on every path. The fee
rate is a parameter so the cost tracks what the transaction will pay.

Input ordering stays opt-in. A new minimize_inputs flag, off by default,
keeps the smallest-first order DD transfers have always used; only the
redemption path sets it, because it must converge on an exact fee with
as few inputs as possible, the same reasoning as TxBuilder::SelectCoins.
Note that transfers on a fragmented wallet change behaviour: selection
that used to come up short now succeeds, using several small inputs and
paying a correspondingly larger fee.

A new DigiDollarWallet::SelectRedemptionFeeCoins() applies the pattern
the DD transfer path already uses in PreflightDDTransferCapacity(): it
builds a projected transaction from the actually-selected inputs,
derives the fee from EstimateTransactionVSize(), and re-selects against
the updated fee, bounded to six rounds. It excludes the collateral
outpoint and the DD UTXOs being burned, preserves the MIN_DD_TX_FEE
floor and the MAX_STANDARD_TX_WEIGHT guard, and reports its outcome as
a category so the RPC can distinguish a funding shortfall from a
transaction that cannot be built. Both redemption call sites use it in
place of the fixed size guess, and failures now name the numbers and
tell the user to consolidate small DGB UTXOs.

MIN_DD_FEE_RATE and MIN_DD_TX_FEE move to txbuilder.h and replace the
copies scattered across the wallet and RPC.

Mint is unaffected: it funds itself through TxBuilder::SelectCoins. No
consensus code is touched.

Covered by unit tests for fee-input selection and redemption fee
convergence, and by a new functional test,
digidollar_redeem_fragmented_fees.py, which reproduces the original
user-facing failure on regtest: a wallet fragmented into 0.0525 DGB
UTXOs could not redeem a matured vault, and now can.
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