Skip to content

fix(key-wallet): never offer an outpoint the builder already holds - #931

Merged
ZocoLini merged 1 commit into
devfrom
fix/add-funding-dedup
Aug 7, 2026
Merged

fix(key-wallet): never offer an outpoint the builder already holds#931
ZocoLini merged 1 commit into
devfrom
fix/add-funding-dedup

Conversation

@QuantumExplorer

@QuantumExplorer QuantumExplorer commented Aug 7, 2026

Copy link
Copy Markdown
Member

What was fixed

add_funding (the additive replacement for set_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_inputs with a caller-chosen outpoint, or an earlier add_funding of an overlapping account — the outpoint became two candidates.

Coin selection performs no outpoint deduplication. SelectionStrategy::All clones 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_funding it replaced did self.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_input seeds a UTXO via add_inputs, funds the account that owns it, and drains with SelectionStrategy::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. Full key-wallet suite green (628 passed), clippy --all-targets -D warnings clean, cargo fmt clean.

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 (Swift addInputs), which seeds the builder with UTXOs drawn from the wallet's own account, and then finalizes through add_funding on 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

  • Bug Fixes
    • Improved transaction funding to prevent duplicate inputs when multiple funding sources overlap.
    • Ensured existing transaction inputs are correctly accounted for and reserved during funding.
    • Added safeguards to produce transactions with unique previous outputs and more reliable input selection.

`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>
@coderabbitai

coderabbitai Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: c81b66c3-9268-445e-8fcc-967ee23964f3

📥 Commits

Reviewing files that changed from the base of the PR and between 944e53a and 14eaa66.

📒 Files selected for processing (1)
  • key-wallet/src/wallet/managed_wallet_info/transaction_builder.rs

📝 Walkthrough

Walkthrough

add_funding now prevents duplicate candidate outpoints and records pre-seeded eligible inputs for reservation. A regression test validates unique transaction prevouts and ownership-based reservation.

Changes

Funding outpoint deduplication

Layer / File(s) Summary
Funding selection and reservation validation
key-wallet/src/wallet/managed_wallet_info/transaction_builder.rs
add_funding tracks existing inputs, skips duplicate candidates, and records eligible account-owned outpoints. The regression test verifies unique prevouts and reservation by the owning account.

Estimated code review effort: 2 (Simple) | ~10 minutes

Possibly related PRs

Suggested labels: ready-for-review

Suggested reviewers: zocolini, xdustinface, romchornyi

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the main fix: preventing the builder from receiving duplicate outpoints.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/add-funding-dedup

Comment @coderabbitai help to get the list of available commands.

@codecov

codecov Bot commented Aug 7, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 75.51%. Comparing base (944e53a) to head (14eaa66).

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     
Flag Coverage Δ
core 77.29% <ø> (ø)
ffi 51.49% <ø> (+1.64%) ⬆️
rpc 20.00% <ø> (ø)
spv 91.28% <ø> (+0.01%) ⬆️
wallet 76.88% <100.00%> (+0.03%) ⬆️
Files with missing lines Coverage Δ
.../wallet/managed_wallet_info/transaction_builder.rs 89.30% <100.00%> (+0.34%) ⬆️

... and 19 files with indirect coverage changes

QuantumExplorer added a commit to dashpay/platform that referenced this pull request Aug 7, 2026
…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>
@github-actions github-actions Bot added the ready-for-review CodeRabbit has approved this PR label Aug 7, 2026
@ZocoLini
ZocoLini merged commit 6be2407 into dev Aug 7, 2026
38 checks passed
@ZocoLini
ZocoLini deleted the fix/add-funding-dedup branch August 7, 2026 15:47
QuantumExplorer added a commit that referenced this pull request Aug 10, 2026
…ounts (#944)

* feat(key-wallet): fund an asset lock from a caller-chosen list of accounts

An asset lock could only ever be funded from ONE account. A wallet holding
its balance across the standard families and its DashPay contact-receiving
accounts had to sweep them into BIP44 first and lock out of that — an extra
on-chain hop, an extra fee, and a transparent address reused for the privilege.
The send path stopped needing that in dashpay/platform#4329; this is the same
change for asset locks.

Both builders now take a LIST of `AccountTypePreference` sources plus a
`source_index` instead of a single `AssetLockFundingAccount`, and fold them
through the same `transaction_building::fund` the send path uses: coin
selection draws from the union, the first source supplies the change address,
overlapping sources fund each account once (#931's dedup is what makes the
repeated `add_funding` safe), and derivation paths are collected across every
contributing account so the inputs can be signed.

Which accounts to pool is the CALLER's decision, not this library's. The FFI
entry point takes the source list as a parameter (`FFIAccountTypePreference`,
a tag plus the two identity IDs the DashPay kinds read) rather than applying a
default of its own: a client wanting today's behavior passes a single `BIP44`
source, one wanting the whole spendable balance passes the wider list, and one
funding out of a specific contact names that friendship. An empty list is
rejected instead of falling through to `AccountTypePreference::DEFAULT`, since
defaulting there would be this layer choosing a funding policy that only the
client library knows.

Reservation bookkeeping is the part that had to change shape. A pooled build
reserves in EACH contributing account's own set under the one owner token, so
the post-build failure paths — credit-key derivation on the soft-wallet
builder, the peek/sign/commit loop on the signer builder, both running after
the transaction is already signed — now release across every funded account
instead of just the one. Releasing a single account's set would have stranded
the rest of the inputs until the 24-block TTL sweep. `AssetLockResult` carries
the contributing accounts so the caller's rejected-broadcast release can reach
them all; it is the contributor list, not everything the sources offered, so a
wallet's address book does not inflate the caller's bookkeeping.

`fund`'s strictness rule now matches platform's: a SINGLE named source is
strict (a caller asking for exactly one account's funds must not silently be
given another's), while a pooled list skips the sources this wallet has nothing
for — no BIP32 account, no contacts — and errors only when none of them funds
anything. Without that, a pooled set would fail on the very wallets it is meant
to serve.

CoinJoin funding is unchanged and stays excluded from pooling: it remains
drain-only, and it must now be the sole source, because spending mixed outputs
alongside transparent ones in one transaction links them and undoes the mixing.
The `AssetLockFundingAccount::CoinJoin` + `drain: true` flow that
dashpay/platform#4327 ships on converts to a single-element source list and
behaves exactly as before.

`AssetLockError::AccountNotFound(u32)` is removed — account resolution is now
the builder's, and it reports `BuilderError::AccountNotFound` with the source
that failed. `AssetLockFundingAccount` remains as the drain flows' single-account
vocabulary, with a `From` conversion into the source list.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

* docs(key-wallet): drop the private intra-doc link from build_asset_lock

`validate_funding_sources` is a private free function, so linking to it from
a public doc comment fails `rustdoc::private_intra_doc_links` under the
Documentation job's `-D warnings`. The link was also useless to a reader of
the public docs, who cannot follow it — state the CoinJoin rule inline instead.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

* docs(key-wallet-ffi): flag CoinJoin as unusable on the non-drain asset lock

`wallet_build_and_sign_asset_lock_transaction` always builds with
`drain: false`, and `validate_funding_sources` rejects a CoinJoin source
outside drain mode — so a caller selecting that kind here always gets
`InvalidData`, even passing it alone. The kind's own doc described the
builder's rule ("sole source, drain only") without saying this entry point
never drains, which reads as though the sole-source form would work.

Raised by CodeRabbit on #944.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

* refactor(key-wallet): share the asset-lock funding and reservation prologue

`build_asset_lock` and `build_asset_lock_with_signer` had drifted into two
near-identical copies of the same prologue: payload assembly, the drain
strategy switch, `fund`, the per-account `ReservationSet` capture, and the
release closure. They differed only in which signer reached
`build_signed_reserved` and in comment wording.

That duplication is a hazard on this specific logic. The reservation capture
has to happen between funding and signing — a pooled build reserves in each
contributing account's own set, and both builders reach their release paths
after the transaction is already signed, with the caller holding no token. A
fix applied to one copy and not the other silently reintroduces stranded
inputs on the path that was missed.

`build_signed_asset_lock` is now the single prologue, generic over
`TransactionSigner` so the soft-wallet builder passes `wallet` and the signer
builder passes `signer`. `BuildReservations` owns the sets, the reserved
outpoints and the token, so releasing is one call that cannot reach only some
of the funded accounts.

Also from review:

- `contributing_accounts` iterates the transaction's inputs rather than every
  UTXO of every offered account. A transaction has few inputs; an offered
  account can hold many UTXOs.
- `From<AssetLockFundingAccount> for AccountTypePreference` documents that the
  index does not survive. The type's own doc said "convert to hand one to a
  builder", and a caller doing only that funds source_index 0 rather than the
  account it named.
- The FFI entry point documents that `funding_sources[i].kind` must be a
  declared discriminant, since reading any other value as the enum is UB and
  so cannot be rejected at the boundary.

Raised by CodeRabbit on #944.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

* test(key-wallet-ffi): cover the caller-supplied asset-lock funding sources

The new boundary had no test of its own: the entry point's marshalling body is
where the funding policy now lives, and nothing exercised it.

Three cases, driven against a created-but-unfunded wallet so an accepted call
fails in coin selection rather than at the guards — which is what tells the
two apart:

- an empty list is rejected with `InvalidInput` instead of being forwarded,
  where it would mean `AccountTypePreference::DEFAULT` and reinstate a policy
  this layer must not choose;
- a well-formed pooled list gets past the guards into the build;
- `CoinJoin` is rejected, pinning the behavior documented in f0d2cbf — this
  entry point always builds non-drain, so mixed funds can never back it.

Prompted by the patch-coverage report on #944.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

* docs(key-wallet-ffi): regenerate FFI_API.md for the discriminant safety note

The `# Safety` requirement added in 5a19149 changed the doc comment that
`scripts/generate_ffi_docs.py` extracts, and the generated file was not
refreshed alongside it — which the verify-ffi pre-commit hook catches.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

* test(key-wallet-ffi): free the wallet handle the asset-lock test borrows

`wallet_manager_get_wallet` returns an independently boxed clone that the
caller owns — its own docs say to free it with `wallet_free_const` — and the
test dropped it, leaking a whole `Wallet` per case. The Address Sanitizer job
caught it: 42144 bytes in 24 allocations, three tests' worth.

Also frees `tx_bytes` defensively. Every case here fails before a transaction
is produced, so it is always null today, but a future success case would leak
it the same way.

Verified by reproducing the exact CI figure locally under
`-Zsanitizer=address` with `detect_leaks=1`, then confirming it goes away.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

* test(key-wallet-ffi): run the asset-lock source cases on both networks

The helper hardcoded Testnet, so every FFI contract case here skipped Mainnet
— against the repo's standing rule to test both configurations. Account
derivation is coin-type-scoped, so a guard exercised on one network only could
hide a network-conditional path.

`call_with_sources` now takes the network and each case loops over both,
naming the network in its assertion messages so a one-sided failure says which.

Raised by CodeRabbit on #944.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: bfoss765 <brian.foster@dash.org>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ready-for-review CodeRabbit has approved this PR

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants