Skip to content

feat(key-wallet): pool BIP44 + BIP32 + DashPay receiving funds on the asset-lock path - #935

Open
bfoss765 wants to merge 1 commit into
dashpay:devfrom
bfoss765:feat/asset-lock-pooled-funding
Open

feat(key-wallet): pool BIP44 + BIP32 + DashPay receiving funds on the asset-lock path#935
bfoss765 wants to merge 1 commit into
dashpay:devfrom
bfoss765:feat/asset-lock-pooled-funding

Conversation

@bfoss765

@bfoss765 bfoss765 commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

Issue being fixed or feature implemented

An asset lock could only ever be funded from ONE account (AssetLockFundingAccount: a BIP44 index, or a CoinJoin index for the drain flow). 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 sweep in dashpay/platform#4329. This is the same change for asset locks, so an invitation / identity registration / top-up funds from the union of the wallet's accounts in one transaction.

What was done?

Both build_asset_lock and build_asset_lock_with_signer now take a list of AccountTypePreference sources plus a source_index, and fold them through the same transaction_building::fund the send path uses:

  • coin selection draws from the union of the resolved accounts' UTXOs;
  • the first source supplies the change address (so the default set's change returns to BIP44);
  • overlapping sources fund each account once — fix(key-wallet): never offer an outpoint the builder already holds #931's duplicate-outpoint dedup in add_funding is what makes the repeated call safe;
  • derivation paths are collected per contributing account, since the inputs no longer share one resolver.

Reservation bookkeeping is the part that had to change shape, and it is where I would look hardest in review. A pooled build reserves in each contributing account's own ReservationSet under the one owner token. The post-build failure paths — credit-key derivation on the soft-wallet builder, the peek → sign → commit loop on the signer builder, both of which run after the transaction is already signed — therefore release across every funded account instead of just the one. Releasing a single account's set would have stranded the remaining inputs until the 24-block TTL sweep, with the caller holding no token to free them (it never received one on the error path). Release stays owner-guarded throughout (release_if_owner, platform#4185).

AssetLockResult gains funding_accounts, so a caller's rejected-broadcast release can reach every account holding a share of the reservation. It is the contributor list — accounts that actually supplied an input — not everything the source list offered: selection routinely takes nothing from most offered accounts, and a list naming every DashPay contact would make the caller's release and bookkeeping scale with the address book while claiming contributions that never happened.

fund's strictness rule now matches platform's finalize_transaction: a single named source is strict (a caller asking for exactly one account's funds must not silently be given another's), 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, the default pooled set would fail on the very wallets it is meant to serve. This also fixes the send path, where a multi-source list previously required every named account to exist (platform reimplemented the fold to work around exactly that).

CoinJoin is unchanged and stays out of the pool

CoinJoin funding remains drain-only, and it must now be the sole source: spending mixed outputs alongside transparent ones in one transaction links them and undoes the mixing — the same reasoning that keeps CoinJoin out of AccountTypePreference::DEFAULT. The AssetLockFundingAccount::CoinJoin + drain: true flow that dashpay/platform#4327 ships on converts to a single-element source list and behaves exactly as before (test_drain_coinjoin_asset_lock is untouched and passing).

Breaking changes

  • ManagedWalletInfo::build_asset_lock / build_asset_lock_with_signer take funding_sources: &[AccountTypePreference], source_index: u32 in place of funding_account: AssetLockFundingAccount. AssetLockFundingAccount remains as the drain flows' single-account vocabulary, with impl From<AssetLockFundingAccount> for AccountTypePreference for the conversion.
  • AssetLockResult gains funding_accounts: Vec<AccountType>.
  • AssetLockError::AccountNotFound(u32) is removed: account resolution is the builder's now, and it reports BuilderError::AccountNotFound naming the source that failed.
  • A multi-source sources list is lenient rather than strict (single-source and empty-list behavior unchanged).

key-wallet-ffi's wallet_build_and_sign_asset_lock_transaction keeps its exact C signature — no funding selector was ever exposed there, so flipping its internal default to the pooled set is the whole change.

How Has This Been Tested?

cargo test -p key-wallet --all-features — 635 passed, 0 failed. cargo check --workspace --all-targets clean; cargo clippy -p key-wallet -p key-wallet-ffi --all-targets --all-features -- -D warnings clean; cargo fmt applied.

New tests, weighted toward the reservation failure paths:

  • pooled_asset_lock_spans_the_standard_accounts — neither account covers the lock alone, so the build only succeeds by pooling; asserts every input is signed (proving paths were collected across accounts), change returns to BIP44, each account reserves its own contribution, and funding_accounts names both.
  • credit_key_failure_releases_the_reservation_in_every_pooled_account and its signer_ sibling — force a failure in the window where the transaction is built, signed and reserved but the caller holds no token, and assert both pooled accounts came out with nothing reserved.
  • signing_failure_releases_the_reservation_in_every_pooled_account — same invariant one layer down, in the builder's own signer-failure release.
  • pooled_sources_skip_what_the_wallet_does_not_have, pooled_sources_that_resolve_to_nothing_are_an_error — the leniency rule and its floor.
  • coinjoin_cannot_be_pooled_with_transparent_sources (drain and exact-amount) — rejected before any wallet state is touched, with nothing reserved.
  • a_pooled_list_skips_absent_sources_where_a_single_one_is_strict — pins the send path's strictness rule on both sides.

Breaking Changes

See "Breaking changes" above.

Checklist:

  • I have performed a self-review of my own code
  • I have commented my code, particularly in hard-to-understand areas
  • I have added or updated relevant unit/integration/functional/e2e tests
  • I have made corresponding changes to the documentation

For repository code-owners and collaborators only

  • I have assigned this pull request to a milestone

Summary by CodeRabbit

  • New Features

    • Asset-lock transactions can now draw funds from multiple supported account types.
    • Change is directed to the designated BIP44 account.
    • Results identify all accounts that contributed funding.
    • Funding automatically skips unavailable accounts when pooling is enabled.
  • Bug Fixes

    • Improved cleanup of temporary reservations when transaction building fails.
    • Added validation for invalid drain outputs and unsupported CoinJoin funding combinations.

… asset-lock path

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 (dashpay#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.

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, the default 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.
@coderabbitai

coderabbitai Bot commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

Asset-lock funding now pools BIP44, BIP32, and DashPay receiving accounts. Builders validate sources, track contributors, and release reservations across accounts after failures. The FFI API passes the shared source list to the builder.

Changes

Pooled asset-lock funding

Layer / File(s) Summary
Pooled funding resolution
key-wallet/src/wallet/managed_wallet_info/transaction_building.rs
fund now resolves strict or pooled sources and returns the builder, derivation paths, and funded accounts.
Asset-lock pooled builders
key-wallet/src/wallet/managed_wallet_info/asset_lock_builder.rs
Wallet and external-signer builders use pooled selection, validate CoinJoin and drain rules, track contributing accounts, and clean up reservations on failures. Tests cover pooled funding and failure paths.
FFI funding-source wiring
key-wallet-ffi/src/transaction.rs
The FFI API documents and passes the shared BIP44, BIP32, and DashPay funding-source list.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Caller
  participant ManagedWalletInfo
  participant TransactionFunding
  participant Reservations
  Caller->>ManagedWalletInfo: Request asset-lock transaction
  ManagedWalletInfo->>TransactionFunding: Fund from pooled account sources
  TransactionFunding-->>ManagedWalletInfo: Return builder, paths, and accounts
  ManagedWalletInfo->>Reservations: Reserve inputs across accounts
  ManagedWalletInfo-->>Caller: Return asset-lock result and funding accounts
Loading

Possibly related PRs

Suggested labels: ready-for-review

Suggested reviewers: quantumexplorer, zocolini, xdustinface

🚥 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 summarizes the primary change: pooled BIP44, BIP32, and DashPay receiving funds for asset-lock transactions.
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 💡 1
🛠️ Fix failing CI checks 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
key-wallet-ffi/src/transaction.rs (1)

862-869: 🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

Expose reservation cleanup for the FFI asset-lock path.

This function discards result.reservation_token and result.funding_accounts. When the host rejects the broadcast, it cannot release reservations for the contributing accounts. The inputs remain reserved until the 24-block TTL sweep. Expose the token and contributing accounts, or add an FFI release function.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@key-wallet-ffi/src/transaction.rs` around lines 862 - 869, Update the FFI
asset-lock flow around managed_wallet.build_asset_lock so it exposes
result.reservation_token and result.funding_accounts to the host, either by
returning them in the existing FFI result or by adding a dedicated FFI release
function. Ensure hosts can release the contributing-account reservations when
broadcast is rejected instead of waiting for TTL cleanup.
🧹 Nitpick comments (3)
key-wallet/src/wallet/managed_wallet_info/asset_lock_builder.rs (3)

89-100: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Document the dropped account_index in this conversion.

From<AssetLockFundingAccount> keeps only the family and drops account_index. A caller that converts a drain source must also pass AssetLockFundingAccount::account_index() as source_index, or the build silently targets account 0. Add a doc comment on the impl to state this requirement.

📝 Proposed documentation
+/// Converts to the pooled-source vocabulary. The account index is NOT carried
+/// over: pass [`AssetLockFundingAccount::account_index`] as the builder's
+/// `source_index`.
 impl From<AssetLockFundingAccount> for AccountTypePreference {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@key-wallet/src/wallet/managed_wallet_info/asset_lock_builder.rs` around lines
89 - 100, Add a doc comment to the From<AssetLockFundingAccount> implementation
explaining that the conversion preserves only the account family and drops
account_index; callers converting a drain source must pass
AssetLockFundingAccount::account_index() as source_index to avoid defaulting to
account 0.

302-310: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Invert the lookup to scale with input count, not UTXO count.

The current filter scans every UTXO of every offered account. A pooled list can name many accounts, and an account can hold thousands of UTXOs, while spent holds only the transaction inputs. Probe the account map with the spent outpoints instead. utxos is a BTreeMap, so each probe is logarithmic.

♻️ Proposed refactor
     offered
         .iter()
         .copied()
         .filter(|account_type| {
             accounts.funds_account(account_type).is_some_and(|account| {
-                account.utxos.keys().any(|outpoint| spent.contains(outpoint))
+                spent.iter().any(|outpoint| account.utxos.contains_key(outpoint))
             })
         })
         .collect()
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@key-wallet/src/wallet/managed_wallet_info/asset_lock_builder.rs` around lines
302 - 310, Update the filtering logic around the offered-account collection to
iterate over the spent outpoints and probe each account’s utxos BTreeMap, rather
than scanning all UTXOs for every account. Retain only offered account types
whose account contains at least one spent outpoint, using logarithmic map
lookups.

477-518: 📐 Maintainability & Code Quality | 🔵 Trivial | 🏗️ Heavy lift

Extract the shared pooled-funding and reservation-capture prologue.

build_asset_lock (lines 361-405) and build_asset_lock_with_signer (lines 477-518) now carry the same builder construction, the same reservation capture across offered accounts, and the same release_reservations closure. The reservation-release logic is correctness-critical. If the two copies drift, one path can strand signed inputs until the TTL sweep.

Extract a private helper that builds the TransactionBuilder, calls self.fund, and returns the builder, paths, offered, and the cloned Vec<ReservationSet>. Both builders then keep only their signer-specific tail.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@key-wallet/src/wallet/managed_wallet_info/asset_lock_builder.rs` around lines
477 - 518, Extract the duplicated funding setup from build_asset_lock and
build_asset_lock_with_signer into a private helper that constructs the
TransactionBuilder, applies the drain selection strategy, calls self.fund, and
captures cloned reservation handles for all offered accounts. Have both methods
use the helper and retain only their signer-specific build_signed_reserved and
release_reservations logic, preserving owner-guarded reservation release.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@key-wallet-ffi/src/transaction.rs`:
- Around line 747-760: Preserve host control over asset-lock funding by adding
an explicit funding-source option to
wallet_build_and_sign_asset_lock_transaction or providing a separate entry point
that retains BIP44-only behavior, while keeping the existing behavior available
to callers. Update the crate changelog and the public header documentation to
clearly describe the new pooled funding behavior and its linkage implications.

---

Outside diff comments:
In `@key-wallet-ffi/src/transaction.rs`:
- Around line 862-869: Update the FFI asset-lock flow around
managed_wallet.build_asset_lock so it exposes result.reservation_token and
result.funding_accounts to the host, either by returning them in the existing
FFI result or by adding a dedicated FFI release function. Ensure hosts can
release the contributing-account reservations when broadcast is rejected instead
of waiting for TTL cleanup.

---

Nitpick comments:
In `@key-wallet/src/wallet/managed_wallet_info/asset_lock_builder.rs`:
- Around line 89-100: Add a doc comment to the From<AssetLockFundingAccount>
implementation explaining that the conversion preserves only the account family
and drops account_index; callers converting a drain source must pass
AssetLockFundingAccount::account_index() as source_index to avoid defaulting to
account 0.
- Around line 302-310: Update the filtering logic around the offered-account
collection to iterate over the spent outpoints and probe each account’s utxos
BTreeMap, rather than scanning all UTXOs for every account. Retain only offered
account types whose account contains at least one spent outpoint, using
logarithmic map lookups.
- Around line 477-518: Extract the duplicated funding setup from
build_asset_lock and build_asset_lock_with_signer into a private helper that
constructs the TransactionBuilder, applies the drain selection strategy, calls
self.fund, and captures cloned reservation handles for all offered accounts.
Have both methods use the helper and retain only their signer-specific
build_signed_reserved and release_reservations logic, preserving owner-guarded
reservation release.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 58c78653-b3e7-423f-9e49-ad03284cc372

📥 Commits

Reviewing files that changed from the base of the PR and between b056d07 and 1a1263a.

📒 Files selected for processing (3)
  • key-wallet-ffi/src/transaction.rs
  • key-wallet/src/wallet/managed_wallet_info/asset_lock_builder.rs
  • key-wallet/src/wallet/managed_wallet_info/transaction_building.rs

Comment on lines +747 to +760
/// The funding sources an asset lock pools, in order — the FIRST source
/// (BIP44) supplies the change address, so change from a pooled asset lock
/// always returns to the transparent primary account.
///
/// CoinJoin is deliberately absent (spending mixed outputs alongside
/// transparent ones links them and undoes the mixing — the same reasoning as
/// `AccountTypePreference::DEFAULT`), and so are a contact's watch-only
/// `DashpayExternalAccount` coins, which `AllDashpayReceivingFunds` excludes by
/// construction (it selects only the receiving side the local seed can sign).
const ASSET_LOCK_FUNDING_SOURCES: [AccountTypePreference; 3] = [
AccountTypePreference::BIP44,
AccountTypePreference::BIP32,
AccountTypePreference::AllDashpayReceivingFunds,
];

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | 🏗️ Heavy lift

The C ABI is unchanged, but the funding behavior changes silently for every existing host.

wallet_build_and_sign_asset_lock_transaction keeps its signature, so a host cannot opt out of the new pooled set. Two consequences follow:

  1. An asset lock can now spend DashPay contact-receiving coins. That links funds received from a contact to the identity funding transaction. A host that previously relied on BIP44-only funding gets this new linkage without any code change.
  2. A host that already swept funds into BIP44 before locking sees no break, but a host that intentionally kept BIP32 or contact funds separate does.

Consider adding a funding-source parameter, or a second entry point that keeps the BIP44-only behavior, so hosts can choose. At minimum, record the behavior change in the crate's changelog and in the header documentation that host developers read.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@key-wallet-ffi/src/transaction.rs` around lines 747 - 760, Preserve host
control over asset-lock funding by adding an explicit funding-source option to
wallet_build_and_sign_asset_lock_transaction or providing a separate entry point
that retains BIP44-only behavior, while keeping the existing behavior available
to callers. Update the crate changelog and the public header documentation to
clearly describe the new pooled funding behavior and its linkage implications.

@codecov

codecov Bot commented Aug 9, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 97.07317% with 12 lines in your changes missing coverage. Please review.
✅ Project coverage is 75.34%. Comparing base (b056d07) to head (1a1263a).

Files with missing lines Patch % Lines
...c/wallet/managed_wallet_info/asset_lock_builder.rs 97.15% 10 Missing ⚠️
key-wallet-ffi/src/transaction.rs 0.00% 2 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##              dev     #935      +/-   ##
==========================================
+ Coverage   75.18%   75.34%   +0.15%     
==========================================
  Files         328      328              
  Lines       78194    78473     +279     
==========================================
+ Hits        58792    59123     +331     
+ Misses      19402    19350      -52     
Flag Coverage Δ
core 77.29% <ø> (ø)
ffi 48.60% <0.00%> (+0.01%) ⬆️
rpc 20.00% <ø> (ø)
spv 91.42% <ø> (+0.05%) ⬆️
wallet 77.34% <97.54%> (+0.45%) ⬆️
Files with missing lines Coverage Δ
...wallet/managed_wallet_info/transaction_building.rs 95.50% <100.00%> (+1.97%) ⬆️
key-wallet-ffi/src/transaction.rs 0.00% <0.00%> (ø)
...c/wallet/managed_wallet_info/asset_lock_builder.rs 93.47% <97.15%> (+3.85%) ⬆️

... and 11 files with indirect coverage changes

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