Skip to content

fix(platform-wallet): pool the same funding sources for a contact payment as for a plain send - #4378

Merged
QuantumExplorer merged 4 commits into
fix/dashpay-drain-retry-costfrom
fix/dashpay-pooled-contact-payment
Aug 11, 2026
Merged

fix(platform-wallet): pool the same funding sources for a contact payment as for a plain send#4378
QuantumExplorer merged 4 commits into
fix/dashpay-drain-retry-costfrom
fix/dashpay-pooled-contact-payment

Conversation

@romchornyi

@romchornyi romchornyi commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Stacked on #4373 (which is stacked on #4372). Its own diff is one file plus a test.

Issue being fixed or feature implemented

#4329 made sends draw from BIP44 + BIP32 + every DashPay receiving account. Its own words:

A user holding funds on BIP32 or a DashPay contact-receiving account saw them in the total and got "insufficient funds" trying to spend them.

The DashPay pay-to-contact path never picked that up. Its entire share of #4329 was a mechanical rename:

-                .set_funding(managed_account, account)
+                .add_funding(managed_account, account)

The funding set stayed pinned to BIP44 account 0. So the bug #4329 fixed still reproduces — on the one path where contact-received funds are most likely to be sitting.

Reported from mainnet, straight after the legacy-contact fix in #4372 made 29 contacts payable: eight contact payments drained BIP44, and the ninth failed with

Transaction building failed: Coin selection error:
Insufficient funds: available 41505, required 100000

on a home screen showing plenty. The money was in the contact-receiving accounts — spendable, signable, and simply not offered to selection.

What was done?

send_payment now walks SEND_FUNDING_SOURCES through the same resolve_source_accounts the core path uses, rather than reimplementing the resolution (it is pub(crate) now, and re-exported from wallet::core).

  • BIP44 is still offered first, so the builder keeps taking the change address from it — change returns to BIP44 exactly as before.
  • CoinJoin stays excluded by construction: spending mixed outputs beside transparent ones links them and undoes the mixing.
  • Contact external accounts stay excluded too — they hold the counterparty's xpub and no key this wallet can sign with.
  • A source the wallet does not have contributes nothing rather than failing the send: a wallet with no BIP32 account, or no contacts, still pays from BIP44.

Two consequences of pooling, both handled:

  1. The signer closure resolved derivation paths from the one funding account (managed_account.address_derivation_path). It now consults a map built across every offered account.
  2. A rejected broadcast released reservations on BIP44 only. Inputs drawn from a BIP32 or receiving account would have stayed reserved until the TTL backstop, and an immediate retry would fail with a spurious insufficient-funds. Release now covers every account that offered inputs — a superset of the contributors, which is the safe direction (releasing on an account that supplied nothing is a no-op).

How Has This Been Tested?

cargo test -p platform-wallet — 670 pass. cargo clippy --all-targets clean, cargo fmt applied.

New test contact_payment_funds_from_a_dashpay_receiving_account: leaves BIP44 empty, registers a contact-receiving account and gives it the wallet's only 1,000,000 duffs, then pays the contact 100,000. Reaching the signer at all proves the receiving account was offered to selection.

Verified it catches the defect rather than merely passing — pinned back to BIP44-only funding it fails on the insufficient-funds assertion:

the contact-receiving account's 1_000_000 duffs must be offered to selection —
BIP44-only funding is the bug this pins, got: ... Insufficient funds ...

rejected_broadcast_releases_every_pooled_funding_account covers the release path: BIP44 and a contact-receiving account hold 60,000 duffs each and the payment is 100,000, so neither covers it alone — selection must take from both, and the retry after the rejection can only succeed if both reservations were released. The retry is the assertion.

Also verified by falsification — pinned back to the BIP44-only release it fails exactly as a user would experience it:

an immediate retry must reselect every pooled input ...:
TransactionBuild("Coin selection error: Insufficient funds: available 60000, required 100000")

The contact-receiving account's 60,000 stayed reserved until the TTL backstop.

Breaking Changes

None. A contact payment that succeeded before still succeeds and still takes its change to BIP44; the change only widens what the selector may draw from.

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 added "!" to the title and described breaking changes in the corresponding section if my code contains any
  • I have made corresponding changes to the documentation if needed

For repository code-owners and collaborators only

  • I have assigned this pull request to a milestone

…ment as for a plain send

#4329 made sends draw from BIP44 + BIP32 + every DashPay receiving account,
because a wallet holding funds outside BIP44 saw them in its wallet-wide total
and got "insufficient funds" trying to spend them. The DashPay pay-to-contact
path never picked that up: its whole share of that PR was the mechanical
`set_funding` → `add_funding` rename, leaving the funding set pinned to BIP44
account 0.

So the bug #4329 fixed still reproduces, on the one path where contact-received
funds are most likely to be sitting. Reported from mainnet: after 8 contact
payments drained BIP44, the next one failed with `Insufficient funds: available
41505, required 100000` on a screen showing plenty — the money was in the
contact-receiving accounts.

