Skip to content

refactor(dashpay): call the SDK's ordered wallet bring-up before Core SPV - #961

Merged
romchornyi merged 12 commits into
developfrom
feat/ordered-dashpay-before-core-sync
Aug 11, 2026
Merged

refactor(dashpay): call the SDK's ordered wallet bring-up before Core SPV#961
romchornyi merged 12 commits into
developfrom
feat/ordered-dashpay-before-core-sync

Conversation

@romchornyi

@romchornyi romchornyi commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Rewritten after review. This started as a Swift step sequencer; the reviewer's objection — "the swift client should not be doing this, it should be in rust" — was correct, and packages/swift-sdk/CLAUDE.md forbids policy loops in Swift outright. The ordering now lives in the SDK (dashpay/platform#4359) and this PR is the call site: 209 lines of sequencing became 89 lines of one call plus logging.

Unblockeddashpay/platform#4359 merged into v4.2-dev (76f5429), so startWalletSubsystems is available and this builds against it.

Stacked on #950 — base is fix/dashpay-restore-identity-and-feed-refresh, so the diff to review here is the single commit. Retarget to develop once #950 lands.

Issue being fixed or feature implemented

A DashPay contact's DIP-15 payment addresses are derived from its contact account, and an address the wallet is not watching when the compact-filter scan passes its funding height produces no transaction at all. Core SPV therefore has to wait until those addresses exist, or a restored wallet's contact payment history is missing until the DIP-15 rescan repairs it.

The wallet had no way to wait: it started SPV first and repaired afterwards. Worse, nothing was building the contact accounts at all — the unattended sweep can only enqueue the work, and the unlock that drains that queue ran before the sweep had queued anything. Baseline on the previous build, testnet restore, 7 established contacts:

  • Deferred DashPay account build: enqueued350 times
  • contact-crypto drain skipped — no pending ops — 7 times
  • payments projection — 0 row(s) — 14 times

What was done?

DashPayContactAddressReadiness now makes one call — PlatformWalletManager.startWalletSubsystems — from the slot SwiftDashSDKSPVCoordinator already reserves for work that must precede startSpv, alongside the CoinJoin recovery-gap widen and the pending chain resync (both there for the same reason: the first filter set has to be complete).

