Description
Observed
On a restore-from-seed of a wallet with DashPay contact-payment history (Android host, kotlin-sdk v42int AAR line): a payment received on a DIP-15 friend-chain address before the restore is absent from the restored store — no transaction record, no payment activity — while live-synced wallets record the same class of payment correctly.
Mechanism (two SDK-side gaps)
1. No registration-time backfill. A restored wallet is born with zero friend-chain scripts: WalletAccountCreationOptions has no DashPay variant, so contact receival accounts only enter the watch set later via register_contact_account (wallet/identity/network/contacts.rs). That path inserts directly into the account collection and bypasses key-wallet's rewind_sync_checkpoint_for_new_account — unlike e.g. the asset-lock account path, which goes through the rewinding API. The compensating sweep reconcile_dashpay_rescan (wallet/identity/network/payments.rs) is sound and self-healing — but only fires on a pass that runs AFTER the accounts exist. A host that runs the sweep before its contact-crypto drain (or suppresses later sweeps) permanently misses the backfill. The project's own docs mark this gap: docs/dashpay/DIP_CONFORMANCE_GAPS.md lists "L1 block re-scan from min(coreHeightCreatedAt) on new contact" as MISSING and prescribes lowering synced_height at registration time.
2. Failed receival-account builds are permanently skipped. collect_account_build_candidates (wallet/identity/network/contact_requests.rs, the re-enqueue gate) skips a contact when dashpay_external_accounts contains the key — it never checks dashpay_receival_accounts. The external account row round-trips persistence, while the pending contact-crypto queue is deliberately NOT restored on cold load. So any contact whose RegisterExternal succeeded but whose RegisterReceiving failed once (drain budget exhausted, locked Keystore/signer, process death mid-drain) is never rebuilt on any later launch: that contact's receiving chain is never watched again, and no rescan depth can recover its payments. This affects every host.
Proposed fix shape
register_contact_accountlowers the scan checkpoint itself at registration time — either route the insert throughPlatformWalletInfo::add_managed_account_from_xpubso the existing rewind fires, or (narrower, per the conformance doc) pass the contact'smin(core_height_created_at)in and callupdate_synced_height, reusing the floor logic already inreconcile_dashpay_rescan.- The re-enqueue gate treats a missing
dashpay_receival_accountsentry as a build candidate regardless of the external account's presence.
Acceptance
After restore-from-seed of a wallet with contact-payment history: (a) payments to friend-chain addresses funded below the scan tip are recovered without relying on host sweep ordering; (b) a contact whose receival-account build failed is retried on a later drain and its history backfilled once built.
Host-side ordering/suppression aggravations on Android are being fixed separately in dash-wallet; this issue covers only the SDK-side gaps. Related: #4474 (the CoinJoin-derivation variant of the restore ownership gap).
Activity
bfoss765 commentedon Aug 28, 2026
@HashEngineering — adjacent field observation from the 12.0.0 testnet validation run, on the same recovery path this issue covers (posting here since it's the closest tracker — say if you'd rather have a separate issue).
Device: Galaxy S21, testnet, 12.0.0 QA build (SDK v42int5), restored wallet with ~3,200 transactions. The restore-ordering fix itself worked — the follow-up sweep fired and recovered a real, previously-missing contact payment (+0.001 DASH, duff-exact against the expected balance). But the app-side
DashPayBackfillGatehas been unable to conclude coverage for ~24 hours after arming: it wakes every ~65 s, logs that it is waiting for the SDK's async watermark persist, and readspersistedCoverage=noneon every pass (rescan target height 1,541,263). The scan completed, but the persisted watermark the gate requires as evidence never appears, so the gate loops indefinitely and the coverage conclusion is unreachable.Net: the recovery half of the contract works; the persisted-watermark half doesn't land on this device, and anything keyed off persisted coverage (including the re-enqueue logic this issue proposes) would inherit that. Timestamps in the device logs are UTC; I can pull DB/log excerpts from the device if useful.
fix(platform-wallet): restore contact-payment history without host he…
HashEngineering commentedon Sep 8, 2026
On the first gap: the restore case does not need a registration-time rescan, and we are not pursuing one on Android.
The SDK already has the right primitive —
start_wallet_subsystems(rs-platform-wallet/src/manager/startup.rs), the ordered bring-up iOS uses: identity discovery → contact-request sync → signer-present drain that builds the contact receival/external accounts, all before SPV starts. Android had no binding for it, so the host started the filter scan first and built the accounts afterwards — which is exactly the miss described here.What we did:
WalletManagerNative.startWalletSubsystemsandPlatformWalletManager.startWalletSubsystems(walletId, budgetSecs, gapLimit), returning the startup outcome (status, identity, contact accounts drained/pending). The wallet calls it beforestartSpvon every start.reconcile_dashpay_rescan: contacts whose receival account exists whilesynced_height == 0are marked covered, andstart_wallet_subsystemsruns the sweep once after its drain. Without this the first post-scan sweep saw "unhandled" contacts and rewound to the earliest funding height — a 321k-block redundant rescan on a large CoinJoin wallet.Result on a restored wallet with 7 contacts / 7,318 transactions: every contact-chain payment recorded during the first scan, no rewind, store balance equal to the engine's. Acceptance (a) is met by ordering rather than by a rewind at registration. The host backfill gate from the comment above is removed — it was waiting on a persisted watermark that never lands.
Gap 2 (the candidate gate skipping a contact whose external account exists but whose receival account does not) is unchanged in stock and still open; follow-up separately. One sibling found while testing: a one-sided contact (request in one direction only) never gets a receival account, so payments to our chain for that contact are never watched — separate issue to come.
HashEngineering commentedon Nov 9, 2026
This was fixed by #4740; #5256 was my solution that was closed .