`send_payment` now walks `SEND_FUNDING_SOURCES` through the same
`resolve_source_accounts` the core path uses, rather than reimplementing the
resolution. BIP44 is still offered first, so change keeps returning to it.
CoinJoin stays excluded by construction, and so do the contact *external*
accounts — those hold the counterparty's xpub and no key this wallet can sign
with. A source the wallet does not have contributes nothing instead of failing
the send.

Two consequences of pooling, handled:
- the signer closure resolved derivation paths from the single funding account,
  so it now consults a map built across every offered account;
- a rejected broadcast released reservations on BIP44 only, which would strand
  inputs drawn from a BIP32 or receiving account until the TTL backstop and
  fail an immediate retry with a spurious insufficient-funds. It now releases
  across every account that offered inputs.

`contact_payment_funds_from_a_dashpay_receiving_account` leaves BIP44 empty and
funds only the receiving account. Verified it catches the defect rather than
merely passing: pinned back to BIP44-only it fails on the insufficient-funds
assertion.
@coderabbitai

coderabbitai Bot commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: b4417e47-df4f-494f-a057-54b1dfc03ca9

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

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

@thepastaclaw

thepastaclaw commented Aug 11, 2026

Copy link
Copy Markdown
Collaborator

🕓 Ready for review — 3 ahead in queue (commit ebe696f)
Queue position: 4/5 · 1 review active
ETA: start ~22:15 UTC · complete ~22:25 UTC (median 10m across 30 recent reviews; 2 slots)
Queued 5m ago · Last checked: 2026-08-11 22:00 UTC

romchornyi pushed a commit to dashpay/dashwallet-ios that referenced this pull request Aug 11, 2026
Build 29 carried dashpay/platform#4372 + #4373 and proved the legacy-contact
fix on mainnet (29/29 external accounts built, 8 payments). It then hit a
second, separate bug: contact payments fund only from BIP44, so the ninth
payment failed with 'Insufficient funds: available 41505' while the balance
showed plenty.

30 adds dashpay/platform#4378, which pools BIP44 + BIP32 + DashPay receiving
accounts on the contact-payment path — the funding set a plain send has used
since #4329.
@romchornyi

romchornyi commented Aug 11, 2026

Copy link
Copy Markdown
Contributor Author

Confirmed on testnet

Two runs, each decisive in one direction. They are different wallets — the failure was captured on one before this PR existed, the success on another after — so this is not a same-wallet A/B; each half stands on its own arithmetic.

Without this PR — wallet holding 90,998,870 duffs in BIP44 and 17,498,551 elsewhere (contact-receiving + BIP32); the pay sheet offered Available: 1.08497421 DASH, i.e. the pooled total:

Transaction building failed: Coin selection error:
Insufficient funds: available 90998870, required 105000000

available is BIP44 to the duff. The UI promised the pooled balance and the selector was given one account.

With this PR — wallet holding 6,899,172 duffs in BIP44 plus a 500,000 duff contact payment received minutes earlier:

TXSEND :: pay-to-contact starting — 7000000 duffs
DashPay payment broadcast amount_duffs=7000000
  txid=4a8bb77e8402f2615184ede7dd4dec8d25d73773955a61c3e028369e56dfd0bc
TXSEND :: pay-to-contact broadcast, fee 374 duffs

7,000,000 exceeds that wallet's BIP44 by 100,828 duffs, so it is only satisfiable by reaching the contact-receiving account — which is exactly what contact_payment_funds_from_a_dashpay_receiving_account asserts in a unit test, here reproduced end to end against live testnet.

An earlier run on the same stack also confirmed the parent PRs on mainnet: 29/29 legacy contacts built their external accounts and 8 contact payments went through — which is what exhausted BIP44 and surfaced this bug in the first place.

…broadcast

This PR's description listed the pooled release as the one change without a
test, on the grounds that the harness had no rejecting broadcaster after a
pooled build. It has both pieces — `with_rejecting_broadcaster` and
`plant_receival_utxo` — so the gap was mine, not the harness's.

`rejected_broadcast_releases_every_pooled_funding_account` funds BIP44 and a
contact-receiving account with 60_000 each and pays 100_000: neither side
covers it alone, so selection must take from both and a retry can only succeed
if both reservations were released. The retry is the assertion.

Verified it catches the defect rather than merely passing — pinned back to the
BIP44-only release it fails exactly as a user would experience it:

    an immediate retry must reselect every pooled input ...:
    TransactionBuild("Coin selection error: Insufficient funds:
    available 60000, required 100000")

The contact-receiving account's 60_000 stayed reserved until the TTL backstop.
@QuantumExplorer
QuantumExplorer merged commit fea6e95 into fix/dashpay-drain-retry-cost Aug 11, 2026
3 checks passed
@QuantumExplorer
QuantumExplorer deleted the fix/dashpay-pooled-contact-payment branch August 11, 2026 22:05
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.

4 participants