The sequence, the discovery retry policy and the budget all live in rs-platform-wallet (platform#4359). This file holds no ordering logic of its own — it is the call site and its logging.

Deliberately placed in performStart(for:) rather than the shared performStart(manager:for:), so a Core-only restart does not pay for a Platform round trip it cannot benefit from. The coordinator start order in SwiftDashSDKWalletRuntime is untouched: swapping those two calls would also move the shielded bind ahead of SPV, which nothing here needs.

The statuses are better than the Swift version could report. It knew only "ready" or "budget spent"; the SDK distinguishes a Platform that proved this seed owns no identity from one that never answered, so the log no longer reads as a failure when there is simply nothing to prepare.

reconcile_dashpay_rescan (DIP-15 §12.6) stays load-bearing — contacts established later in a session, or on a later day, always arrive after the scan. This removes the restore case from its workload, not the mechanism.

How Has This Been Tested?

Clean dashpay build. Testnet session on the iOS 26.5 simulator, wallet with 7 established contacts:

👥 DP-READY :: ready for SPV in 8.7s scans=1 drained=14
🛰️ SPVCOORD :: started on 1
  • 14 contact-account builds drained, against the 350-enqueue / 0-drain baseline above.
  • 700 DIP-15 contact addresses registered as a result; reconcile_sent_payments_from_tx_history matched 18 txids and the payment projection went 0 → 21 rows, with contact rows rendering names and correct direction.
  • On a freshly created wallet the sequence correctly no-ops (no identity for this seed; nothing to prepare before SPV) and SPV starts immediately — verified three times in one session, no added launch latency.

Worth being precise about what that run proves: the wallet needed ~12 minutes to catch up on chain, so the payments were recovered by reconcile_dashpay_rescan, not by the ordering. The ordering targets a different case — a restore against an already-synced chain, where the blocks have been walked and nothing will re-scan them. Both mechanisms are needed; this run exercised the drain and the sequencing, and confirmed neither breaks the rescan path.

No unit tests: the unit-test target is currently broken (pre-existing, noted in CLAUDE.md), and the logic that used to live here is now tested in rs-platform-wallet (8 tests). Not yet run on a physical device.

Breaking Changes

None.

For reviewers: Core SPV start can be delayed by up to the SDK's budget (20s default) on a launch that has DashPay work to do. It self-skips when there is nothing to prepare, and on expiry falls through to the previous behaviour — the fallback is never worse than what shipped before.

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

  • New Features
    • DashPay prepares contact addresses before synchronization begins.
    • Contact and identity recovery states are handled automatically, including pending or partially recovered information.
  • Bug Fixes
    • Synchronization continues when contact-address preparation encounters an issue.
    • Wallet startup and local contact discovery are more reliable.
    • Repeated startup attempts are handled consistently when synchronization is already in progress.
    • Recovery preparation is skipped when wallet information does not match, preventing unnecessary failures.

…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 commented Aug 10, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@romchornyi, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 14 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: b41fdeff-00f1-47ce-80ea-7758d8a18a84

📥 Commits

Reviewing files that changed from the base of the PR and between cafd58d and 0d31333.

📒 Files selected for processing (1)
  • DashWallet/Sources/Infrastructure/SwiftDashSDK/Contacts/DashPayContactAddressReadiness.swift
📝 Walkthrough

Walkthrough

DashPay startup now validates wallet binding and prepares contact-address subsystems before Core SPV synchronization. Readiness failures do not stop SPV startup. The SPV coordinator also centralizes repeated-start checks.

Changes

DashPay contact readiness

Layer / File(s) Summary
Readiness coordinator implementation
DashWallet/Sources/Infrastructure/SwiftDashSDK/Contacts/DashPayContactAddressReadiness.swift, DashWallet.xcodeproj/project.pbxproj
Adds a main-actor, DashPay-only coordinator that validates the stored seed, starts wallet subsystems asynchronously, and logs distinct readiness outcomes without propagating errors. The source file is included in the Xcode target.
Pre-SPV readiness integration
DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKSPVCoordinator.swift, DashWallet/Sources/Infrastructure/SwiftDashSDK/PlatformAddressSyncCoordinator.swift
SPV startup retains the managed wallet and awaits contact-address readiness before continuing. A shared helper handles repeated-start checks. Comments describe identity recovery as a pre-SPV step and fallback path.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Sequence Diagram(s)

sequenceDiagram
  participant SwiftDashSDKSPVCoordinator
  participant PlatformWalletManager
  participant DashPayContactAddressReadiness
  participant CoreSPV
  SwiftDashSDKSPVCoordinator->>PlatformWalletManager: start(network:)
  PlatformWalletManager-->>SwiftDashSDKSPVCoordinator: ManagedPlatformWallet
  SwiftDashSDKSPVCoordinator->>DashPayContactAddressReadiness: await readiness
  DashPayContactAddressReadiness->>PlatformWalletManager: startWalletSubsystems
  DashPayContactAddressReadiness-->>SwiftDashSDKSPVCoordinator: continue despite startup errors
  SwiftDashSDKSPVCoordinator->>CoreSPV: start shared SPV flow
Loading

Possibly related PRs

  • dashpay/dashwallet-ios#950: Both PRs modify PlatformAddressSyncCoordinator.performStart around DashPay identity and address-wallet startup behavior.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 33.33% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the main change: calling the SDK's ordered wallet bring-up before Core SPV.
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.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/ordered-dashpay-before-core-sync

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

…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.
@romchornyi
romchornyi force-pushed the feat/ordered-dashpay-before-core-sync branch from cee4d63 to 16f8b46 Compare August 10, 2026 13:51
…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`).
@romchornyi
romchornyi force-pushed the feat/ordered-dashpay-before-core-sync branch from 16f8b46 to 89fd5aa Compare August 10, 2026 14:13
…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.
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
romchornyi marked this pull request as draft August 10, 2026 15:15
@romchornyi

Copy link
Copy Markdown
Contributor Author

Converting to draft. The reviewer is right that this belongs in Rust, and packages/swift-sdk/CLAUDE.md says so explicitly:

No iteration / gap-limit walks / policy loops in Swift. […] 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.

DashPayContactAddressReadiness is a hand-rolled step sequencer with a wall-clock budget and two poll loops — squarely what that forbids.

One idea investigated and rejected before landing here: making the unattended DashPay sweep resolve its own signer Rust-side, the way discover_identities already does, so contact accounts are never deferred and no client needs to sequence anything. That does not work as a refactor. MnemonicResolver is a Swift-owned vtable pinned to a single synchronous FFI call; making the 15s sweep self-sufficient would turn a per-call Keychain capability into a standing one, and drain_auto_accepts needs a full identity Signer, not just an ECDH/xpub derive. That is a security-posture change, not a simplification.

The replacement is a single Rust orchestration entry point on PlatformWalletManager that owns the ordering decision — identity discovery (with the retry policy just reverted out of #950) → one DashPay sync pass → drain with caller-supplied handles → start the periodic coordinators. Swift then makes one bounded call and starts Core SPV when it returns. That also removes the two competing Swift-side sequencers that exist today (PlatformAddressSyncCoordinator.performStart and this file).

Keeping the branch rather than closing it: the testnet evidence it produced is what justifies the Rust work — 14 deferred account builds drained where the previous build logged 350 enqueues and 0 drains, and payments projection went from 0 rows to 3.

Startup order is now identity → contacts → contact accounts → core sync.

A contact's DIP-15 addresses are derived from its contact account, and an
address the wallet is not watching when the scan passes its funding
height produces no transaction at all. Until now the wallet started Core
SPV first and repaired afterwards: `reconcile_dashpay_rescan` (DIP-15
§12.6) lowers the SPV synced height once a contact account finally
appears, so the scan re-walks blocks it had already covered. On a
restore that is the slowest possible path to a correct contact history.

The third step is the one that is easy to get wrong. After
`dashPaySyncNow()` the accounts still do not exist: the recurring DashPay
sweep runs unattended, holds no signer, and can only enqueue the builds
("Deferred DashPay account build"). They come into being when a
signer-present drain runs, so the sequence unlocks from the keychain and
waits for the queue to settle. Stopping after the contact sync would have
ordered the steps correctly and still started SPV with nothing extra to
watch.

Lives in the slot `SwiftDashSDKSPVCoordinator` already reserves for work
that must precede `startSpv`, alongside the CoinJoin recovery gap widen
and the pending chain resync — both there for the same reason, that the
first filter set has to be complete. Placed in `performStart(for:)` and
not the shared `performStart(manager:for:)` so a Core-only restart does
not pay for a Platform round trip it cannot benefit from. The coordinator
start order in `SwiftDashSDKWalletRuntime` is deliberately untouched:
swapping those two calls would also move the shielded bind ahead of SPV,
which nothing here needs.

Core sync is the wallet's primary function and Platform is not, so the
whole sequence is bounded at 20s and every step is best-effort. When the
budget is spent SPV starts anyway and the DIP-15 rescan backfills — the
fallback is exactly the behaviour that shipped before, never worse. The
bound is a real deadline, not just a between-steps check: the contact
sync is polled to a completion flag so a hung pass cannot hold the wallet
without a balance. It is not cancelled, because cancelling a Swift task
cannot abort a call already inside Rust, and a late pass still lands.

`reconcile_dashpay_rescan` stays load-bearing regardless — contacts
established later in a session, or on a later day, always arrive after
the scan. This removes the restore case from its workload, not the
mechanism.
Review: "the swift client should not be doing this, it should be in
rust." `packages/swift-sdk/CLAUDE.md` agrees — no 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."

So the helper was added first. `PlatformWalletManager.startWalletSubsystems`
now owns the sequence, the discovery retry policy and the budget; this
file is the call site and its logging, holding no ordering logic of its
own. The hand-rolled step sequencer, the wall-clock deadline and the two
poll loops are gone — 209 lines down to 89, and Android gets the same
behaviour without reimplementing it.

The statuses are also better than what the Swift version could report.
It could only say "ready" or "budget spent"; the SDK distinguishes a
Platform that proved this seed owns no identity from one that never
answered, so the log no longer reads as a failure when there is simply
nothing to prepare.

One idea rejected on the way: making the unattended DashPay sweep
resolve its own signer, which would have removed the need for any
sequencing at all. `MnemonicResolver` is a Swift-owned vtable pinned to
a single synchronous call, and the auto-accept half of the drain needs a
full identity signer — turning either into a standing capability is a
security-posture change, not a refactor.
@romchornyi
romchornyi force-pushed the feat/ordered-dashpay-before-core-sync branch from 89fd5aa to 75c0fbd Compare August 10, 2026 16:05
@romchornyi romchornyi changed the title feat(dashpay): make contact addresses exist before Core SPV starts refactor(dashpay): call the SDK's ordered wallet bring-up before Core SPV Aug 10, 2026
Follows the SDK gaining a terminal `discoveryFailed` status
(dashpay/platform#4359): a wallet or persistence fault, as opposed to an
unreachable Platform. Logged at error rather than warning because it is
the one outcome here that nothing will clear on its own — not this
session, not the next launch.
@romchornyi

Copy link
Copy Markdown
Contributor Author

Unblocked and out of draft: platform#4359 merged into v4.2-dev (76f5429), so PlatformWalletManager.startWalletSubsystems is available and this branch builds against it.

Re-verified after the merge, since #4359 changed on its way in (the drains became deadline-bounded and self-clearing, the scan key became a lazily-invoked resolver erased by its own guard):

  • SwiftDashSDK for iOS Simulator — clean.
  • dashpay scheme, Debug, iOS 26.5 simulator — clean.

Unrelated finding while doing that, worth its own fix: the dashpay target's Testnet configuration is pinned to IPHONEOS_DEPLOYMENT_TARGET = 17.0 while the Podfile declares platform :ios, '18.0' and the other 23 configurations are 18.0. So xcodebuild -scheme dashpay -configuration Testnet fails on every branch — pods built for 18.0 cannot be imported into a 17.0 compilation. Under Xcode 26's explicit modules it surfaces as unable to resolve module dependency: 'SQLite', which reads like a CocoaPods problem; with SWIFT_ENABLE_EXPLICIT_MODULES=NO the real message appears. Introduced by ede279b — not by this PR, and not something I changed here.

Still stacked on #950; will retarget to develop once that lands.

@romchornyi
romchornyi marked this pull request as ready for review August 11, 2026 08:41

@llbartekll llbartekll left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The new startup call bypasses the existing wrong-seed guard. On a persisted restore, loadFromPersistor() calls unlockWalletFromKeychain; if the Keychain mnemonic does not bind to this wallet, that verification rejects it and publishes dashPayUnlockStatus[walletId].seedMismatch specifically so DashPay signing stays disabled. awaitReady then unconditionally calls startWalletSubsystems, whose Swift bridge creates a fresh MnemonicResolver / KeychainSigner but neither runs verify_seed_binds nor honors that mismatch state. For a wallet with an already-known identity it therefore proceeds directly to contact sync/drain using the rejected seed, potentially deriving/registering incorrect DIP-15 accounts or attempting auto-accept signing. Please enforce the seed-binding check inside the SDK startup helper (preferred, so every client is protected), or otherwise prevent mnemonic-derived startup work when the manager has already rejected that wallet/seed binding.

@romchornyi
romchornyi changed the base branch from fix/dashpay-restore-identity-and-feed-refresh to develop August 11, 2026 09:15

@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/SwiftDashSDKSPVCoordinator.swift`:
- Around line 242-243: The performStart(manager:for:) flow must check its
existing same-network-running guard before calling
DashPayContactAddressReadiness.awaitReady, so repeated starts return without
re-running startWalletSubsystems or waiting for readiness. Preserve the current
idempotent behavior, and only perform readiness retries through a separate retry
path.
🪄 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: 2e53bd01-2c39-49b2-ae6b-e1a213ec6fa2

📥 Commits

Reviewing files that changed from the base of the PR and between 1a24518 and a322353.

📒 Files selected for processing (4)
  • DashWallet.xcodeproj/project.pbxproj
  • DashWallet/Sources/Infrastructure/SwiftDashSDK/Contacts/DashPayContactAddressReadiness.swift
  • DashWallet/Sources/Infrastructure/SwiftDashSDK/PlatformAddressSyncCoordinator.swift
  • DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKSPVCoordinator.swift

Comment thread DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKSPVCoordinator.swift Outdated
From review on #961.

The readiness pass sits in `performStart(for:)` rather than the shared
`performStart(manager:for:)` on purpose — a Core-only restart re-enters
the shared one and must not pay for a Platform round trip it cannot
benefit from. But the start-elision guard lives in that shared path, so
a repeated start on a running network reached the readiness pass first
and waited out the SDK's whole startup budget before hitting a guard
that returns immediately.

The condition now has one home, `isAlreadyRunning(manager:network:)`,
consulted by both. Duplicating it inline would have left the two free to
drift back apart, which is how this appeared in the first place.
@romchornyi
romchornyi requested a review from llbartekll August 11, 2026 09:58

@llbartekll llbartekll left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Re-reviewed at ddb60cf. The repeated-start fix looks correct: the shared isAlreadyRunning predicate now prevents the readiness pass from adding latency when SPV is already up.

The blocker from my previous review is still unresolved, though. I also checked the current v4.2-dev implementation of startWalletSubsystems: it still constructs a fresh MnemonicResolver / KeychainSigner without performing the cached seed-binding verification or honoring dashPayUnlockStatus[walletId].seedMismatch. Therefore this call can still run mnemonic-derived contact-account/auto-accept work after unlockWalletFromKeychain has explicitly rejected that Keychain seed for this wallet. Please keep this as changes requested until the SDK helper enforces the binding (preferred) or the iOS call site skips the helper after a mismatch.

From review on #961.

`startWalletSubsystems` builds its own `MnemonicResolver` and
`KeychainSigner`, so it did not inherit the wrong-seed gate that
`unlockWalletFromKeychain` applies. On a wallet whose identity is already
known the bring-up skips discovery and goes straight to the contact sync
and drain — deriving DIP-15 contact accounts from a mnemonic this wallet
has already rejected.

That damage does not heal. `register_contact_account`'s existence check
keys on the contact pair, not the xpub, so a wrong receiving xpub is
written once and every later correct-seed run no-ops over it; unlike the
external-account side there is no rotation sweep to rebuild it. The
wallet ends up watching addresses nobody pays to, with no symptom beyond
payments that never appear.

The verify runs here rather than reading the published `seedMismatch`
flag, because that flag is racing this call:
`SwiftDashSDKHost.loadPersistedWallet` schedules the unlock in a detached
task and returns immediately, so on the launch where the seed is wrong
the flag may not be set yet. Performing the verification is what makes
the check race-free. It is marker-cached in the SDK, so a match costs a
string comparison rather than a Keychain read.

A watch-only wallet still proceeds: with no stored mnemonic there is no
binding to contradict, and the bring-up already handles that case.
@romchornyi

Copy link
Copy Markdown
Contributor Author

Fixed in cafd58d — taking your second option, the iOS call site, so this does not wait on an SDK release.

Two things I found while tracing it that sharpen the report:

The published flag cannot be the gate. SwiftDashSDKHost.loadPersistedWallet schedules unlockDashPayContactCrypto in a detached, un-awaited Task and returns the wallet immediately, and performStart goes straight into awaitReady. So dashPayUnlockStatus[walletId].seedMismatch is not merely not honored by the new call — it is racing it, and on the launch where the seed is wrong it may not be set yet. Reading the flag would have closed the hole only when the detached task happened to win. The fix therefore performs the verification itself (unlockWalletFromKeychain, whose marker cache makes a match a string comparison rather than a Keychain read) and skips the bring-up when it throws. Race-free by construction.

The RegisterReceiving half does not self-heal, which is what makes this worth blocking over. register_contact_account's early-exit keys on (index, owner, friend) and never on the xpub value, and unlike the external-account side there is no rotation/rebuild sweep for receiving accounts. So a wrong xpub is written once and every later correct-seed run no-ops over it — the wallet watches addresses nobody pays to, with no symptom except payments that never arrive. RegisterExternal is self-defending by contrast (a wrong-seed ECDH product fails the xpub format parse, marks the channel broken, and the rotation sweep rebuilds it), and the auto-accept leg cannot broadcast at all: KeychainSigner passes the on-chain public key as a binding and the signer rejects a wrong-seed key before signing.

Clean dashpay build on the iOS 26.5 simulator.

On your preferred fix — enforcing this inside the SDK helper so every client is protected — I agree that is the right home, and it is not what this PR does. The reason it belongs in start_wallet_subsystems in rs-platform-wallet rather than the Swift wrapper is the same race: only a check inside the call that does the derivation is ordering-independent. It also needs the marker/stamp threaded through the FFI to stay cheap, plus a terminal SeedMismatch status, which is an ABI addition. Happy to open that against v4.2-dev as a follow-up if you want it before this lands rather than after.

@romchornyi
romchornyi requested a review from llbartekll August 11, 2026 10:12

@llbartekll llbartekll left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The seed-mismatch hole is closed, but using unlockWalletFromKeychain as the verification primitive introduces a drain race. This method is not verify-only: when pending contact-crypto exists it starts a detached drain and sets the Swift-side dashPayUnlockStatus.draining guard. Immediately afterward startWalletSubsystems runs its own Rust-side drain, which does not consult or publish that guard. The existing SDK code explicitly avoids stacking drains because two snapshots can duplicate the network/ECDH work and race channel-broken/auto-accept side effects. This is reachable both with a persisted pending queue and when the host watcher sees work queued by the inline contact sync while the startup drain is running. Please make the preflight use a verify-only binding API, or otherwise serialize/centralize the drain so the verification and ordered bring-up cannot launch competing drains.

From review on #961.

The previous commit used `unlockWalletFromKeychain` as the preflight,
which is not a verification primitive: when the contact-crypto queue is
non-empty it also starts a detached drain, so the bring-up's own drain
would race a second one over a second snapshot — duplicated network and
ECDH work, and competing channel-broken / auto-accept writes.

There was no verify-only API to call instead, so the gate moved into the
SDK, where it belongs anyway: dashpay/platform#4368 adds
`PlatformWalletManager.verifySeedBinding` and has `startWalletSubsystems`
call it before touching any key material. Every client is now protected,
not just this call site.

This file goes back to what it was: the call site and its logging. A
mismatch now surfaces as a throw from the bring-up, which the existing
catch already handles the right way — log it and start Core SPV, because
a wallet without a balance is worse than a wallet without DashPay state.

Requires platform#4368; the SDK is a local path dependency.
@romchornyi

Copy link
Copy Markdown
Contributor Author

You are right, and the drain race is exactly why there was no clean way to do this app-side: unlockWalletFromKeychain was the only reachable verification, DashSDKFFI is not an exported SPM product, and the SDK had no verify-only entry point — so asking the question at all meant starting a drain.

So I took your preferred option instead: dashpay/platform#4368.

  • PlatformWalletManager.verifySeedBinding(_:) — step 1 of the unlock, extracted verbatim and made public. Returns .verified / .watchOnly, throws on mismatch, and does nothing else. unlockWalletFromKeychain is now that call plus its drain, so no existing caller changes behaviour.
  • startWalletSubsystems calls it before touching any key material, so every client is protected rather than every client having to remember.

0d31333b here drops the app-side workaround: this file goes back to being the call site and its logging, and a mismatch surfaces as a throw that the existing catch already handles correctly — log it, start Core SPV.

One point from your first review that I want to put on the record, because it shaped the fix: the published seedMismatch flag could not have been the gate either. SwiftDashSDKHost.loadPersistedWallet schedules the unlock in a detached, un-awaited Task and returns the wallet immediately, and performStart goes straight into the bring-up — so the flag races the very call it is supposed to guard and, on the launch where the seed is wrong, may not be set yet. Only verification performed inside the call is ordered with respect to the work it protects. That reasoning is in the platform PR description too.

I also stated there what I could confirm about blast radius, so the fix is not over-scoped: RegisterReceiving is the branch that matters, because register_contact_account keys its existence check on the contact pair rather than the xpub and there is no rotation sweep for receiving accounts — a wrong xpub is written once and never revisited. RegisterExternal is self-defending (the ECDH product fails the xpub format parse, the channel is marked broken, the rotation sweep rebuilds it), and the auto-accept leg cannot broadcast at all, since KeychainSigner binds the on-chain public key and rejects a wrong-seed key before signing.

This PR now needs #4368 to land first — the SDK is a local path dependency. Clean dashpay build against that branch on the iOS 26.5 simulator.

@romchornyi
romchornyi requested a review from llbartekll August 11, 2026 10:42
@romchornyi

Copy link
Copy Markdown
Contributor Author

Dependency cleared: dashpay/platform#4368 merged into v4.2-dev (e87142b), so the seed-binding gate now lives in startWalletSubsystems itself and every client inherits it.

State of this PR against that:

  • 0d31333b removed the app-side workaround — this file is back to being the call site and its logging, with no gate of its own. A mismatched seed surfaces as a throw from the bring-up, which the existing catch handles the way it should: log it, start Core SPV.
  • Rebuilt against the merged SDK, not the branch: clean dashpay build on the iOS 26.5 simulator.
  • Even with develop — zero commits behind.

Diff is 4 files / 129 lines, all of it the call site: DashPayContactAddressReadiness, the two coordinator hooks, and the pbxproj entry.

@llbartekll both of your points are addressed — the seed-binding hole in the SDK, and the drain race that the first attempt at fixing it introduced. Ready for another look whenever you have a moment.

@romchornyi
romchornyi merged commit 4a577d3 into develop Aug 11, 2026
2 checks passed
@romchornyi
romchornyi deleted the feat/ordered-dashpay-before-core-sync branch August 11, 2026 11:15
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