Skip to content

fix(platform-wallet): build our receiving account for one-way DashPay contacts - #5256

Draft
HashEngineering wants to merge 2 commits into
v5.1-devfrom
fix/dashpay-one-way-contact-receival
Draft

HashEngineering wants to merge 2 commits into
v5.1-devfrom
fix/dashpay-one-way-contact-receival

Conversation

@HashEngineering

@HashEngineering HashEngineering commented Oct 2, 2026 •

Copy link
Copy Markdown
Collaborator

Issue being fixed or feature implemented

Suppose we send a DashPay contact request and the contact never sends one back. That contact never got a DashpayReceivingFunds account. DIP-15 puts our receiving xpub inside the request we send, so the contact can pay us on that chain without ever reciprocating. Two places assumed the opposite:

  • collect_account_build_candidates (contact_requests.rs) walks established_contacts() only, so the sweep never queued a receiving account for a one-way contact.
  • reconcile_dashpay_rescan (payments.rs) skips every receival account whose contact is not established. Even with the account built, the history before it was registered would never be rescanned.

Together, payments on that chain stay invisible at any rescan depth. The live send path (send_contact_request_with_external_signer) registers the account itself. The gap therefore hits wallets that learned of the sent request from Platform (restore from seed, a second device), plus any live registration that failed after the request had been saved.

Seen on testnet (topple wallet 7dc06ad3…):

  • Transaction e5169bfc4989585abd4b0476188611b981e3c750539da5b8a39fe135e3bbb957, height 1,475,820: 0.001 DASH to yMNNc2UZz62V9N7SZfQnsk79G5okCP7eSN. That is our receiving chain 15'/0'/(us)/(4193bbe6…)/0 for contact 5QzA7GnST….
  • The store holds one request for that contact: ours, at core height 1,475,801, 19 blocks before the payment. There is no incoming request and no DashPay account.
  • The payment is missing from every archived SDK store (int6 through int27). dashj's wallet dump has it.
  • Its later spend 6ac8356c… is stored with netAmount = +99734, because the funding transaction is unknown. The balance is right today only because the coin has been spent.
  • Two more contacts on the same wallet are in the same one-way state.

Fixes #5246.

What was done?

  • Sweep: a new step (3b) runs enqueue_receiving_account_builds after the existing account builds. It calls collect_receiving_account_candidates, which lists every contact that holds a request we sent, in sent_contact_requests() or established_contacts(), and has no receival account. Each one gets a RegisterReceiving queue entry, and only that. The gate is on our side alone, because our receiving account depends only on our own request: it needs our identity, theirs and the signer, never their xpub. Besides the one-way contact, that covers two established cases the regular gate skips for good: a contact whose channel is marked payment_channel_broken (the failure was in decrypting their xpub, which our receiving side never touches, and RegisterReceiving makes no fetch and no decrypt, so it cannot retry without bound), and a contact whose external account was built but whose receiving build failed once (the external row survives a relaunch, so the regular has_external gate then skips it forever; this is gap 2 of platform-wallet: restored wallets miss historical DashPay contact payments — no registration-time rescan, and failed receival-account builds are never re-enqueued #4475). The external account keeps its own gate: it still needs the contact's xpub from a request they send us. Overlap with the regular candidates is harmless, since enqueueing is idempotent per (owner, contact, kind). Identities without an HD index are skipped. Because the queue is not restored on load (platform-wallet: restore the deferred DashPay contact-crypto queue on load #5091), re-discovering candidates every sweep is what carries a pending build across a relaunch.
  • Rescan: reconcile_dashpay_rescan rewinds a sent-only receival account to our request's core_height_created_at (the request carries our xpub). It no longer skips such accounts. Established contacts keep min(outgoing, incoming).
  • A contact who only sent us a request gets nothing new: they never received our xpub, so they cannot pay us on a chain of ours.
  • collect_account_build_candidates and AccountBuildCandidate are unchanged on purpose (see "How this composes" below). Folding the receiving-only case into that struct would be cleaner, but fix(platform-wallet)!: persist DashPay coreHeight backfill coverage so a relaunch resumes instead of rewinding again #5026 makes both pub(super) and uses them in tests; that consolidation is better done once the open work lands.

How this composes with open work

How Has This Been Tested?

New tests:

  • one_way_contact_tests::should_build_receiving_account_for_one_way_contact_on_sweep runs the real sync_contact_requests_reporting on a mock SDK that answers both contact-request queries with no documents, over a wallet that holds a one-way sent request. It asserts both fetches were answered, then that exactly one RegisterReceiving is queued. After draining with a seed provider, it asserts the receival account exists.
  • payments::tests::rescan_backfills_a_one_way_contact_from_our_sent_request_height: a one-way contact's receival account rewinds synced_height from 1,561,776 to 1,475,801, and only once.
  • one_way_contact_tests::should_collect_every_contact_holding_our_request_as_receiving_candidate covers which contacts are candidates: one-way sent and established are, one-way received is not.
  • one_way_contact_tests::should_still_build_receiving_account_when_channel_is_broken: an established contact with payment_channel_broken and no accounts is skipped by the regular gate and listed by the receiving gate.
  • one_way_contact_tests::should_queue_a_payload_free_receiving_op_for_one_way_contact checks that re-enqueueing is idempotent and the queued op carries no payload.

Without the fix: I reverted only the sweep's new call and the rescan change, keeping the helpers so the test module still compiled. Both regression tests failed:

  • the sweep test queued [] instead of [RegisterReceiving]
  • the rescan test got None instead of Some(1475801)

With the fix:

  • cargo test -p platform-wallet --features shielded: 1,413 lib tests plus all integration test binaries pass (the full lib suite was re-run after the second commit; the integration binaries after the first).
  • cargo test -p platform-wallet-ffi --features shielded: 430 lib tests plus all integration test binaries pass.
  • cargo check --tests -p platform-wallet-storage: clean.

Known limitation. The rescan hunk uses the tracked (newest) sent request's height. After a rotation re-send, that misses the span between the first publication of our xpub and the re-send. The established path on v5.1-dev has the same limitation, and #4740 fixes both with its earliest-height checkpoint.

Build note. This branch is based on v5.1-dev at 218cb89f18, whose tip did not compile: packages/rs-platform-version/src/version/v15.rs still imported drive_abci_query_versions::v3::DRIVE_ABCI_QUERY_VERSIONS_V3 after #5057 folded V3 into V2. #5212 (662c1fc945) has since fixed that on v5.1-dev. The local runs above used an equivalent uncommitted patch (both references changed to V2), which is not part of this PR. The branch merges into the current v5.1-dev tip (1ebcedb028) without conflicts, so it was left unrebased.

QA on a device (topple, testnet)

Run by the kotlin-sdk int28 integration build (integration/v42int28-pin @ 4dd8e3d0a2 on the HashEngineering fork), upgraded in place over int27 on the topple wallet (7dc06ad3…) on 2026-10-02. What that build contains, checked with git range-diff against this branch:

Results, from the SDK store captured two minutes after launch (store-after1) and the logcat:

int27 (before) int28 (after)
e5169bfc… in transactions absent present: received, +100000 duffs, height 1,476,252
e5169bfc:0 in txos absent present, marked spent by 6ac8356c…
6ac8356c… netAmount +99734 −266 (the fee)
receival accounts for the three one-way contacts (4193bbe6…, c7037829…, ce3cff2f…) none all three
  • The sweep drained three RegisterReceiving builds; the reconcile logged lowered SPV synced_height … floor=1475801 rewound_from=1564627 contacts=3. 1,475,801 is our sent request's height for 4193bbe6…. SPV climbed back to the tip in about 27 s.
  • The balance stayed at 168.96778705, as expected: the 0.001 DASH receive is credited and the same coin's spend is now accounted, so the two offset.
  • After a force-stop and relaunch, fix(platform-wallet)!: persist DashPay coreHeight backfill coverage so a relaunch resumes instead of rewinding again #5026's record reported the three contacts covered and no rewind happened.

Caveats: the run exercised this PR's rescan fallback inside #5026's function, not inside v5.1-dev's; the fallback as it sits in this PR has unit-test coverage only (rescan_backfills_a_one_way_contact_from_our_sent_request_height). Whichever of #5026 and this PR lands second conflicts in that one function; fc9142a30d on int28 is the resolution. One host-side observation, not an SDK defect: dash-wallet's WalletTransactionMetadataProvider later logged "DROPPED — no wallet tx and no fallback row" for e5169bfc… although the SDK store holds it; filed as dashpay/dash-wallet#1598.

Breaking Changes

None.

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
  • If I added or changed GroveDB structure, I described it in the area's structure.rs, regenerated grovedb-structure.json, and checked the structure viewer link posted on this pull request

For repository code-owners and collaborators only

  • I have assigned this pull request to a milestone

🤖 Generated with Claude Code

HashEngineering and others added 2 commits October 1, 2026 16:26
… contacts

A contact we sent a contact request to, who never sent one back, got no
DashpayReceivingFunds account. DIP-15 puts our receiving xpub in the
request we send, so that contact can pay us without reciprocating. But
the sweep built accounts only for established contacts, and the rescan
reconcile skipped every contact that was not established. Payments on
that chain were never seen, at any rescan depth. The live send path
registers the account itself, so this hit wallets that learned of the
sent request from Platform (restore from seed, a second device) or whose
live registration failed after the request was saved.

Seen on a topple testnet wallet: a 0.001 DASH receive (e5169bfc…, height
1,475,820) on our chain for a contact whose only request is ours, sent
19 blocks earlier. It is missing from every archived SDK store and
present in dashj's, and its later spend is recorded with a positive net
amount because the funding transaction is unknown.

The sweep now queues RegisterReceiving, and only that, for each
unreciprocated sent request that has no receival account. There is no
xpub of theirs to decrypt, so no external account can be built until
they reciprocate. The rescan reconcile rewinds a sent-only receival
account to our request's core height.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… alone

The previous commit queued our receiving account for a contact whose
request we sent and who never replied. Two established contacts were
still left without one: a contact whose payment channel is marked broken,
and a contact whose external account was built but whose receiving build
failed once. The regular candidate gate skips both for good, and it is
the only thing that re-queues a build after a relaunch.

Our receiving account needs our identity, theirs and the signer. It never
touches their xpub, so neither failure is a reason to skip it. The
receiving-side collector now lists every contact that holds a request we
sent, in sent_contact_requests or established_contacts, with no receival
account, and ignores the broken flag. RegisterReceiving makes no fetch
and no decrypt, so it cannot retry without bound. The external account
keeps its own gate, and the overlap with the regular candidates is
harmless because enqueueing is idempotent per (owner, contact, kind).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Oct 2, 2026

Copy link
Copy Markdown
Contributor

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
  auto_review:
    drafts: true
  • Autopilot · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Autopilot is currently an internal CodeRabbit preview.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

@github-actions github-actions Bot added this to the v5.1.0 milestone Oct 2, 2026
@thepastaclaw

Copy link
Copy Markdown
Collaborator

🕓 Review not started yet because this PR is a draft.

  • Request normal review — click when the PR is ready for review.
  • Request priority review — click to move this review to the front of the queue.

Commit de7bfa8. Normal review starts when eligible; priority review starts as soon as a slot is available.

This branch has not been deployed

No deployments
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.

2 participants