Skip to content

platform-wallet: restored wallets miss historical DashPay contact payments — no registration-time rescan, and failed receival-account builds are never re-enqueued #4475

Description

@bfoss765

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

  1. register_contact_account lowers the scan checkpoint itself at registration time — either route the insert through PlatformWalletInfo::add_managed_account_from_xpub so the existing rewind fires, or (narrower, per the conformance doc) pass the contact's min(core_height_created_at) in and call update_synced_height, reusing the floor logic already in reconcile_dashpay_rescan.
  2. The re-enqueue gate treats a missing dashpay_receival_accounts entry 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

  1. bfoss765 commented on Aug 28, 2026

    @bfoss765
    CollaboratorAuthor

    @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 DashPayBackfillGate has 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 reads persistedCoverage=none on 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.

  2. HashEngineering commented on Sep 8, 2026

    @HashEngineering
    Collaborator

    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:

    1. Exposed it to Kotlin: JNI WalletManagerNative.startWalletSubsystems and PlatformWalletManager.startWalletSubsystems(walletId, budgetSecs, gapLimit), returning the startup outcome (status, identity, contact accounts drained/pending). The wallet calls it before startSpv on every start.
    2. Guard-mark in stock reconcile_dashpay_rescan: contacts whose receival account exists while synced_height == 0 are marked covered, and start_wallet_subsystems runs 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions