fix(platform-wallet): build our receiving account for one-way DashPay contacts - #5256
Draft
HashEngineering wants to merge 2 commits into
Draft
HashEngineering wants to merge 2 commits into
HashEngineering wants to merge 2 commits into
Conversation
… 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>
Contributor
|
Important Draft PR not reviewedDraft PRs are not automatically reviewed by default.
To automatically review draft PRs, update your CodeRabbit configuration: reviews:
auto_review:
drafts: true
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. Comment |
Collaborator
|
🕓 Review not started yet because this PR is a draft.
Commit de7bfa8. Normal review starts when eligible; priority review starts as soon as a slot is available. |
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
DashpayReceivingFundsaccount. 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) walksestablished_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…):e5169bfc4989585abd4b0476188611b981e3c750539da5b8a39fe135e3bbb957, height 1,475,820: 0.001 DASH toyMNNc2UZz62V9N7SZfQnsk79G5okCP7eSN. That is our receiving chain15'/0'/(us)/(4193bbe6…)/0for contact5QzA7GnST….6ac8356c…is stored withnetAmount = +99734, because the funding transaction is unknown. The balance is right today only because the coin has been spent.Fixes #5246.
What was done?
enqueue_receiving_account_buildsafter the existing account builds. It callscollect_receiving_account_candidates, which lists every contact that holds a request we sent, insent_contact_requests()orestablished_contacts(), and has no receival account. Each one gets aRegisterReceivingqueue 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 markedpayment_channel_broken(the failure was in decrypting their xpub, which our receiving side never touches, andRegisterReceivingmakes 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 regularhas_externalgate 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.reconcile_dashpay_rescanrewinds a sent-only receival account to our request'score_height_created_at(the request carries our xpub). It no longer skips such accounts. Established contacts keepmin(outgoing, incoming).collect_account_build_candidatesandAccountBuildCandidateare 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 bothpub(super)and uses them in tests; that consolidation is better done once the open work lands.How this composes with open work
f9426a4236. Its description names this exact scenario (Alice sends Bob a request from device A, Bob pays, device B scans the block before it sees the request, "the relationship may stay one-way"). But device B never gets the receival account under fix(platform-wallet): rescan DashPay contact accounts from the contact request height #4740: its production call sites ofregister_contact_accountare the same three as onv5.1-dev(live send, the drain'sRegisterReceivingarm, the accept path), its onlyRegisterReceivingenqueue is still reached throughcollect_account_build_candidates, which is unchanged and walksestablished_contacts()only, and its sent sweep (ingest_sent_sweep,note_sent_request_core_height) only records heights. Its own one-way test,should_cover_restored_sent_only_account_in_rescan, creates the account by callingregister_contact_accountdirectly, which models device A relaunching with its persisted account row, not device B. The topple store is the device-B case: our request for4193bbe6…and no account row. What fix(platform-wallet): rescan DashPay contact accounts from the contact request height #4740 does cover, better than this PR's rescan hunk, is the rescan once a sent-only account exists:receiving_scan_checkpointuses the earliest sent request height, andregister_contact_accountapplies the checkpoint at registration time. The two compose cleanly: this PR's sweep queues the build, the drain callsregister_contact_account, and fix(platform-wallet): rescan DashPay contact accounts from the contact request height #4740's version of it rewinds to the right height, which the sweep has recorded by then. contact_requests.rs merges cleanly. payments.rs conflicts only insidereconcile_dashpay_rescanand where the tests are inserted. Resolution: take fix(platform-wallet): rescan DashPay contact accounts from the contact request height #4740's version of the function, drop this PR's rescan hunk, and droprescan_backfills_a_one_way_contact_from_our_sent_request_heightin favour of fix(platform-wallet): rescan DashPay contact accounts from the contact request height #4740's test.reconcile_dashpay_rescanstill requires an established contact. Without this PR's rescan change, a sent-only account would be built but never rewound for and never recorded. contact_requests.rs merges cleanly: fix(platform-wallet)!: persist DashPay coreHeight backfill coverage so a relaunch resumes instead of rewinding again #5026 only makes the candidate collector and structpub(super), and those lines are untouched here. payments.rs has two conflict blocks in the same function. Resolution: keep fix(platform-wallet)!: persist DashPay coreHeight backfill coverage so a relaunch resumes instead of rewinding again #5026's record logic and replace its established-onlycheckpointlookup with this PR's fallback to the sent request's height. A newly watched one-way contact then gets a coverage entry like any other.fix/dashpay-contact-rescan-4475(a026f0059b, the same diff as2d230d0865): it widens the established-contact gate tohas_external && has_receival, re-queuing both ops for an established contact whose receiving build failed. This PR's receiving-side gate now covers that contact's receival account too, so the two overlap on that case; the overlap is harmless (idempotent enqueue) and the fork's change still adds the pairedRegisterExternalretry. It merges cleanly with this PR, because the new collector sits beside the existing one instead of changing it.How Has This Been Tested?
New tests:
one_way_contact_tests::should_build_receiving_account_for_one_way_contact_on_sweepruns the realsync_contact_requests_reportingon 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 oneRegisterReceivingis 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 rewindssynced_heightfrom 1,561,776 to 1,475,801, and only once.one_way_contact_tests::should_collect_every_contact_holding_our_request_as_receiving_candidatecovers 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 withpayment_channel_brokenand 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_contactchecks 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:
[]instead of[RegisterReceiving]Noneinstead ofSome(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-devhas the same limitation, and #4740 fixes both with its earliest-height checkpoint.Build note. This branch is based on
v5.1-devat218cb89f18, whose tip did not compile:packages/rs-platform-version/src/version/v15.rsstill importeddrive_abci_query_versions::v3::DRIVE_ABCI_QUERY_VERSIONS_V3after #5057 folded V3 into V2. #5212 (662c1fc945) has since fixed that onv5.1-dev. The local runs above used an equivalent uncommitted patch (both references changed toV2), which is not part of this PR. The branch merges into the currentv5.1-devtip (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@4dd8e3d0a2on the HashEngineering fork), upgraded in place over int27 on the topple wallet (7dc06ad3…) on 2026-10-02. What that build contains, checked withgit range-diffagainst this branch:fc9142a30d,9877f00e42);collect_receiving_account_candidates,enqueue_receiving_account_builds, the call site) byte-identical to this branch;reconcile_dashpay_rescan. This PR's 10-line checkpoint fallback (established →min(outgoing, incoming); one-way → our sent request'score_height_created_at; neither → skip) was placed inside fix(platform-wallet)!: persist DashPay coreHeight backfill coverage so a relaunch resumes instead of rewinding again #5026's version of that function; the diff of int28 against fix(platform-wallet)!: persist DashPay coreHeight backfill coverage so a relaunch resumes instead of rewinding again #5026's head is that insertion and nothing else. So the rescan fallback ran with fix(platform-wallet)!: persist DashPay coreHeight backfill coverage so a relaunch resumes instead of rewinding again #5026's durable guard rather than the in-memoryrescan_triggeredguard this PR targets onv5.1-dev. The guard decides whether a contact is rewound for a second time; the fallback decides whether and to what height it is rewound at all, and that logic is the same in both;integer_range_clausesdropped from the mockedDocumentQuery, a field int28's older base does not have;Results, from the SDK store captured two minutes after launch (
store-after1) and the logcat:e5169bfc…intransactionse5169bfc:0intxos6ac8356c…6ac8356c…netAmount4193bbe6…,c7037829…,ce3cff2f…)RegisterReceivingbuilds; the reconcile loggedlowered SPV synced_height … floor=1475801 rewound_from=1564627 contacts=3. 1,475,801 is our sent request's height for4193bbe6…. SPV climbed back to the tip in about 27 s.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;fc9142a30don int28 is the resolution. One host-side observation, not an SDK defect: dash-wallet'sWalletTransactionMetadataProviderlater logged "DROPPED — no wallet tx and no fallback row" fore5169bfc…although the SDK store holds it; filed as dashpay/dash-wallet#1598.Breaking Changes
None.
Checklist:
structure.rs, regeneratedgrovedb-structure.json, and checked the structure viewer link posted on this pull requestFor repository code-owners and collaborators only
🤖 Generated with Claude Code