Skip to content

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

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

HashEngineering wants to merge 4 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

PR Hygiene · e968545

  • Bots — coderabbitai not yet · thepastaclaw ✓ — /skip-bots proceeds without the ones not yet reported
  • Self-review — post /self-reviewed
  • Build green
  • Approvals
    • rs-platform-wallet (packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs, packages/rs-platform-wallet/src/wallet/identity/network/contacts.rs, packages/rs-platform-wallet/src/wallet/identity/network/payments.rs) — ZocoLini or llbartekll or romchornyi

When every merge requirement is met, the PR Hygiene check passes. Reviewer limits do not block merging; other required GitHub checks and protections still apply.

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

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: Repository: dashpay/platform/.coderabbit.yaml
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: f9854fae-26a3-4f9b-ae68-fe672b1e7b7f

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
  • Autopilot · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

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

thepastaclaw commented Oct 2, 2026 •

Copy link
Copy Markdown
Collaborator

✅ Final review complete — no blockers (commit e968545) · triage: normal

@github-actions

github-actions Bot commented Oct 6, 2026

Copy link
Copy Markdown
Contributor

Bots are done — your move: post /self-reviewed.
Full checklist in the description.

@github-actions github-actions Bot added waiting-self-review Waiting for the author to post /self-reviewed bot-review-skipped A required review bot did not report; it was skipped by the window or by a person. labels Oct 6, 2026

@thepastaclaw thepastaclaw left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Final validation — Phase 1 + Phase 2

Verified the supplied findings against head de7bfa8. The receiving-only enqueue and sent-request rescan fallback match the PR's goal; no blocking issues were confirmed, and one method-documentation nitpick remains. Validation was static only: in the supplied CI snapshot, Rust workspace tests were still running and the dedicated Rust wallet tests were skipped.

💬 1 nitpick(s)

