Skip to content

fix(dashpay): recover a restored identity in-session and repaint contact payments - #950

Merged
QuantumExplorer merged 5 commits into
developfrom
fix/dashpay-restore-identity-and-feed-refresh
Aug 11, 2026
Merged

fix(dashpay): recover a restored identity in-session and repaint contact payments#950
QuantumExplorer merged 5 commits into
developfrom
fix/dashpay-restore-identity-and-feed-refresh

Conversation

@romchornyi

@romchornyi romchornyi commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

Scope narrowed after review. The Swift-side identity-recovery retry policy has been reverted out of this PR — "the swift client should not be doing this, it should be in rust", and packages/swift-sdk/CLAUDE.md forbids policy loops in Swift outright. That backoff will land in rs-platform-wallet together with the startup-ordering work (the Swift sequencer in #961 is now a draft for the same reason).

What remains here is the part with no Rust equivalent:

  • The transaction feed never repainted when DashPay payment rows landed. A Swift read-model cache with no publisher, plus a view model whose SwiftData-save filter ignores the entities those rows live in. Rust has no view to invalidate.
  • A failed platformAddressWallet() took the shielded, DashPay-sync and identity subsystems down with it, and the resulting error was then erased by the first successful address sync.

Identity recovery itself is unchanged from develop in this PR. platform#4352 (merged) already makes an unreachable scan raise IdentityDiscoveryIncomplete rather than an empty success, so a context is marked complete only when Platform actually answered; a failed scan retries on the next runtime start.

Issue being fixed or feature implemented

QA: "transactions on DashPay and contacts come back if you resync" — after restore-from-seed the identity sometimes does not appear, and contact payment rows render with the wrong direction and a ? avatar until the app is relaunched.

These are two independent defects that happen to share a symptom, which is why neither reproduced reliably.

1 — the identity was found once, or not until relaunch. Recovery runs inside PlatformAddressSyncCoordinator.performStart, which early-returns when the coordinator is already running, so a restored wallet gets exactly one scan — in the seconds right after restore, when the network is least likely to answer. A scan that came back empty was recorded in completedContexts as final for the process, because an empty result arrived as success. No identity means no DashPay tabs, no contacts and no contact payment history for the whole session. Whether it happened came down to whether the SDK's quorum endpoint answered in those first seconds.

2 — the feed never learned that contact payments had arrived. A DashPay row's true direction, amount and contact name come from DashPayPaymentTxLookup. Those rows are written by an app-pulled projection into entities HomeViewModel.saveTouchesFeedRows filters out, and are read through a computed property (Transaction.dashPayPayment) on rows that were already rendered. The only DashPay-aware reload fires when the identity is adopted — before the sync loop has fetched anything. So the feed kept dash-spv's misread direction (an outgoing contact payment reads as incoming +amount) until something unrelated touched PersistentTransaction. A wallet still catching up on SPV repainted by accident; a quiet, already-synced one never did.

Companion Platform fix: dashpay/platform#4352 — that one stops discover_identities from reporting an unreachable scan as "no identity exists". The changes here are independently effective, since an empty scan is no longer treated as final either way.

What was done?

Identity recovery

  • DWCurrentUserIdentityInfo.swiftDWSameSeedIdentityRecoveryCoordinator now marks a context complete only when an identity was actually found. An empty or failed scan retries on a 20s/60s/180s backoff. The scan body moved into attempt(...), which returns whether the wallet has an identity; scheduleRetries(...) walks the schedule.
    Retries run outside the runtime-start pipeline, so each pass re-resolves the live wallet via SwiftDashSDKHost.shared.wallet and checks runningNetwork rather than holding the handle it was started with — a wipe, wallet switch or network switch between retries ends the search instead of scanning against a torn-down runtime. A fresh performStart cancels any pending backoff, since it re-runs the same first attempt.

  • PlatformAddressSyncCoordinator.swift — a failing platformAddressWallet() no longer returns. It returned before the shielded bind, startDashPaySync() and identity recovery, none of which use the address wallet, so one failure took all three down for the session. The error is carried in addressWalletError and lands on lastError at the end; addressWallet was already an optional the rest of the method handles, since the no-Platform-account branch sets it to nil and continues.

Transaction feed

  • SwiftDashSDKContactsService.swiftDashPayPaymentTxLookup.store(_:) compares the incoming snapshot against the current one and posts DWDashPayPaymentTxLookupDidChange only on a real change (PaymentInfo gained Equatable). Gating on change matters: the projection re-runs on a timer, and an unconditional post would rebuild the whole history list every pass.
  • HomeViewModel.swiftobserveDashPay() subscribes to that notification and funnels it through txReloadRequests.
  • SwiftDashSDKContactsService.swiftrefreshPaymentsProjection() arms its 60s throttle only when it actually pulled. Previously a call that returned early for want of an identity still spent the launch's first window, and the identity typically lands seconds later.

How Has This Been Tested?

xcodebuild -workspace DashWallet.xcworkspace -scheme dashpay -sdk iphonesimulator \
  -destination 'generic/platform=iOS Simulator' ARCHS=arm64 build     # BUILD SUCCEEDED

No new tests. The unit-test target is currently broken (pre-existing, noted in CLAUDE.md), and the changed behaviour lives in @MainActor types that need a live ManagedPlatformWallet. The existing SameSeedIdentityRecoveryPipeline tests in DashWalletTests/SwiftDashSDKCoreLifecycleTests.swift still apply unchanged — the pipeline's sequencing was not touched, only the coordinator's interpretation of its outcome. The Platform-side counterpart of this fix does carry unit tests (dashpay/platform#4352).

Not verified on-device — this is the main gap. Suggested manual checks on testnet:

  • Defect 1: restore a seed that owns an identity with the network briefly blocked or heavily throttled at launch. Expect 🪪 IDENT-RECOVERY :: scan found no identity; will retry followed by a successful pass within ~20s, and DashPay tabs appearing without a relaunch. Escape hatch if it still fails: Menu → Security → Wallets → Identities → Find identities.
  • Defect 2: restore a wallet that is already fully synced and has contact payment history, then leave the app idle on Home for ~2 minutes. Contact rows should gain their name/avatar and correct direction on their own. This is the case that never self-healed before.

Breaking Changes

None.

One behavioural note for reviewers: PlatformAddressSyncCoordinator can now finish performStart with isRunning == true and a non-nil lastError, where it previously bailed out. That state is what drives the platform-sync status UI, and it is deliberate — the address surfaces are empty and lastError says why, while Shielded and DashPay keep working.

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

Summary by CodeRabbit

  • Bug Fixes
    • Improved same-seed identity recovery by retrying unavailable Platform scans and stopping when recovery succeeds, is canceled, or context changes.
    • Wallet startup now continues initializing available services even if a Platform address wallet cannot be loaded.
    • Payment projections run immediately once valid identity information becomes available.
    • Payment lookup changes now trigger timely transaction list refreshes.
    • Reduced unnecessary refresh notifications when payment data has not changed.
    • Improved cleanup of pending identity recovery during wallet resets.

@coderabbitai

coderabbitai Bot commented Aug 9, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The changes update DashPay payment refresh notifications, same-seed identity recovery retries, Platform startup error handling, and runtime reset cleanup.

Changes

DashPay payment refresh

Layer / File(s) Summary
Payment lookup change propagation
DashWallet/Sources/Infrastructure/SwiftDashSDK/Contacts/SwiftDashSDKContactsService.swift, DashWallet/Sources/UI/Home/Views/HomeViewModel.swift
Payment projections do not arm throttling without identity context. Payment lookup changes emit typed notifications only when snapshots differ. HomeViewModel reloads transactions and shortcuts after notifications.

Same-seed identity recovery

Layer / File(s) Summary
Identity recovery retries and reset coordination
DashWallet/Sources/Infrastructure/SwiftDashSDK/Identity/DWCurrentUserIdentityInfo.swift, DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKWalletRuntime.swift
Recovery performs immediate scans and retries only retryable failures after 20, 60, and 180 seconds. Retries validate wallet and network context. Full runtime reset awaits cancellation before stopping the SDK host.

Platform startup

Layer / File(s) Summary
Deferred address-wallet errors
DashWallet/Sources/Infrastructure/SwiftDashSDK/PlatformAddressSyncCoordinator.swift
Address-wallet failures no longer stop manager startup. The coordinator publishes deferred errors after the manager reaches the running state and preserves them across sync operations.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Runtime
  participant DWCurrentUserIdentityInfo
  participant Wallet
  participant PlatformDiscovery
  participant RetryTask
  Runtime->>DWCurrentUserIdentityInfo: Start recovery
  DWCurrentUserIdentityInfo->>Wallet: Capture wallet and network context
  DWCurrentUserIdentityInfo->>PlatformDiscovery: Run immediate discovery
  PlatformDiscovery-->>DWCurrentUserIdentityInfo: Return identity, empty result, or error
  DWCurrentUserIdentityInfo->>RetryTask: Schedule validated retry
  RetryTask->>PlatformDiscovery: Run delayed discovery
  PlatformDiscovery-->>DWCurrentUserIdentityInfo: Return discovery result
  Runtime->>DWCurrentUserIdentityInfo: Cancel recovery during reset
Loading

Possibly related PRs

Suggested reviewers: quantumexplorer, llbartekll

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main changes: in-session identity recovery and refreshed DashPay contact payment displays.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/dashpay-restore-identity-and-feed-refresh

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

…act payments

Two independent defects behind "DashPay contacts and transactions only
come back after a resync".

**The identity was found once, or not until relaunch.** Recovery runs
inside `PlatformAddressSyncCoordinator.performStart`, which early-returns
when the coordinator is already running — so a restored wallet gets
exactly one scan, in the seconds right after restore, when the network is
least likely to answer. A scan that came back empty was then recorded in
`completedContexts` as final for the process, because an empty result was
reported as success. No identity means no DashPay tabs, no contacts and
no contact payment history for the whole session.

Only a found identity now ends the search. An empty or failed scan
retries on a 20s/60s/180s backoff. The retries run outside the
runtime-start pipeline, so each pass re-resolves the live wallet through
`SwiftDashSDKHost.shared.wallet` and checks `runningNetwork` instead of
holding the handle it started with — a wipe, wallet switch or network
switch between retries ends the search rather than scanning against a
torn-down runtime.

**A failed `platformAddressWallet()` took DashPay down with it.** That
path returned before the shielded bind, the DashPay sync start AND
identity recovery, none of which use the address wallet. It now records
`lastError` and continues; `addressWallet` was already an optional the
rest of the method handles, since the no-Platform-account branch sets it
to nil and carries on.

**The feed never learned that contact payments had arrived.** A DashPay
row's true direction, amount and contact name come from
`DashPayPaymentTxLookup`, whose rows are written by an app-pulled
projection into entities `saveTouchesFeedRows` filters out, and are read
through a computed property on rows that were already rendered. The one
DashPay-aware reload fires when the identity is adopted — before the sync
loop has fetched anything. So the feed kept dash-spv's misread direction
(an outgoing contact payment reads as incoming) and a nameless "?" avatar
until something unrelated happened to touch `PersistentTransaction`.
Whether that happened decided whether the bug appeared: a wallet still
catching up repainted by accident, a quiet one never did.

The lookup now posts `DWDashPayPaymentTxLookupDidChange` when the
snapshot actually changes (`PaymentInfo` gained `Equatable` for the
comparison), and `observeDashPay()` reloads the feed on it. Gated on a
real change so the projection's timer cannot turn into a periodic
rebuild of the whole history list.

Also stop arming the projection's 60s throttle from a call that returned
early for want of an identity: that spent the launch's first window on a
no-op, and the identity typically lands seconds later.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In
`@DashWallet/Sources/Infrastructure/SwiftDashSDK/Identity/DWCurrentUserIdentityInfo.swift`:
- Around line 661-676: Update fullReset to cancel and await any active identity
recovery tasks before calling SwiftDashSDKHost.shared.stop(), ensuring suspended
discoverIdentities or syncDpnsNames work cannot access a released wallet. Also
invalidate the context so a subsequent recoverIfNeeded call can start fresh
rather than returning because activeContexts still contains the old recovery.

In
`@DashWallet/Sources/Infrastructure/SwiftDashSDK/PlatformAddressSyncCoordinator.swift`:
- Line 837: Preserve the address-wallet startup error separately from the
consumer-facing lastError in the coordinator’s startup flow around
addressWalletError. Restore that stored error after successful Platform address
sync handling in handleSyncEvent, rather than allowing success to clear it
permanently, and clear the deferred error during teardown.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: c32132d1-af19-488e-83c1-9b7ef139cb69

📥 Commits

Reviewing files that changed from the base of the PR and between b326045 and 7a18300.

📒 Files selected for processing (4)
  • DashWallet/Sources/Infrastructure/SwiftDashSDK/Contacts/SwiftDashSDKContactsService.swift
  • DashWallet/Sources/Infrastructure/SwiftDashSDK/Identity/DWCurrentUserIdentityInfo.swift
  • DashWallet/Sources/Infrastructure/SwiftDashSDK/PlatformAddressSyncCoordinator.swift
  • DashWallet/Sources/UI/Home/Views/HomeViewModel.swift

…ry on teardown

Both findings from the review on #950.

**A successful address sync erased the address-wallet failure.** Making
`platformAddressWallet()` non-fatal meant the Platform address sync now
starts even when that wallet is missing — and `handleSyncEvent` clears
`lastError` on every successful pass. The startup failure was published
once and then wiped by the first success, so the status screen reported a
healthy sync over address surfaces that cannot work. The two fail
independently, so the startup error is held in its own field and
re-published wherever `lastError` is cleared on success; teardown clears
it.

**Recovery retries could outlive the wallet.** The first attempt runs
inside the serialized runtime start, which is what kept the wallet handle
alive across its FFI scan. The retries deliberately run outside that
serialization and only re-check the wallet *before* awaiting, so a pass
suspended inside `discoverIdentities` could still be running when
`fullReset` dropped the handle. `cancelPendingWork()` cancels the pending
backoffs and — the part that matters — awaits each task, since a task
suspended in a synchronous FFI call never observes cancellation.
`fullReset` calls it before `SwiftDashSDKHost.stop()`.

Two smaller consequences of the same ownership question:

- `recoverIfNeeded` now cancels a superseded backoff *before* its guards.
  An attempt already in flight keeps `activeContexts` set, and returning
  early there left the old backoff scheduled behind it.
- A finished retry task no longer removes itself from `retryTasks`. That
  dictionary is teardown's handle on the work, and a self-removal could
  delete an entry a newer start had already replaced, putting that task
  out of reach. Awaiting a finished task is free.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In
`@DashWallet/Sources/Infrastructure/SwiftDashSDK/PlatformAddressSyncCoordinator.swift`:
- Around line 123-133: Update the lifecycle comment for
addressWalletStartupError near its declaration to include clearDisplay() as
another path that clears the retained error during local-state reset or
network-switch preparation, alongside teardown and successful wallet resolution.
- Line 134: Remove the explicit “= nil” initializer from the
addressWalletStartupError property declaration; keep it as an optional String
property so Swift’s default initialization supplies nil.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: bbed5a0a-2cbe-4446-afd6-000f0ff7b3e8

📥 Commits

Reviewing files that changed from the base of the PR and between 7a18300 and d1b063e.

📒 Files selected for processing (3)
  • DashWallet/Sources/Infrastructure/SwiftDashSDK/Identity/DWCurrentUserIdentityInfo.swift
  • DashWallet/Sources/Infrastructure/SwiftDashSDK/PlatformAddressSyncCoordinator.swift
  • DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKWalletRuntime.swift
🚧 Files skipped from review as they are similar to previous changes (1)
  • DashWallet/Sources/Infrastructure/SwiftDashSDK/Identity/DWCurrentUserIdentityInfo.swift

…cycle

Review follow-up on #950. The doc comment claimed the value is "cleared
only by teardown or a start that resolves the wallet", but `clearDisplay()`
also clears it, and that runs on wipe and on network-switch preparation
too. Those are all paths that invalidate the wallet the error was recorded
against, so the behaviour is right — the description was not, which is
exactly what the repo's comment rule exists to catch.

Also drop the redundant `= nil` on the optional (SwiftLint
`redundant_optional_initialization`).
@QuantumExplorer

Copy link
Copy Markdown
Member

Empty is provably empty, there is no reason to rescan if the network proves to you that you have no identity.

@QuantumExplorer QuantumExplorer left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

left comment above

…retry

Review follow-up on #950: "empty is provably empty, there is no reason to
rescan if the network proves to you that you have no identity."

That is right, and it is right *because* platform#4352 landed. Before it,
an empty result could mean either "Platform says this seed owns no
identity" or "we never reached Platform", so retrying an empty was the
only way to survive the second case. Now an unanswered scan raises
`IdentityDiscoveryIncomplete` and the two are distinguishable, which is
what makes a returned empty trustworthy enough to record as final.

So the backoff now fires only when the scan threw. A scan that returns —
with or without an identity — marks the context complete and stops. A
wallet that genuinely owns no identity no longer pays three pointless
network round trips per launch.

`attempt` returns "Platform answered" rather than "an identity was
found"; the call site and the doc comments say so.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In
`@DashWallet/Sources/Infrastructure/SwiftDashSDK/Identity/DWCurrentUserIdentityInfo.swift`:
- Around line 762-769: The SameSeedIdentityRecoveryPipeline.run flow currently
treats post-discovery failures like incomplete discovery. Track discovery
completion separately from refreshNames or identity-adoption errors, and make
recoverIfNeeded return false only when discoverIdentities reports an
incomplete/failed scan; if discovery completed successfully, preserve completion
and avoid scheduling discovery backoffs even when syncDpnsNames fails. Add a
regression test covering successful discovery followed by syncDpnsNames failure
and assert that no discovery retry is scheduled.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: f747b84f-39bf-4139-875a-043672f768a5

📥 Commits

Reviewing files that changed from the base of the PR and between 8a14971 and fcd6d8f.

📒 Files selected for processing (1)
  • DashWallet/Sources/Infrastructure/SwiftDashSDK/Identity/DWCurrentUserIdentityInfo.swift

Review: "the swift client should not be doing this, it should be in
rust." Correct, and `packages/swift-sdk/CLAUDE.md` says so outright —
"No iteration / gap-limit walks / policy loops in Swift", and "if it's
deciding anything — how many, which index, which path, which key, which
order — move the decision to Rust. If you find a decision that Rust
doesn't currently let you ask for by a single call, add the helper in
the Rust library first."

A hand-rolled 20s/60s/180s backoff with its own per-context bookkeeping
is exactly that. Reverting it here rather than carrying it as a stopgap,
so the policy has one home when it lands in `rs-platform-wallet`
alongside the startup-ordering work.

Little is lost in the meantime: platform#4352 already made an
unreachable scan raise `IdentityDiscoveryIncomplete` instead of an empty
success, so the restored behaviour marks a context complete only when
Platform actually answered, and a failed scan is retried on the next
runtime start. The gap that remains is healing time within one session,
not correctness.

`cancelPendingWork` and its `fullReset` hook go with it — they existed
only to keep those retry tasks from outliving the wallet.

What stays in this PR is the part that has no Rust equivalent: the
transaction feed not repainting when DashPay payment rows land, and the
address-wallet startup failure no longer taking the DashPay subsystems
down with it.
@romchornyi

Copy link
Copy Markdown
Contributor Author

Agreed, and fixed — in two steps, because the first one only fixed half of it.

fcd6d8f stopped treating an empty scan as a reason to try again: a scan that returned has an answer from Platform, and a proof of absence is not something rescanning can overturn. Only a scan that never reached Platform is worth repeating, which platform#4352 made expressible as IdentityDiscoveryIncomplete.

875268a then removed the Swift-side retry policy altogether, per your other point that this belongs in Rust. A hand-rolled 20s/60s/180s backoff with its own per-context bookkeeping is exactly what packages/swift-sdk/CLAUDE.md forbids — "no policy loops in Swift", and "if it's deciding anything […] which order — move the decision to Rust".

That decision now lives in rs-platform-wallet: platform#4359 landed in v4.2-dev this morning, and its start_wallet_subsystems owns the retry policy, including the empty-vs-unreachable distinction, with unit tests pinning it. The client call site is #961, stacked on this PR.

So this PR no longer decides anything about rescanning; it fixes the two real bugs it set out to fix (a restored identity not recovering in-session, and the home feed not repainting DashPay payment rows).

@QuantumExplorer
QuantumExplorer merged commit 1a24518 into develop Aug 11, 2026
3 checks passed
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.

3 participants