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/PlatformAddressSyncCoordinator.swift b/DashWallet/Sources/Infrastructure/SwiftDashSDK/PlatformAddressSyncCoordinator.swift index 42c6b3a0d..2584f48fa 100644 --- a/DashWallet/Sources/Infrastructure/SwiftDashSDK/PlatformAddressSyncCoordinator.swift +++ b/DashWallet/Sources/Infrastructure/SwiftDashSDK/PlatformAddressSyncCoordinator.swift @@ -119,6 +119,20 @@ 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 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 @@ -301,7 +315,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 +714,7 @@ public final class PlatformAddressSyncCoordinator: NSObject, ObservableObject { lastSyncBlockTime = nil lastSyncTime = nil lastError = nil + addressWalletStartupError = nil syncCountSinceLaunch = 0 totalTrunkQueries = 0 totalBranchQueries = 0 @@ -744,20 +759,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 +849,8 @@ public final class PlatformAddressSyncCoordinator: NSObject, ObservableObject { self.platformAccountAvailability = accountAvailability self.runningNetwork = network self.isRunning = true - self.lastError = nil + self.addressWalletStartupError = addressWalletError + self.lastError = addressWalletError subscribeToManager(manager: manager, walletId: resolvedWallet.walletId) refreshDerivedAddresses() @@ -1192,7 +1219,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/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