Skip to content

feat(wallet): restore DashPay contact payment history after wallet restore - #918

Open
romchornyi wants to merge 3 commits into
developfrom
fix/bug-28-dashpay-payment-history
Open

feat(wallet): restore DashPay contact payment history after wallet restore#918
romchornyi wants to merge 3 commits into
developfrom
fix/bug-28-dashpay-payment-history

Conversation

@romchornyi

Copy link
Copy Markdown
Contributor

Issue being fixed or feature implemented

BUG-28. After restoring a wallet from seed, every contact card showed "No payments with this contact yet", and DashPay rows in the transaction list rendered their avatar as "?". The counterparty wallet still listed the full history, so nothing was lost on chain.

Companion to dashpay/platform#4300, which does the actual reconstruction. This PR is what makes it possible to run: without it the restored wallet has no contact accounts to match against, so the upstream pass has nothing to scan.

What was done?

Drain the deferred contact crypto. loadFromPersistor rehydrates a wallet external-signable — xpubs only, no key material — so the DashPay contact sweep cannot ECDH each contact's encrypted xpub into a DashpayExternalAccount. It enqueues the build instead and logs Deferred DashPay account build. Nothing ever drained that queue: send_payment does it with its own signer (which is why the gap only showed on wallets that had not paid the contact since restoring), and the app never called unlockWalletFromKeychain — there were zero references to it in the app.

Unlock again when work appears. One unlock at load is not enough: the SDK schedules the drain only when the queue is already non-empty, and at load it is empty because the contact sweep has not run yet. Measured on device: 0 pending at unlock, 4 pending 45s later, nothing draining them. So the host watches the SDK's published pending count and unlocks again once work shows up — bounded to a few attempts, skipped while a drain is in flight, and cheap because the seed-binding verify is marker-cached.

Sweep immediately once the accounts exist. Waiting for the next scheduled DashPay pass was roughly half of the 32s between launch and the history appearing — long enough that the first open of the contact card looked empty and only a later one showed anything.

Fall back to the DPNS label for a payment row's title. ContactItem.displayTitle resolves alias → profile display name → DPNS label → truncated id, but the payment projection stopped after the profile display name. Most contacts have no dashpay.profile.displayName, so the contact card showed the username while the transaction row had no name at all and rendered "?". The truncated-identity last resort is deliberately not mirrored — a transaction row falls back to its own generic title, which reads better than "Sent to 89fd6ddb…".

The DPNS hints resolve in one batch through a static helper. Reaching the contacts service singleton from the payment lookup re-enters that singleton's own initialization (initrefresh → the payment lookup) and traps with EXC_BREAKPOINT on the static let shared line; the batch also stops the identity snapshot being re-read once per payment row.

How Has This Been Tested?

Testnet, on device, wallet restored from seed with two established contacts and outgoing payments predating the restore.

Before: Deferred DashPay account build repeating every sweep (64 occurrences in one session), no external accounts, empty contact card.

After: the queue drains, both accounts register, and the upstream pass reconstructs six Sent entries with amounts and dates matching the counterparty wallet. They survive relaunch, and later passes report no eligible contacts — no repeated scanning. Transaction rows show the contact name instead of "?".

Also checked in the same runs:

  • a wallet that was never restored is unaffected;
  • the receiving side is unchanged — no duplicated or overwritten incoming entries;
  • the unlock work is instrumented and costs tookMs=1…4 on the main thread, so it is not a source of UI stalls.

Clean dashpay build. The unit-test target remains broken (pre-existing).

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 made corresponding changes to the documentation

For repository code-owners and collaborators only

  • I have assigned this pull request to a milestone

…store

A restored wallet showed "No payments with this contact yet" on every
contact card, and DashPay rows in the transaction list rendered their
avatar as "?".

Drain the deferred contact crypto. `loadFromPersistor` rehydrates a
wallet external-signable — xpubs only, no key material — so the DashPay
contact sweep cannot ECDH each contact's encrypted xpub into a
`DashpayExternalAccount` and enqueues the build instead ("Deferred
DashPay account build"). Nothing drained that queue: `send_payment` does
it with its own signer, which is why the gap only showed on wallets that
had not paid the contact since restoring, and the app never called
`unlockWalletFromKeychain`. Without those accounts the wallet has no
derived contact addresses, so sent-payment history cannot be
reconstructed at all.

One unlock at load is not enough — it schedules the drain only when the
queue is already non-empty, and at load it is empty because the contact
sweep has not run yet (measured: 0 pending at unlock, 4 pending 45s
later). So watch the SDK's published pending count and unlock again when
work appears, bounded to a few attempts and skipped while a drain is in
flight. Re-unlocking is cheap: the seed-binding verify is marker-cached.

Fall back to the DPNS label for a payment row's title.
`ContactItem.displayTitle` resolves alias → profile display name → DPNS
label → truncated id, but the payment projection stopped after the
profile display name. Most contacts have no `dashpay.profile.displayName`,
so the contact card showed the username while the transaction row had no
name at all. The DPNS hints now resolve in one batch through a static
helper: reaching the contacts service singleton from here would re-enter
its own initialization (`init` → `refresh` → the payment lookup) and trap
on the `static let shared` line.

The truncated-identity last resort is deliberately not mirrored — a
transaction row falls back to its own generic title, which reads better
than "Sent to 89fd6ddb…".
…uilt

Restoring a wallet left the contact card empty on first open; the
payments only appeared when it was opened again later. Measured end to
end: 32s from launch to the reconstructed rows, roughly half of it spent
idling until the next scheduled DashPay pass after the deferred contact
accounts had already been registered.

Watch the pending-account-build count down to zero and run a DashPay
sync right then, instead of waiting out the interval.
@coderabbitai

coderabbitai Bot commented Aug 5, 2026

Copy link
Copy Markdown

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: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 7784b49b-2f3a-49ad-9421-11d0a43c01ba

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

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

Every payment on a restored wallet's contact card showed today's date,
while the transaction-detail screen behind it showed the real one.

The list read `PersistentDashpayPayment.createdAt`, which is row
bookkeeping — the model says so in as many words — and only passed for a
payment date because a live send writes its row as the payment happens.
A payment reconstructed after a restore is written today, so the whole
recovered history collapsed onto one date.

Take the date from the transaction's `blockTimestamp` instead, matched
by txid (reversed: payment rows carry display-order hex, the transaction
table is keyed by wire-order bytes). An unconfirmed or not-yet-synced
transaction has no block time and keeps the row's timestamp, which for a
live send is the correct answer anyway.

Sorting moves onto the same date. Reconstructed rows are all written
within the same second, so insert order carried no information about
which payment came first.
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