1 finding(s) not shown inline (the lines are not part of this PR's diff)

💬 Nitpick: Sweep method docs omit the new receiving-only step
packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs:1334-1338

The per-sweep documentation on sync_contact_requests, whose implementation delegates to sync_contact_requests_reporting, describes account builds only for established contacts missing a sending account. The reporting implementation now also calls enqueue_receiving_account_builds for contacts holding our sent request, including unreciprocated contacts and established contacts excluded by the external-account gate. Add that receiving-only step to the method documentation so its behavior summary covers the recovery path introduced by this PR.

    /// 4. For **every** established contact missing a sending account
    ///    (not only newly-established ones — this also repairs
    ///    restore-from-seed and best-effort-accept gaps), rebuilds both
    ///    the `DashpayReceivingFunds` and `DashpayExternalAccount`
    ///    accounts, with the transient/permanent failure policy.
    /// 5. For every contact holding a request we sent, reciprocated or not,
    ///    and missing a receiving account, enqueues a
    ///    `DashpayReceivingFunds` build alone for the signer-backed drain.
    ///    This receiving-only step does not depend on the contact's xpub
    ///    or the external-account gate.

source: muse-spark-1.3-contributor (phase1-reviewer: general)

Review provenance

Source: reviewer 1: muse-spark-1.3-contributor (agent: phase1-reviewer, role: general); reviewer 2: muse-spark-1.3-contributor (agent: phase1-reviewer, role: rust-quality); reviewer 3: gpt-6.1-sol (agent: phase2-reviewer, role: general); reviewer 4: gpt-6.1-sol (agent: phase2-reviewer, role: architecture-layering); reviewer 5: gpt-6.1-sol (agent: phase2-reviewer, role: rust-quality); reviewer 6: gpt-6.1-sol (agent: phase2-reviewer, role: security-auditor); final verifier: gpt-6.1-sol (agent: sol-verifier, role: final-verifier)

  • Triage: normal by gpt-6.1-sol (effort low) — The diff adds receiving-account candidate discovery and queueing in contact_requests.rs and adjusts rescan eligibility in payments.rs, but does not change funds movement, coin selection, cryptography, key derivation, or storage migrations.
  • Phase 1 reviewers: muse-spark-1.3-contributor — general (completed, effort xhigh); agent phase1-reviewer, muse-spark-1.3-contributor — rust-quality (completed, effort xhigh); agent phase1-reviewer
  • Phase 1 model: muse-spark-1.3-contributor — not quota-gated; passed over gemini-3.8-flash-high (antigravity below 15% reserve: weekly 15% left, 5h 100% left), glm-5.3-flash (not used above high effort; tier asks max)
  • Fresh verifier: gpt-6.1-sol — final-verifier; agent sol-verifier
  • Phase 2 reviewers: gpt-6.1-sol — general (completed, effort high); agent phase2-reviewer, gpt-6.1-sol — architecture-layering (completed, effort high); agent phase2-reviewer, gpt-6.1-sol — rust-quality (completed, effort high); agent phase2-reviewer, gpt-6.1-sol — security-auditor (completed, effort high); agent phase2-reviewer
🤖 Prompt for all review comments with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.

In `packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs`:
- [NITPICK] packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs:1334-1338: Sweep method docs omit the new receiving-only step
  The per-sweep documentation on `sync_contact_requests`, whose implementation delegates to `sync_contact_requests_reporting`, describes account builds only for established contacts missing a sending account. The reporting implementation now also calls `enqueue_receiving_account_builds` for contacts holding our sent request, including unreciprocated contacts and established contacts excluded by the external-account gate. Add that receiving-only step to the method documentation so its behavior summary covers the recovery path introduced by this PR.

HashEngineering and others added 2 commits October 7, 2026 12:48
The step list on sync_contact_requests described account builds for
established contacts only. Add the receiving-account step the sweep now
runs for every contact holding a request we sent.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@HashEngineering HashEngineering self-assigned this Oct 7, 2026
@HashEngineering

Copy link
Copy Markdown
Collaborator Author

/self-review

@github-actions

github-actions Bot commented Oct 7, 2026 •

Copy link
Copy Markdown
Contributor

Waiting for bot review — coderabbitai not yet · thepastaclaw ✓. Wait for the missing reviews, or a writer can post /skip-bots to proceed without them; blocking findings still need addressing.
Full checklist in the description.

@github-actions github-actions Bot added waiting-bots Waiting for the review bots to report on this head and removed waiting-self-review Waiting for the author to post /self-reviewed bot-review-skipped A required review bot did not report; it was skipped by the window or by a person. labels Oct 7, 2026

@thepastaclaw thepastaclaw left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Re-review — Final validation — Phase 1 + Phase 2

The complete diff at e968545 addresses receiving-account discovery and rescan eligibility for one-way DashPay contacts without introducing a confirmed production defect. One new regression assertion does not actually exercise the single-shot rescan guard; the prior documentation finding is fixed. Validation was static only: no builds or tests were run, and the supplied CI snapshot still shows build and validation checks pending or running.

🟡 1 suggestion(s)

Review provenance

Source: reviewer 1: muse-spark-1.3-contributor (agent: phase1-reviewer, role: general); reviewer 2: muse-spark-1.3-contributor (agent: phase1-reviewer, role: rust-quality); reviewer 3: gpt-6.1-sol (agent: phase2-reviewer, role: general); reviewer 4: gpt-6.1-sol (agent: phase2-reviewer, role: architecture-layering); reviewer 5: gpt-6.1-sol (agent: phase2-reviewer, role: rust-quality); reviewer 6: gpt-6.1-sol (agent: phase2-reviewer, role: security-auditor); reviewer 7: gpt-6.1-sol (agent: phase2-reviewer, role: general); reviewer 8: gpt-6.1-sol (agent: phase2-reviewer, role: architecture-layering); reviewer 9: gpt-6.1-sol (agent: phase2-reviewer, role: rust-quality); reviewer 10: gpt-6.1-sol (agent: phase2-reviewer, role: security-auditor); final verifier: gpt-6.1-sol (agent: sol-verifier, role: final-verifier)

  • Triage: normal by gpt-6.1-sol (effort low) — The diff adds receiving-account discovery and deferred registration plus rescan eligibility changes in contact_requests.rs and payments.rs, but does not alter funds movement, coin selection, cryptography, or key derivation itself.
  • Phase 1 reviewers: muse-spark-1.3-contributor — general (completed, effort xhigh); agent phase1-reviewer, muse-spark-1.3-contributor — rust-quality (completed, effort xhigh); agent phase1-reviewer
  • Phase 1 model: muse-spark-1.3-contributor — not quota-gated; passed over gemini-3.8-flash-high (antigravity below 15% reserve: weekly 15% left, 5h 100% left), glm-5.3-flash (not used above high effort; tier asks max)
  • Fresh final gate: an independent Phase-2 review ran after iterative findings were reconciled
  • Fresh verifier: gpt-6.1-sol — final-verifier; agent sol-verifier
  • Phase 2 reviewers: gpt-6.1-sol — general (completed, effort high); agent phase2-reviewer, gpt-6.1-sol — architecture-layering (completed, effort high); agent phase2-reviewer, gpt-6.1-sol — rust-quality (completed, effort high); agent phase2-reviewer, gpt-6.1-sol — security-auditor (completed, effort high); agent phase2-reviewer, gpt-6.1-sol — general (completed, effort high); agent phase2-reviewer, gpt-6.1-sol — architecture-layering (completed, effort high); agent phase2-reviewer, gpt-6.1-sol — rust-quality (completed, effort high); agent phase2-reviewer, gpt-6.1-sol — security-auditor (completed, effort high); agent phase2-reviewer
🤖 Prompt for all review comments with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.

In `packages/rs-platform-wallet/src/wallet/identity/network/payments.rs`:
- [SUGGESTION] packages/rs-platform-wallet/src/wallet/identity/network/payments.rs:2962-2970: Advance the scan height before asserting the rescan is single-shot
  After the first reconciliation, synced_height equals the sent request's funding height. The second call therefore returns None even if this one-way contact was never inserted into rescan_triggered, because funding < synced_height is already false. The assertion cannot detect a regression in the single-shot behavior it claims to verify. Advance synced_height before reconciling again and assert that the advanced height is preserved; without the guard, that call would rewind the wallet again.

Comment on lines +2962 to +2970
assert_eq!(synced_height(&manager, wallet_id).await, 1_475_801);
assert_eq!(
iw.dashpay()
.reconcile_dashpay_rescan()
.await
.expect("rescan 2"),
None,
"the guard makes it single-shot, as for established contacts"
);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🟡 Suggestion: Advance the scan height before asserting the rescan is single-shot

After the first reconciliation, synced_height equals the sent request's funding height. The second call therefore returns None even if this one-way contact was never inserted into rescan_triggered, because funding < synced_height is already false. The assertion cannot detect a regression in the single-shot behavior it claims to verify. Advance synced_height before reconciling again and assert that the advanced height is preserved; without the guard, that call would rewind the wallet again.

Suggested change
assert_eq!(synced_height(&manager, wallet_id).await, 1_475_801);
assert_eq!(
iw.dashpay()
.reconcile_dashpay_rescan()
.await
.expect("rescan 2"),
None,
"the guard makes it single-shot, as for established contacts"
);
assert_eq!(synced_height(&manager, wallet_id).await, 1_475_801);
set_synced_height(&manager, wallet_id, 1_561_776).await;
assert_eq!(
iw.dashpay()
.reconcile_dashpay_rescan()
.await
.expect("rescan 2"),
None,
"the guard makes it single-shot, as for established contacts"
);
assert_eq!(
synced_height(&manager, wallet_id).await,
1_561_776,
"scan progress must not be rewound again"
);

source: gpt-6.1-sol (phase2-reviewer: rust-quality)

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

waiting-bots Waiting for the review bots to report on this head

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants