From 4ce79f48f733b856c970e9458d347e9de80096b6 Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Mon, 10 Aug 2026 00:37:56 +0300 Subject: [PATCH 1/5] fix(dashpay): recover a restored identity in-session and repaint contact payments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../SwiftDashSDKContactsService.swift | 33 ++++- .../Identity/DWCurrentUserIdentityInfo.swift | 115 +++++++++++++++++- .../PlatformAddressSyncCoordinator.swift | 19 ++- .../Sources/UI/Home/Views/HomeViewModel.swift | 16 +++ 4 files changed, 172 insertions(+), 11 deletions(-) diff --git a/DashWallet/Sources/Infrastructure/SwiftDashSDK/Contacts/SwiftDashSDKContactsService.swift b/DashWallet/Sources/Infrastructure/SwiftDashSDK/Contacts/SwiftDashSDKContactsService.swift index c4c0328b1..1b59d1a88 100644 --- a/DashWallet/Sources/Infrastructure/SwiftDashSDK/Contacts/SwiftDashSDKContactsService.swift +++ b/DashWallet/Sources/Infrastructure/SwiftDashSDK/Contacts/SwiftDashSDKContactsService.swift @@ -249,7 +249,6 @@ final class SwiftDashSDKContactsService: ObservableObject { // refreshPaymentsProjection), so ride the snapshot refresh at // most once a minute. if Date().timeIntervalSince(lastPaymentsProjection) > 60 { - lastPaymentsProjection = Date() refreshPaymentsProjection() } } @@ -269,8 +268,14 @@ final class SwiftDashSDKContactsService: ObservableObject { guard let manager = SwiftDashSDKHost.shared.manager, let wallet = SwiftDashSDKHost.shared.wallet, let ownerId = DWCurrentUserIdentityInfo.shared.identityId else { + // No identity yet: nothing was pulled, so leave the piggyback + // throttle unarmed. Arming it here spent the launch's first + // window on a call that returned immediately — the identity + // typically lands seconds later, and the next chance to project + // its payments was then a minute away. return } + lastPaymentsProjection = Date() do { let payments = try manager.refreshDashPayPayments( walletId: wallet.walletId, @@ -964,7 +969,7 @@ final class ContactsNotificationsBridge: NSObject { final class DashPayPaymentTxLookup { static let shared = DashPayPaymentTxLookup() - struct PaymentInfo: Sendable { + struct PaymentInfo: Sendable, Equatable { let amountDuffs: UInt64 /// True when the wallet's identity SENT this payment. let isOutgoing: Bool @@ -1061,9 +1066,33 @@ final class DashPayPaymentTxLookup { } } + /// Swap the snapshot in, and say so when it actually changed. + /// + /// The signal matters because nothing else carries it. The payment rows + /// behind this snapshot are written by an app-pulled projection, not by + /// the SDK persister, and they live in entities the transaction feed's + /// SwiftData-save filter ignores — so a feed already on screen kept + /// rendering rows with dash-spv's misread direction and no contact name + /// for the rest of the session. That was the whole of "DashPay + /// transactions only come back after a resync": the data was correct in + /// this cache, and nobody asked it again. + /// + /// Gated on a real change: the projection re-runs on a timer, and an + /// unconditional post would rebuild the whole history list every pass. private func store(_ map: [String: PaymentInfo]) { lock.lock() + let changed = infoByTxid != map infoByTxid = map lock.unlock() + + guard changed else { return } + NotificationCenter.default.post(name: Self.didChangeNotification, object: nil) } } + +extension DashPayPaymentTxLookup { + /// Posted when the txid → DashPay-payment snapshot gained, lost, or + /// altered an entry. Consumers re-read `info(forTxidHex:)`. + static let didChangeNotification = + Notification.Name("DWDashPayPaymentTxLookupDidChange") +} diff --git a/DashWallet/Sources/Infrastructure/SwiftDashSDK/Identity/DWCurrentUserIdentityInfo.swift b/DashWallet/Sources/Infrastructure/SwiftDashSDK/Identity/DWCurrentUserIdentityInfo.swift index 61167aa94..88c704ccc 100644 --- a/DashWallet/Sources/Infrastructure/SwiftDashSDK/Identity/DWCurrentUserIdentityInfo.swift +++ b/DashWallet/Sources/Infrastructure/SwiftDashSDK/Identity/DWCurrentUserIdentityInfo.swift @@ -614,9 +614,9 @@ enum SameSeedIdentityRecoveryPipeline { } /// Best-effort startup recovery for an identity created by the same seed on a -/// different device/install. One successful attempt is enough per -/// network-scoped wallet and process; failures remain retryable on the next -/// runtime start. +/// different device/install. Finding an identity is the only outcome that ends +/// the search: an empty or failed scan is retried on a backoff inside this +/// session, and again on the next runtime start. @MainActor final class DWSameSeedIdentityRecoveryCoordinator { static let shared = DWSameSeedIdentityRecoveryCoordinator() @@ -625,8 +625,21 @@ final class DWSameSeedIdentityRecoveryCoordinator { subsystem: "org.dashfoundation.dash", category: "swift-sdk-migration.identity-recovery") + /// In-session retry schedule. + /// + /// The scan runs during runtime start, right after a restore — the worst + /// moment to need the network. Every probe verifies its proof against + /// quorum keys the SDK pulls from one HTTPS endpoint whose cache is cold + /// on each launch, so a scan that starts before that endpoint answers + /// fails whole rather than per-node, and no amount of DAPI-level retrying + /// helps. Retrying the scan itself does, which is why the first retry is + /// soon; the later ones spread out so a genuinely identity-less wallet + /// costs three cheap lookups, not a poll. + private static let retryDelays: [Duration] = [.seconds(20), .seconds(60), .seconds(180)] + private var completedContexts: Set = [] private var activeContexts: Set = [] + private var retryTasks: [String: Task] = [:] private init() {} @@ -645,13 +658,56 @@ final class DWSameSeedIdentityRecoveryCoordinator { return } + // A fresh runtime start supersedes any backoff still pending from the + // previous one — it re-runs the same first attempt below. + retryTasks.removeValue(forKey: contextKey)?.cancel() + + let found = await attempt( + wallet: wallet, + modelContainer: modelContainer, + network: network, + contextKey: contextKey) + + if !found { + scheduleRetries( + walletId: walletId, + modelContainer: modelContainer, + network: network, + contextKey: contextKey) + } + } + + /// One recovery pass. Returns `true` only when the wallet now has an + /// identity — the sole outcome that stops the search. + /// + /// A scan that completes without finding one is NOT success. It used to be + /// recorded as final for the process, so a restore whose scan came back + /// empty for network reasons left the wallet identity-less (no DashPay + /// tabs, no contacts, no contact payment history) until the next launch. + /// Platform now reports an unanswered scan as an error rather than an + /// empty result, but a genuinely empty result stays retryable here too: + /// the identity may simply not be registered yet at scan time. + private func attempt( + wallet: ManagedPlatformWallet, + modelContainer: ModelContainer, + network: Network, + contextKey: String + ) async -> Bool { + guard !completedContexts.contains(contextKey), + !activeContexts.contains(contextKey) + else { + return completedContexts.contains(contextKey) + } + activeContexts.insert(contextKey) defer { activeContexts.remove(contextKey) } do { let outcome = try await SameSeedIdentityRecoveryPipeline.run( localIdentityIds: { - Self.localIdentityIds(walletId: walletId, modelContainer: modelContainer) + Self.localIdentityIds( + walletId: wallet.walletId, + modelContainer: modelContainer) }, discover: { Self.logger.info( @@ -695,6 +751,15 @@ final class DWSameSeedIdentityRecoveryCoordinator { DWCurrentUserIdentityInfo.shared.reconcileRecoveredIdentity() }) + guard outcome.identityCount > 0 else { + Self.logger.info( + """ + 🪪 IDENT-RECOVERY :: scan found no identity; will retry \ + discovered=\(outcome.discoveredCount, privacy: .public) + """) + return false + } + completedContexts.insert(contextKey) Self.logger.info( """ @@ -703,12 +768,52 @@ final class DWSameSeedIdentityRecoveryCoordinator { identities=\(outcome.identityCount, privacy: .public) \ adopted=\(outcome.adopted, privacy: .public) """) + return true } catch { Self.logger.warning( """ - 🪪 IDENT-RECOVERY :: failed; will retry after next runtime start: \ + 🪪 IDENT-RECOVERY :: failed; will retry: \ \(String(describing: error), privacy: .public) """) + return false + } + } + + /// Walk the backoff until a pass finds an identity or the schedule runs + /// out. Runs outside the runtime-start pipeline, so each pass re-resolves + /// the live wallet instead of holding the handle it was started with: a + /// wipe, a wallet switch, or a network switch between retries must end the + /// search rather than scan against a torn-down runtime. + private func scheduleRetries( + walletId: Data, + modelContainer: ModelContainer, + network: Network, + contextKey: String + ) { + retryTasks[contextKey] = Task { [weak self] in + for delay in Self.retryDelays { + try? await Task.sleep(for: delay) + guard !Task.isCancelled, let self else { return } + guard !self.completedContexts.contains(contextKey) else { return } + + guard let wallet = SwiftDashSDKHost.shared.wallet, + wallet.walletId == walletId, + PlatformAddressSyncCoordinator.shared.runningNetwork == network + else { + Self.logger.info( + "🪪 IDENT-RECOVERY :: retry abandoned — runtime moved on") + return + } + + if await self.attempt( + wallet: wallet, + modelContainer: modelContainer, + network: network, + contextKey: contextKey) { + break + } + } + self?.retryTasks.removeValue(forKey: contextKey) } } diff --git a/DashWallet/Sources/Infrastructure/SwiftDashSDK/PlatformAddressSyncCoordinator.swift b/DashWallet/Sources/Infrastructure/SwiftDashSDK/PlatformAddressSyncCoordinator.swift index 42c6b3a0d..d23a1b4e9 100644 --- a/DashWallet/Sources/Infrastructure/SwiftDashSDK/PlatformAddressSyncCoordinator.swift +++ b/DashWallet/Sources/Infrastructure/SwiftDashSDK/PlatformAddressSyncCoordinator.swift @@ -744,20 +744,31 @@ public final class PlatformAddressSyncCoordinator: NSObject, ObservableObject { let accountAvailability = resolvePlatformAccountAvailability( walletId: resolvedWallet.walletId) let addressWallet: ManagedPlatformAddressWallet? + // Carried to the end rather than returned on: see below. + var addressWalletError: String? do { addressWallet = try resolvedWallet.platformAddressWallet() } catch { + addressWallet = nil if accountAvailability == .unavailable { // A wallet can legitimately have Shielded state without a // DIP-17 Platform Payment account. Keep the shared manager // alive for Shielded/DashPay and expose a neutral UI state. - addressWallet = nil Self.logger.info( "🛰️ PLATFORM-ADDR :: no Platform Payment account; continuing without address wallet") } else { + // Report the failure, but do not abort the start. Shielded, + // the DashPay sync loop and identity recovery share the + // manager, not the address wallet, and returning here took all + // three down with it: a restored wallet that hit this on the + // one start it gets per session lost its identity — and with + // it every contact and all contact payment history — until the + // app was relaunched. `addressWallet` is already an optional + // the rest of this method handles (the branch above sets it to + // nil and continues), so the only difference here is that + // `lastError` explains why the address surfaces are empty. Self.logger.error("🛰️ PLATFORM-ADDR :: platformAddressWallet() failed: \(String(describing: error), privacy: .public)") - lastError = "platformAddressWallet failed: \(error.localizedDescription)" - return + addressWalletError = "platformAddressWallet failed: \(error.localizedDescription)" } } @@ -823,7 +834,7 @@ public final class PlatformAddressSyncCoordinator: NSObject, ObservableObject { self.platformAccountAvailability = accountAvailability self.runningNetwork = network self.isRunning = true - self.lastError = nil + self.lastError = addressWalletError subscribeToManager(manager: manager, walletId: resolvedWallet.walletId) refreshDerivedAddresses() diff --git a/DashWallet/Sources/UI/Home/Views/HomeViewModel.swift b/DashWallet/Sources/UI/Home/Views/HomeViewModel.swift index 1f115217b..9f021260c 100644 --- a/DashWallet/Sources/UI/Home/Views/HomeViewModel.swift +++ b/DashWallet/Sources/UI/Home/Views/HomeViewModel.swift @@ -2063,6 +2063,22 @@ extension HomeViewModel { // sync loop (PlatformAddressSyncCoordinator). } .store(in: &cancellableBag) + + // The reload above fires when the identity is adopted, which is before + // the DashPay sync loop has had a pass to fetch anything — so it runs + // against an empty payment lookup and was the only DashPay-aware + // trigger the feed had. The payments themselves land later, written by + // an app-pulled projection into entities `saveTouchesFeedRows` filters + // out, and are read through a computed property on rows that were + // already rendered. Without this the feed kept dash-spv's misread + // direction and a nameless contact for the rest of the session. The + // lookup posts only on a real change, so this is not a periodic reload. + NotificationCenter.default.publisher(for: DashPayPaymentTxLookup.didChangeNotification) + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + self?.txReloadRequests.send() + } + .store(in: &cancellableBag) } } #endif From d1b063eb70766f5e8bc477597675b04d595cac15 Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Mon, 10 Aug 2026 16:50:51 +0300 Subject: [PATCH 2/5] fix(dashpay): keep the address-wallet error visible and settle recovery on teardown MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../Identity/DWCurrentUserIdentityInfo.swift | 44 ++++++++++++++++--- .../PlatformAddressSyncCoordinator.swift | 21 ++++++++- .../SwiftDashSDKWalletRuntime.swift | 7 +++ 3 files changed, 65 insertions(+), 7 deletions(-) diff --git a/DashWallet/Sources/Infrastructure/SwiftDashSDK/Identity/DWCurrentUserIdentityInfo.swift b/DashWallet/Sources/Infrastructure/SwiftDashSDK/Identity/DWCurrentUserIdentityInfo.swift index 88c704ccc..74ea069ae 100644 --- a/DashWallet/Sources/Infrastructure/SwiftDashSDK/Identity/DWCurrentUserIdentityInfo.swift +++ b/DashWallet/Sources/Infrastructure/SwiftDashSDK/Identity/DWCurrentUserIdentityInfo.swift @@ -652,16 +652,19 @@ final class DWSameSeedIdentityRecoveryCoordinator { let walletHex = walletId.map { String(format: "%02x", $0) }.joined() let contextKey = "\(network.rawValue):\(walletHex)" + // A fresh runtime start supersedes any backoff still pending from the + // previous one — it re-runs the same first attempt below. Done BEFORE + // the guards: an attempt already in flight keeps `activeContexts` set, + // and returning early there would leave the superseded backoff + // scheduled behind it. + retryTasks.removeValue(forKey: contextKey)?.cancel() + guard !completedContexts.contains(contextKey), !activeContexts.contains(contextKey) else { return } - // A fresh runtime start supersedes any backoff still pending from the - // previous one — it re-runs the same first attempt below. - retryTasks.removeValue(forKey: contextKey)?.cancel() - let found = await attempt( wallet: wallet, modelContainer: modelContainer, @@ -779,6 +782,28 @@ final class DWSameSeedIdentityRecoveryCoordinator { } } + /// Cancel every pending backoff and wait for any in-flight pass to return. + /// + /// Runtime teardown must call this before releasing 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, so without this a retry could be suspended + /// inside `discoverIdentities` while `SwiftDashSDKHost.stop()` drops the + /// handle underneath it. Cancelling alone would not be enough — a task + /// suspended in a synchronous FFI call does not observe cancellation — so + /// this awaits each task rather than only signalling it. + func cancelPendingWork() async { + guard !retryTasks.isEmpty else { return } + let pending = retryTasks + retryTasks.removeAll() + Self.logger.info( + "🪪 IDENT-RECOVERY :: cancelling \(pending.count, privacy: .public) pending retry task(s) for teardown") + for task in pending.values { + task.cancel() + await task.value + } + } + /// Walk the backoff until a pass finds an identity or the schedule runs /// out. Runs outside the runtime-start pipeline, so each pass re-resolves /// the live wallet instead of holding the handle it was started with: a @@ -812,8 +837,17 @@ final class DWSameSeedIdentityRecoveryCoordinator { contextKey: contextKey) { break } + + // A teardown that started while the pass above was inside the + // FFI is waiting on this task; don't spend another round. + if Task.isCancelled { return } } - self?.retryTasks.removeValue(forKey: contextKey) + // Deliberately does NOT remove itself from `retryTasks`. Ownership + // of that dictionary belongs to `recoverIfNeeded` and + // `cancelPendingWork`, and a finished task self-removing could + // delete the entry a newer start had already put in its place — + // dropping that one out of teardown's reach. Awaiting a finished + // task returns immediately, so leaving it there costs nothing. } } diff --git a/DashWallet/Sources/Infrastructure/SwiftDashSDK/PlatformAddressSyncCoordinator.swift b/DashWallet/Sources/Infrastructure/SwiftDashSDK/PlatformAddressSyncCoordinator.swift index d23a1b4e9..6b607f6b1 100644 --- a/DashWallet/Sources/Infrastructure/SwiftDashSDK/PlatformAddressSyncCoordinator.swift +++ b/DashWallet/Sources/Infrastructure/SwiftDashSDK/PlatformAddressSyncCoordinator.swift @@ -119,6 +119,19 @@ public final class PlatformAddressSyncCoordinator: NSObject, ObservableObject { @Published public private(set) var isClearing: Bool = false @Published public private(set) var lastSyncTime: Date? = nil @Published public private(set) var lastError: String? = nil + + /// Startup failure of `platformAddressWallet()`, held apart from + /// `lastError` because it outlives the events that clear it. + /// + /// The address wallet and the manager's Platform address sync fail + /// independently: the sync can complete successfully while this wallet is + /// missing, and every success clears `lastError`. Publishing the startup + /// failure only once would let the first successful pass erase it, leaving + /// the status screen reporting a healthy sync over address surfaces that + /// cannot work. Kept here and re-published wherever `lastError` is cleared + /// on success; cleared only by teardown or a start that resolves the + /// wallet. + private var addressWalletStartupError: String? = nil @Published private(set) var platformAccountAvailability: PlatformAccountAvailability = .unknown @Published public private(set) var platformBalance: UInt64 = 0 @@ -301,7 +314,7 @@ public final class PlatformAddressSyncCoordinator: NSObject, ObservableObject { try manager.startPlatformAddressSync() } isSyncing = true - lastError = nil + lastError = addressWalletStartupError try await manager.syncPlatformAddressNow() } catch { isSyncing = false @@ -700,6 +713,7 @@ public final class PlatformAddressSyncCoordinator: NSObject, ObservableObject { lastSyncBlockTime = nil lastSyncTime = nil lastError = nil + addressWalletStartupError = nil syncCountSinceLaunch = 0 totalTrunkQueries = 0 totalBranchQueries = 0 @@ -834,6 +848,7 @@ public final class PlatformAddressSyncCoordinator: NSObject, ObservableObject { self.platformAccountAvailability = accountAvailability self.runningNetwork = network self.isRunning = true + self.addressWalletStartupError = addressWalletError self.lastError = addressWalletError subscribeToManager(manager: manager, walletId: resolvedWallet.walletId) @@ -1203,7 +1218,9 @@ public final class PlatformAddressSyncCoordinator: NSObject, ObservableObject { guard let result = event.result(for: walletId) else { return } if result.success { - lastError = nil + // A healthy address-sync pass says nothing about the address + // wallet, which failed to resolve at start and stays broken. + lastError = addressWalletStartupError if result.checkpointHeight > 0 { checkpointHeight = result.checkpointHeight } diff --git a/DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKWalletRuntime.swift b/DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKWalletRuntime.swift index 32025ec3f..98726b5de 100644 --- a/DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKWalletRuntime.swift +++ b/DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKWalletRuntime.swift @@ -349,6 +349,13 @@ final class SwiftDashSDKWalletRuntime: NSObject { } await SwiftDashSDKSPVCoordinator.shared.stopAsync(lastError: lastError) SwiftDashSDKWalletState.shared.clearAllState() +#if DASHPAY + // Identity-recovery retries run on their own backoff, outside this + // pipeline's serialization, so they are the one wallet-backed workload + // that can still be mid-FFI here. Settle them before the host drops the + // wallet handle. + await DWSameSeedIdentityRecoveryCoordinator.shared.cancelPendingWork() +#endif SwiftDashSDKHost.shared.stop() currentNetwork = nil if forWipe { From 8a14971607bf826faac528d41c7b9a414d2b8343 Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Mon, 10 Aug 2026 17:12:36 +0300 Subject: [PATCH 3/5] docs(dashpay): describe the retained address-wallet error's real lifecycle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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`). --- .../SwiftDashSDK/PlatformAddressSyncCoordinator.swift | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/DashWallet/Sources/Infrastructure/SwiftDashSDK/PlatformAddressSyncCoordinator.swift b/DashWallet/Sources/Infrastructure/SwiftDashSDK/PlatformAddressSyncCoordinator.swift index 6b607f6b1..2584f48fa 100644 --- a/DashWallet/Sources/Infrastructure/SwiftDashSDK/PlatformAddressSyncCoordinator.swift +++ b/DashWallet/Sources/Infrastructure/SwiftDashSDK/PlatformAddressSyncCoordinator.swift @@ -129,9 +129,10 @@ public final class PlatformAddressSyncCoordinator: NSObject, ObservableObject { /// failure only once would let the first successful pass erase it, leaving /// the status screen reporting a healthy sync over address surfaces that /// cannot work. Kept here and re-published wherever `lastError` is cleared - /// on success; cleared only by teardown or a start that resolves the - /// wallet. - private var addressWalletStartupError: String? = nil + /// on success. Cleared by `clearDisplay()`, which runs on teardown, wipe + /// and network-switch preparation — every path that invalidates the wallet + /// this error was recorded against — and overwritten by the next start. + private var addressWalletStartupError: String? @Published private(set) var platformAccountAvailability: PlatformAccountAvailability = .unknown @Published public private(set) var platformBalance: UInt64 = 0 From fcd6d8f6695359c34d8b494290be046c2cdbe4c4 Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Mon, 10 Aug 2026 17:58:03 +0300 Subject: [PATCH 4/5] fix(dashpay): treat a proven-empty identity scan as an answer, not a retry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../Identity/DWCurrentUserIdentityInfo.swift | 51 ++++++++++++------- 1 file changed, 32 insertions(+), 19 deletions(-) diff --git a/DashWallet/Sources/Infrastructure/SwiftDashSDK/Identity/DWCurrentUserIdentityInfo.swift b/DashWallet/Sources/Infrastructure/SwiftDashSDK/Identity/DWCurrentUserIdentityInfo.swift index 74ea069ae..a9db8b6fd 100644 --- a/DashWallet/Sources/Infrastructure/SwiftDashSDK/Identity/DWCurrentUserIdentityInfo.swift +++ b/DashWallet/Sources/Infrastructure/SwiftDashSDK/Identity/DWCurrentUserIdentityInfo.swift @@ -614,9 +614,10 @@ enum SameSeedIdentityRecoveryPipeline { } /// Best-effort startup recovery for an identity created by the same seed on a -/// different device/install. Finding an identity is the only outcome that ends -/// the search: an empty or failed scan is retried on a backoff inside this -/// session, and again on the next runtime start. +/// different device/install. A definitive answer from Platform ends the search +/// — whether that answer is an identity or a proof that this seed owns none. +/// Only a scan that could not reach Platform is retried, on a backoff inside +/// this session and again on the next runtime start. @MainActor final class DWSameSeedIdentityRecoveryCoordinator { static let shared = DWSameSeedIdentityRecoveryCoordinator() @@ -633,8 +634,9 @@ final class DWSameSeedIdentityRecoveryCoordinator { /// on each launch, so a scan that starts before that endpoint answers /// fails whole rather than per-node, and no amount of DAPI-level retrying /// helps. Retrying the scan itself does, which is why the first retry is - /// soon; the later ones spread out so a genuinely identity-less wallet - /// costs three cheap lookups, not a poll. + /// soon; the later ones spread out. A wallet that genuinely owns no + /// identity never reaches this schedule at all — Platform's proof of + /// absence is an answer, and the search stops on the first pass. private static let retryDelays: [Duration] = [.seconds(20), .seconds(60), .seconds(180)] private var completedContexts: Set = [] @@ -665,13 +667,15 @@ final class DWSameSeedIdentityRecoveryCoordinator { return } - let found = await attempt( + let answered = await attempt( wallet: wallet, modelContainer: modelContainer, network: network, contextKey: contextKey) - if !found { + // Only an unreachable Platform earns a backoff; a definitive "no + // identity" is an answer, not a failure. + if !answered { scheduleRetries( walletId: walletId, modelContainer: modelContainer, @@ -680,16 +684,17 @@ final class DWSameSeedIdentityRecoveryCoordinator { } } - /// One recovery pass. Returns `true` only when the wallet now has an - /// identity — the sole outcome that stops the search. + /// One recovery pass. Returns whether Platform gave a definitive answer — + /// an identity, or a proof that this seed owns none. Only `false`, meaning + /// the scan never reached Platform, is worth repeating. /// - /// A scan that completes without finding one is NOT success. It used to be - /// recorded as final for the process, so a restore whose scan came back - /// empty for network reasons left the wallet identity-less (no DashPay - /// tabs, no contacts, no contact payment history) until the next launch. - /// Platform now reports an unanswered scan as an error rather than an - /// empty result, but a genuinely empty result stays retryable here too: - /// the identity may simply not be registered yet at scan time. + /// The distinction is the whole fix. Both outcomes used to arrive as an + /// empty success and were recorded as final for the process, so a restore + /// whose scan failed for network reasons left the wallet identity-less — + /// no DashPay tabs, no contacts, no contact payment history — until the + /// next launch. Since platform#4352 an unanswered scan raises + /// `IdentityDiscoveryIncomplete` instead, which is what lets this treat a + /// returned empty result as trustworthy and stop. private func attempt( wallet: ManagedPlatformWallet, modelContainer: ModelContainer, @@ -754,16 +759,24 @@ final class DWSameSeedIdentityRecoveryCoordinator { DWCurrentUserIdentityInfo.shared.reconcileRecoveredIdentity() }) + // A scan that returns without throwing has an answer from + // Platform, and an empty answer is a proof of absence — this seed + // owns no identity at the scanned indices. Rescanning cannot + // change that, so it counts as done. Only a scan that could not + // reach Platform is worth repeating, and since platform#4352 that + // arrives as `IdentityDiscoveryIncomplete` rather than an empty + // success, which is what makes this distinction safe to draw. + completedContexts.insert(contextKey) + guard outcome.identityCount > 0 else { Self.logger.info( """ - 🪪 IDENT-RECOVERY :: scan found no identity; will retry \ + 🪪 IDENT-RECOVERY :: Platform reports no identity for this seed \ discovered=\(outcome.discoveredCount, privacy: .public) """) - return false + return true } - completedContexts.insert(contextKey) Self.logger.info( """ 🪪 IDENT-RECOVERY :: complete \ From 875268a869f25aedf3ddce35ff88df82e7f72199 Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Mon, 10 Aug 2026 18:15:09 +0300 Subject: [PATCH 5/5] revert(dashpay): drop the Swift-side identity-recovery retry policy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../Identity/DWCurrentUserIdentityInfo.swift | 162 +----------------- .../SwiftDashSDKWalletRuntime.swift | 7 - 2 files changed, 5 insertions(+), 164 deletions(-) diff --git a/DashWallet/Sources/Infrastructure/SwiftDashSDK/Identity/DWCurrentUserIdentityInfo.swift b/DashWallet/Sources/Infrastructure/SwiftDashSDK/Identity/DWCurrentUserIdentityInfo.swift index a9db8b6fd..61167aa94 100644 --- a/DashWallet/Sources/Infrastructure/SwiftDashSDK/Identity/DWCurrentUserIdentityInfo.swift +++ b/DashWallet/Sources/Infrastructure/SwiftDashSDK/Identity/DWCurrentUserIdentityInfo.swift @@ -614,10 +614,9 @@ enum SameSeedIdentityRecoveryPipeline { } /// Best-effort startup recovery for an identity created by the same seed on a -/// different device/install. A definitive answer from Platform ends the search -/// — whether that answer is an identity or a proof that this seed owns none. -/// Only a scan that could not reach Platform is retried, on a backoff inside -/// this session and again on the next runtime start. +/// different device/install. One successful attempt is enough per +/// network-scoped wallet and process; failures remain retryable on the next +/// runtime start. @MainActor final class DWSameSeedIdentityRecoveryCoordinator { static let shared = DWSameSeedIdentityRecoveryCoordinator() @@ -626,22 +625,8 @@ final class DWSameSeedIdentityRecoveryCoordinator { subsystem: "org.dashfoundation.dash", category: "swift-sdk-migration.identity-recovery") - /// In-session retry schedule. - /// - /// The scan runs during runtime start, right after a restore — the worst - /// moment to need the network. Every probe verifies its proof against - /// quorum keys the SDK pulls from one HTTPS endpoint whose cache is cold - /// on each launch, so a scan that starts before that endpoint answers - /// fails whole rather than per-node, and no amount of DAPI-level retrying - /// helps. Retrying the scan itself does, which is why the first retry is - /// soon; the later ones spread out. A wallet that genuinely owns no - /// identity never reaches this schedule at all — Platform's proof of - /// absence is an answer, and the search stops on the first pass. - private static let retryDelays: [Duration] = [.seconds(20), .seconds(60), .seconds(180)] - private var completedContexts: Set = [] private var activeContexts: Set = [] - private var retryTasks: [String: Task] = [:] private init() {} @@ -654,68 +639,19 @@ final class DWSameSeedIdentityRecoveryCoordinator { let walletHex = walletId.map { String(format: "%02x", $0) }.joined() let contextKey = "\(network.rawValue):\(walletHex)" - // A fresh runtime start supersedes any backoff still pending from the - // previous one — it re-runs the same first attempt below. Done BEFORE - // the guards: an attempt already in flight keeps `activeContexts` set, - // and returning early there would leave the superseded backoff - // scheduled behind it. - retryTasks.removeValue(forKey: contextKey)?.cancel() - guard !completedContexts.contains(contextKey), !activeContexts.contains(contextKey) else { return } - let answered = await attempt( - wallet: wallet, - modelContainer: modelContainer, - network: network, - contextKey: contextKey) - - // Only an unreachable Platform earns a backoff; a definitive "no - // identity" is an answer, not a failure. - if !answered { - scheduleRetries( - walletId: walletId, - modelContainer: modelContainer, - network: network, - contextKey: contextKey) - } - } - - /// One recovery pass. Returns whether Platform gave a definitive answer — - /// an identity, or a proof that this seed owns none. Only `false`, meaning - /// the scan never reached Platform, is worth repeating. - /// - /// The distinction is the whole fix. Both outcomes used to arrive as an - /// empty success and were recorded as final for the process, so a restore - /// whose scan failed for network reasons left the wallet identity-less — - /// no DashPay tabs, no contacts, no contact payment history — until the - /// next launch. Since platform#4352 an unanswered scan raises - /// `IdentityDiscoveryIncomplete` instead, which is what lets this treat a - /// returned empty result as trustworthy and stop. - private func attempt( - wallet: ManagedPlatformWallet, - modelContainer: ModelContainer, - network: Network, - contextKey: String - ) async -> Bool { - guard !completedContexts.contains(contextKey), - !activeContexts.contains(contextKey) - else { - return completedContexts.contains(contextKey) - } - activeContexts.insert(contextKey) defer { activeContexts.remove(contextKey) } do { let outcome = try await SameSeedIdentityRecoveryPipeline.run( localIdentityIds: { - Self.localIdentityIds( - walletId: wallet.walletId, - modelContainer: modelContainer) + Self.localIdentityIds(walletId: walletId, modelContainer: modelContainer) }, discover: { Self.logger.info( @@ -759,24 +695,7 @@ final class DWSameSeedIdentityRecoveryCoordinator { DWCurrentUserIdentityInfo.shared.reconcileRecoveredIdentity() }) - // A scan that returns without throwing has an answer from - // Platform, and an empty answer is a proof of absence — this seed - // owns no identity at the scanned indices. Rescanning cannot - // change that, so it counts as done. Only a scan that could not - // reach Platform is worth repeating, and since platform#4352 that - // arrives as `IdentityDiscoveryIncomplete` rather than an empty - // success, which is what makes this distinction safe to draw. completedContexts.insert(contextKey) - - guard outcome.identityCount > 0 else { - Self.logger.info( - """ - 🪪 IDENT-RECOVERY :: Platform reports no identity for this seed \ - discovered=\(outcome.discoveredCount, privacy: .public) - """) - return true - } - Self.logger.info( """ 🪪 IDENT-RECOVERY :: complete \ @@ -784,83 +703,12 @@ final class DWSameSeedIdentityRecoveryCoordinator { identities=\(outcome.identityCount, privacy: .public) \ adopted=\(outcome.adopted, privacy: .public) """) - return true } catch { Self.logger.warning( """ - 🪪 IDENT-RECOVERY :: failed; will retry: \ + 🪪 IDENT-RECOVERY :: failed; will retry after next runtime start: \ \(String(describing: error), privacy: .public) """) - return false - } - } - - /// Cancel every pending backoff and wait for any in-flight pass to return. - /// - /// Runtime teardown must call this before releasing 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, so without this a retry could be suspended - /// inside `discoverIdentities` while `SwiftDashSDKHost.stop()` drops the - /// handle underneath it. Cancelling alone would not be enough — a task - /// suspended in a synchronous FFI call does not observe cancellation — so - /// this awaits each task rather than only signalling it. - func cancelPendingWork() async { - guard !retryTasks.isEmpty else { return } - let pending = retryTasks - retryTasks.removeAll() - Self.logger.info( - "🪪 IDENT-RECOVERY :: cancelling \(pending.count, privacy: .public) pending retry task(s) for teardown") - for task in pending.values { - task.cancel() - await task.value - } - } - - /// Walk the backoff until a pass finds an identity or the schedule runs - /// out. Runs outside the runtime-start pipeline, so each pass re-resolves - /// the live wallet instead of holding the handle it was started with: a - /// wipe, a wallet switch, or a network switch between retries must end the - /// search rather than scan against a torn-down runtime. - private func scheduleRetries( - walletId: Data, - modelContainer: ModelContainer, - network: Network, - contextKey: String - ) { - retryTasks[contextKey] = Task { [weak self] in - for delay in Self.retryDelays { - try? await Task.sleep(for: delay) - guard !Task.isCancelled, let self else { return } - guard !self.completedContexts.contains(contextKey) else { return } - - guard let wallet = SwiftDashSDKHost.shared.wallet, - wallet.walletId == walletId, - PlatformAddressSyncCoordinator.shared.runningNetwork == network - else { - Self.logger.info( - "🪪 IDENT-RECOVERY :: retry abandoned — runtime moved on") - return - } - - if await self.attempt( - wallet: wallet, - modelContainer: modelContainer, - network: network, - contextKey: contextKey) { - break - } - - // A teardown that started while the pass above was inside the - // FFI is waiting on this task; don't spend another round. - if Task.isCancelled { return } - } - // Deliberately does NOT remove itself from `retryTasks`. Ownership - // of that dictionary belongs to `recoverIfNeeded` and - // `cancelPendingWork`, and a finished task self-removing could - // delete the entry a newer start had already put in its place — - // dropping that one out of teardown's reach. Awaiting a finished - // task returns immediately, so leaving it there costs nothing. } } diff --git a/DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKWalletRuntime.swift b/DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKWalletRuntime.swift index 98726b5de..32025ec3f 100644 --- a/DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKWalletRuntime.swift +++ b/DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKWalletRuntime.swift @@ -349,13 +349,6 @@ final class SwiftDashSDKWalletRuntime: NSObject { } await SwiftDashSDKSPVCoordinator.shared.stopAsync(lastError: lastError) SwiftDashSDKWalletState.shared.clearAllState() -#if DASHPAY - // Identity-recovery retries run on their own backoff, outside this - // pipeline's serialization, so they are the one wallet-backed workload - // that can still be mid-FFI here. Settle them before the host drops the - // wallet handle. - await DWSameSeedIdentityRecoveryCoordinator.shared.cancelPendingWork() -#endif SwiftDashSDKHost.shared.stop() currentNetwork = nil if forWipe {