fix(key-wallet): never offer an outpoint the builder already holds - #931
Conversation
`add_funding` appends every unreserved UTXO of a funding account to the candidate set. When the builder was already seeded with one of those UTXOs — `add_inputs` with a caller-chosen outpoint, or an earlier `add_funding` of an overlapping account — the same outpoint became TWO candidates. Coin selection does not deduplicate by outpoint: `SelectionStrategy::All` takes every candidate, so the duplicate was guaranteed to reach the transaction, and the ordinary strategies could pick both copies and double-count them toward the target. Either way the result is a transaction spending one prevout twice, which Core rejects. This is specific to additive funding. The `set_funding` it replaced assigned `self.inputs`, so a pre-seeded outpoint was silently dropped rather than duplicated — an invalid transaction is the strictly worse failure, so fix it where the candidates are built. The account still records every unreserved outpoint it owns that is in the candidate pool, including a pre-seeded one, so that if selection picks it the owning account reserves it. Recording only the UTXOs this call appended would leave a pre-seeded input unreserved and free for a concurrent build to select. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (1)
📝 WalkthroughWalkthrough
ChangesFunding outpoint deduplication
Estimated code review effort: 2 (Simple) | ~10 minutes Possibly related PRs
Suggested labels: Suggested reviewers: 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Comment |
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## dev #931 +/- ##
==========================================
+ Coverage 75.28% 75.51% +0.23%
==========================================
Files 328 328
Lines 77982 78017 +35
==========================================
+ Hits 58710 58918 +208
+ Misses 19272 19099 -173
|
…and guard duplicate prevouts Review follow-ups on the pooled send path. Contributors vs offered accounts. `funding_accounts` was pushed when an account's UTXOs were OFFERED to selection, so a transaction funded entirely from BIP44 still reported BIP32 and every DashPay contact as a contributor — violating the field's documented contract and scaling release and registry bookkeeping with the address book. Membership now rides a HashSet (the linear `contains` made pooled funding quadratic in contact count), the ordered offered list drives build-time cleanup only, and the list stored on the transaction is derived by mapping each selected prevout back to the account that owns it. Pooled shortfalls. A pooled build's `available`/`required` describe the union of every offered source, so attributing them to the first preference reported aggregate figures as "insufficient funds on BIP44 account 0" — and could name BIP44 even when no such account existed and it was skipped. Single-source builds keep the account-specific error; pooled builds get `CorePooledInsufficientFunds`, carrying the source list instead of one account. Both map to the same FFI code, so hosts classify a shortfall exactly as before. Duplicate prevouts. Additive funding can offer an outpoint the builder was already seeded with through `add_inputs` (the `add_inputs_from_outpoints` FFI draws from the wallet's own account), and coin selection does not deduplicate, so the transaction could spend one prevout twice — invalid, and Core rejects it. Fixed upstream in dashpay/rust-dashcore#931; asserted here as well rather than handing a signer and then the network a transaction that cannot confirm. Swift `buildSignedPayment` defaulted to `.bip44` while its Kotlin counterpart defaults to pooled, so Swift callers omitting `accountType` could hit an insufficient-funds error with a sufficient pooled balance. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
What was fixed
add_funding(the additive replacement forset_funding, #925) appends every unreserved UTXO of a funding account to the candidate set without checking what the builder already holds. If the builder was seeded with one of those same UTXOs —add_inputswith a caller-chosen outpoint, or an earlieradd_fundingof an overlapping account — the outpoint became two candidates.Coin selection performs no outpoint deduplication.
SelectionStrategy::Allclones every candidate, so the duplicate was guaranteed to reach the transaction; the ordinary strategies can select both copies and double-count them toward the target. Either way the built transaction spends one prevout twice, and Core rejects it.This hazard is specific to additive funding: the
set_fundingit replaced didself.inputs = …, so a pre-seeded outpoint was silently dropped. Losing a caller's explicit input is a bug too, but building an invalid transaction is strictly worse, so the fix filters candidates rather than restoring the overwrite.Reservation correctness
The funding entry still records every unreserved outpoint the account owns that is in the candidate pool — including one pre-seeded by
add_inputs— so if selection picks it, the owning account reserves it. Recording only the UTXOs this call appended would leave a pre-seeded input unreserved and free for a concurrent build to select, which is the double-spend window the reservation system exists to close.Testing
add_funding_does_not_duplicate_a_pre_seeded_inputseeds a UTXO viaadd_inputs, funds the account that owns it, and drains withSelectionStrategy::All. It asserts the outpoint is offered exactly once, that the built transaction has no duplicate prevouts, and that the pre-seeded input is reserved. Verified it fails without the fix (the outpoint appears twice in the candidate list) and passes with it. Fullkey-walletsuite green (628 passed), clippy--all-targets -D warningsclean,cargo fmtclean.Why now
Found while reviewing dashpay/platform#4329, which adopts multi-account funding: platform's FFI exposes
core_wallet_tx_builder_add_inputs_from_outpoints(SwiftaddInputs), which seeds the builder with UTXOs drawn from the wallet's own account, and then finalizes throughadd_fundingon that same account — exactly the sequence that duplicates. That platform PR needs a re-pin onto this fix before it can merge.🤖 Generated with Claude Code
Summary by CodeRabbit