diff --git a/DashWallet/Sources/Infrastructure/SwiftDashSDK/Contacts/SwiftDashSDKContactsService.swift b/DashWallet/Sources/Infrastructure/SwiftDashSDK/Contacts/SwiftDashSDKContactsService.swift index 1b59d1a88..73ef52a38 100644 --- a/DashWallet/Sources/Infrastructure/SwiftDashSDK/Contacts/SwiftDashSDKContactsService.swift +++ b/DashWallet/Sources/Infrastructure/SwiftDashSDK/Contacts/SwiftDashSDKContactsService.swift @@ -792,6 +792,39 @@ final class SwiftDashSDKContactsService: ObservableObject { return missing } + /// Identity ids whose DIP-15 pair Platform has confirmed enabled this + /// session. Platform never silently removes keys (they can only be + /// explicitly disabled), so one confirmation spares a network query on + /// every subsequent tab load. + private var platformConfirmedDashPayIdentities: Set = [] + + /// Authoritative Platform-side count of the missing DIP-15 keys for + /// `ownerId`. The local SwiftData key rows lag when the pair was added + /// from another device — `missingDashPayKeyCount()` alone would keep + /// showing the "Enable DashPay" intro for an identity that is already + /// enabled. Takes the identity explicitly so a caller can bind the + /// result to the identity it captured before awaiting (a wallet switch + /// mid-flight must not apply one identity's answer to another). + /// Returns nil when the query can't run (no SDK / network error); + /// callers keep the local answer then. + func missingDashPayKeyCountOnPlatform(identityId ownerId: Data) async -> Int? { + guard let sdk = SwiftDashSDKHost.shared.sdk else { + return nil + } + if platformConfirmedDashPayIdentities.contains(ownerId) { return 0 } + do { + let keysById = try await sdk.identityGetKeys(identityId: ownerId.toBase58String()) + let missing = DWIdentityKeyUpgrader.missingDashPayPurposes(inPlatformKeysById: keysById).count + if missing == 0 { + platformConfirmedDashPayIdentities.insert(ownerId) + } + return missing + } catch { + Self.logger.error("👥 CONTACTS :: platform DashPay-key re-check failed: \(String(describing: error), privacy: .public)") + return nil + } + } + /// Estimated network fee for the enable-DashPay IdentityUpdate, in /// duffs (1 duff = 1000 credits): the platform fee schedule's /// `identity_update` minimum (100,000 credits) plus diff --git a/DashWallet/Sources/Infrastructure/SwiftDashSDK/Identity/DWIdentityKeyUpgrader.swift b/DashWallet/Sources/Infrastructure/SwiftDashSDK/Identity/DWIdentityKeyUpgrader.swift index 12055a3a9..7f0455ed9 100644 --- a/DashWallet/Sources/Infrastructure/SwiftDashSDK/Identity/DWIdentityKeyUpgrader.swift +++ b/DashWallet/Sources/Infrastructure/SwiftDashSDK/Identity/DWIdentityKeyUpgrader.swift @@ -49,18 +49,7 @@ enum DWIdentityKeyUpgrader { // rows can lag — e.g. keys added from another device). let keysById = try await sdk.identityGetKeys(identityId: ownerId.toBase58String()) let keys = keysById.values.compactMap { $0 as? [String: Any] } - - func hasEnabledECDSAKey(purpose: Int) -> Bool { - keys.contains { key in - (key["purpose"] as? Int) == purpose - && (key["type"] as? Int) == 0 // ECDSA_SECP256K1 - && (key["disabledAt"] == nil || key["disabledAt"] is NSNull) - } - } - - var missingPurposes: [KeyPurpose] = [] - if !hasEnabledECDSAKey(purpose: 1) { missingPurposes.append(.encryption) } - if !hasEnabledECDSAKey(purpose: 2) { missingPurposes.append(.decryption) } + let missingPurposes = Self.missingDashPayPurposes(inPlatformKeysById: keysById) guard !missingPurposes.isEmpty else { return false } Self.logger.info("🪪 KEY-UPGRADE :: identity missing \(missingPurposes.count, privacy: .public) DashPay key(s) — deriving + broadcasting IdentityUpdate") @@ -100,6 +89,27 @@ enum DWIdentityKeyUpgrader { return true } + /// The DIP-15 purposes (ENCRYPTION / DECRYPTION) that lack an enabled + /// ECDSA_SECP256K1 key in a Platform `identityGetKeys` response. Shared + /// by the upgrade broadcast above and the Contacts tab's authoritative + /// "is Enable DashPay really needed?" re-check. + nonisolated static func missingDashPayPurposes( + inPlatformKeysById keysById: [String: Any] + ) -> [KeyPurpose] { + let keys = keysById.values.compactMap { $0 as? [String: Any] } + func hasEnabledECDSAKey(purpose: Int) -> Bool { + keys.contains { key in + (key["purpose"] as? Int) == purpose + && (key["type"] as? Int) == 0 // ECDSA_SECP256K1 + && (key["disabledAt"] == nil || key["disabledAt"] is NSNull) + } + } + var missing: [KeyPurpose] = [] + if !hasEnabledECDSAKey(purpose: 1) { missing.append(.encryption) } + if !hasEnabledECDSAKey(purpose: 2) { missing.append(.decryption) } + return missing + } + private static func fetchIdentityRow( ownerId: Data, modelContainer: ModelContainer @@ -111,3 +121,43 @@ enum DWIdentityKeyUpgrader { return (try? context.fetch(descriptor))?.first } } + +/// Re-fetches an identity from Platform through the Rust load pipeline +/// (`loadIdentity(atIndex:)`), folding the authoritative state — public +/// keys included — back into the wallet and, via the persistence event +/// channel, the local SwiftData rows. Backs the Storage Explorer's +/// pull-to-refresh so keys added from another device become visible. +/// +/// dashpay target only (same as the upgrader above). +@MainActor +enum DWIdentityReloader { + + private static let logger = Logger( + subsystem: "org.dashfoundation.dash", + category: "swift-sdk-migration.identity-reloader") + + /// Reload the identity persisted at `identityIndex`. Errors are logged, + /// not thrown — pull-to-refresh has no failure UI, and the stale local + /// rows remain an honest fallback. + static func reload(identityIndex: UInt32) async { + guard let wallet = SwiftDashSDKHost.shared.wallet else { return } + do { + let id = try await wallet.loadIdentity(atIndex: identityIndex) + Self.logger.info("🪪 RELOAD :: identity at index \(identityIndex, privacy: .public) reloaded (found=\(id != nil, privacy: .public))") + } catch { + Self.logger.error("🪪 RELOAD :: identity reload failed at index \(identityIndex, privacy: .public): \(String(describing: error), privacy: .public)") + } + } + + /// Reload the current user's main identity, resolving its identity + /// index from the persisted row. No-op when no identity is registered. + static func reloadCurrentUserIdentity() async { + guard let modelContainer = SwiftDashSDKHost.shared.modelContainer, + let ownerId = DWCurrentUserIdentityInfo.shared.identityId else { return } + var descriptor = FetchDescriptor( + predicate: #Predicate { $0.identityId == ownerId }) + descriptor.fetchLimit = 1 + guard let row = (try? modelContainer.mainContext.fetch(descriptor))?.first else { return } + await reload(identityIndex: row.identityIndex) + } +} diff --git a/DashWallet/Sources/UI/DashPay/Contacts/SwiftUI/ContactsScreen.swift b/DashWallet/Sources/UI/DashPay/Contacts/SwiftUI/ContactsScreen.swift index 831cea4c2..0412a2dbd 100644 --- a/DashWallet/Sources/UI/DashPay/Contacts/SwiftUI/ContactsScreen.swift +++ b/DashWallet/Sources/UI/DashPay/Contacts/SwiftUI/ContactsScreen.swift @@ -111,6 +111,7 @@ final class ContactsViewModel: ObservableObject { service.refresh() missingDashPayKeyCount = service.missingDashPayKeyCount() needsDashPayEnable = missingDashPayKeyCount > 0 + reconcileDashPayEnableWithPlatform() let info = DWCurrentUserIdentityInfo.shared ownDisplayName = info.displayName ownUsername = info.username?.withoutDashSuffix @@ -118,6 +119,35 @@ final class ContactsViewModel: ObservableObject { ownIdentitySeed = info.identityId ?? Data() } + /// Guards `reconcileDashPayEnableWithPlatform` against overlapping + /// checks when `refresh()` fires in bursts. + private var platformKeyCheckInFlight = false + + /// The local key rows lag an "Enable DashPay" done on another device. + /// When they claim the DIP-15 pair is missing, confirm against + /// Platform's authoritative key set and correct the intro — most + /// importantly clearing it for an identity that is already enabled. + /// + /// The result is bound to the identity captured BEFORE the await: a + /// wallet switch mid-flight discards the stale answer and re-checks + /// for the newly active identity instead. + private func reconcileDashPayEnableWithPlatform() { + guard needsDashPayEnable, !platformKeyCheckInFlight, + let ownerId = DWCurrentUserIdentityInfo.shared.identityId else { return } + platformKeyCheckInFlight = true + Task { + let missing = await service.missingDashPayKeyCountOnPlatform(identityId: ownerId) + platformKeyCheckInFlight = false + guard DWCurrentUserIdentityInfo.shared.identityId == ownerId else { + reconcileDashPayEnableWithPlatform() + return + } + guard let missing else { return } + missingDashPayKeyCount = missing + needsDashPayEnable = missing > 0 + } + } + /// Estimated IdentityUpdate fee for the confirm sheet, sized to the /// keys actually missing: "~0.000131 DASH (≈ THB 0.13)" in the user's /// local currency. diff --git a/DashWallet/Sources/UI/Menu/Security/Wallets/IdentitiesScreen.swift b/DashWallet/Sources/UI/Menu/Security/Wallets/IdentitiesScreen.swift index 698a1ab54..1c76318ce 100644 --- a/DashWallet/Sources/UI/Menu/Security/Wallets/IdentitiesScreen.swift +++ b/DashWallet/Sources/UI/Menu/Security/Wallets/IdentitiesScreen.swift @@ -553,7 +553,7 @@ struct IdentityDetailScreen: View { .font(.system(size: 14)) .foregroundColor(.dash.secondaryText) Spacer() - Text("\(row.publicKeys.count)") + Text("\(currentRow.publicKeys.count)") .font(.system(size: 14, weight: .semibold)) .foregroundColor(.dash.primaryText) Image(systemName: "chevron.right") @@ -565,7 +565,7 @@ struct IdentityDetailScreen: View { .contentShape(Rectangle()) } .buttonStyle(.plain) - .disabled(row.publicKeys.isEmpty) + .disabled(currentRow.publicKeys.isEmpty) } .background(Color.dash.secondaryBackground) .cornerRadius(12) @@ -575,7 +575,7 @@ struct IdentityDetailScreen: View { /// list → detail navigation). private func showPublicKeys() { let controller = UIHostingController( - rootView: IdentityPublicKeysScreen(row: row, vc: vc)) + rootView: IdentityPublicKeysScreen(row: row, vc: vc, viewModel: viewModel)) controller.hidesBottomBarWhenPushed = true vc.pushViewController(controller, animated: true) } @@ -704,9 +704,34 @@ struct IdentityDetailScreen: View { struct IdentityPublicKeysScreen: View { let row: IdentityRowModel let vc: UINavigationController + @ObservedObject var viewModel: IdentitiesViewModel /// Key id whose Copy button just fired (transient checkmark). @State private var copiedKeyId: Int32? + /// Drives the refresh button's spinner/disabled state. + @State private var isReloadingKeys = false + + /// Live projection of this identity from the shared view model — + /// `row` is the push-time capture; a keys refresh reloads the view + /// model and re-renders through this (same pattern as the detail). + private var currentRow: IdentityRowModel { + viewModel.rows.first(where: { $0.identityId == row.identityId }) ?? row + } + + /// Refreshing probes the active wallet's DIP-9 tree, so only its own + /// identities can be reloaded; the affordance hides otherwise. Gated + /// on the wallet linkage, NOT `isLocal` — persisted rows have been + /// observed carrying `isLocal == false` for the wallet's own identity. + private var canRefresh: Bool { + row.walletId != nil && row.walletId == SwiftDashSDKHost.shared.wallet?.walletId + } + + private func refreshKeys() async { + guard !isReloadingKeys else { return } + isReloadingKeys = true + defer { isReloadingKeys = false } + await viewModel.refreshIdentityKeys(for: currentRow) + } var body: some View { ZStack { @@ -717,7 +742,7 @@ struct IdentityPublicKeysScreen: View { ScrollView { VStack(spacing: 12) { - ForEach(row.publicKeys) { key in + ForEach(currentRow.publicKeys) { key in keyCard(key) } } @@ -725,6 +750,7 @@ struct IdentityPublicKeysScreen: View { .padding(.top, 4) .padding(.bottom, 24) } + .refreshable { await refreshKeys() } } } .navigationBarHidden(true) @@ -743,6 +769,25 @@ struct IdentityPublicKeysScreen: View { .overlay(Circle().stroke(Color.dash.gray300.opacity(0.3), lineWidth: 1)) } Spacer() + if canRefresh { + Button { + Task { await refreshKeys() } + } label: { + Group { + if isReloadingKeys { + SwiftUI.ProgressView() + } else { + Image(systemName: "arrow.clockwise") + .font(.system(size: 16, weight: .medium)) + .foregroundColor(Color.dash.primaryText) + } + } + .frame(width: 36, height: 36) + .overlay(Circle().stroke(Color.dash.gray300.opacity(0.3), lineWidth: 1)) + } + .disabled(isReloadingKeys) + .accessibilityLabel(Text(NSLocalizedString("Refresh keys from Platform", comment: "Identities: re-fetch this identity's public keys"))) + } } .padding(.horizontal, 5) .padding(.top, 10) diff --git a/DashWallet/Sources/UI/Menu/Security/Wallets/IdentitiesViewModel.swift b/DashWallet/Sources/UI/Menu/Security/Wallets/IdentitiesViewModel.swift index b97fb8b33..ca05ea258 100644 --- a/DashWallet/Sources/UI/Menu/Security/Wallets/IdentitiesViewModel.swift +++ b/DashWallet/Sources/UI/Menu/Security/Wallets/IdentitiesViewModel.swift @@ -193,6 +193,31 @@ final class IdentitiesViewModel: ObservableObject { /// identity's balance, and backfill a DPNS name for rows that have /// none (silent — not every identity has a name). Mirrors the example /// app's `IdentityRow.refreshBalance`, batched over the whole list. + /// Re-fetch one wallet-owned identity from Platform — public keys + /// included — via the Rust load pipeline, then rebuild the rows. The + /// persisted key rows lag keys added on another device; this is the + /// user-facing "refresh my identity's keys" action (Identities → + /// detail → Public keys). Only identities of the ACTIVE wallet can be + /// reloaded: `loadIdentity(atIndex:)` probes the active wallet's DIP-9 + /// tree. The wallet linkage is the gate — deliberately NOT `isLocal`, + /// which persisted rows have been observed to carry as false for the + /// wallet's own identity. Returns false for other wallets' rows. + @discardableResult + func refreshIdentityKeys(for row: IdentityRowModel) async -> Bool { + guard let activeWalletId = SwiftDashSDKHost.shared.wallet?.walletId, + row.walletId == activeWalletId else { + DWLogger.log("IdentitiesViewModel: key refresh skipped — identity \(row.idBase58) is not the active wallet's (walletId=\(row.walletId?.hexEncodedString() ?? "nil"))") + return false + } + #if DASHPAY + await DWIdentityReloader.reload(identityIndex: row.identityIndex) + reload() + return true + #else + return false + #endif + } + func refreshFromNetwork() async { guard !isRefreshing else { return } guard let sdk = SwiftDashSDKHost.shared.sdk, diff --git a/DashWallet/Sources/UI/Menu/Tools/StorageExplorer/StorageModelListViews.swift b/DashWallet/Sources/UI/Menu/Tools/StorageExplorer/StorageModelListViews.swift index 7700bf2b9..4f0a5748a 100644 --- a/DashWallet/Sources/UI/Menu/Tools/StorageExplorer/StorageModelListViews.swift +++ b/DashWallet/Sources/UI/Menu/Tools/StorageExplorer/StorageModelListViews.swift @@ -67,10 +67,57 @@ struct DataContractStorageListView: View { // MARK: - PersistentPublicKey +#if DASHPAY +/// Identity reloads for the Storage Explorer's pull-to-refresh — the +/// SDK call stays out of the `View` structs per the SwiftUI guardrails. +/// Shared by the Public Keys list (current-user reload) and the identity +/// detail (per-record reload). +@MainActor +final class StorageIdentityReloadModel: ObservableObject { + /// Drives the toolbar refresh button's spinner/disabled state (the + /// pull-to-refresh gesture has its own built-in indicator). + @Published private(set) var isReloading = false + + func reloadCurrentUserIdentity() async { + isReloading = true + defer { isReloading = false } + await DWIdentityReloader.reloadCurrentUserIdentity() + } + + func reload(identityIndex: UInt32) async { + isReloading = true + defer { isReloading = false } + await DWIdentityReloader.reload(identityIndex: identityIndex) + } + + /// Reload every identity of the ACTIVE wallet — the Public Keys list + /// spans identities, so refreshing only the current user's would leave + /// the others' keys stale. Scoped by the wallet linkage (NOT `isLocal`, + /// which persisted rows have been observed carrying as false for the + /// wallet's own identity): `loadIdentity(atIndex:)` probes the active + /// wallet's DIP-9 tree and cannot refresh other wallets' identities. + func reloadAllLocalIdentities() async { + isReloading = true + defer { isReloading = false } + guard let modelContainer = SwiftDashSDKHost.shared.modelContainer, + let activeWalletId = SwiftDashSDKHost.shared.wallet?.walletId else { return } + let rows = (try? modelContainer.mainContext.fetch(FetchDescriptor())) ?? [] + let indexes = Set(rows.filter { $0.wallet?.walletId == activeWalletId }.map(\.identityIndex)) + for index in indexes.sorted() { + await DWIdentityReloader.reload(identityIndex: index) + } + } +} +#endif + struct PublicKeyStorageListView: View { @Query(sort: \PersistentPublicKey.createdAt, order: .reverse) private var records: [PersistentPublicKey] + #if DASHPAY + @StateObject private var reloadModel = StorageIdentityReloadModel() + #endif + var body: some View { List(records) { record in NavigationLink(destination: PublicKeyStorageDetailView(record: record)) { @@ -83,6 +130,28 @@ struct PublicKeyStorageListView: View { } .navigationTitle("Public Keys (\(records.count))") .overlay { if records.isEmpty { ContentUnavailableView("No Records", systemImage: "key") } } + #if DASHPAY + // Pull-to-refresh re-fetches every wallet-owned identity from + // Platform so keys added on another device appear here — the + // list spans identities, not just the current user's. The toolbar + // button is the same action with a visible affordance. + .refreshable { await reloadModel.reloadAllLocalIdentities() } + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button { + Task { await reloadModel.reloadAllLocalIdentities() } + } label: { + if reloadModel.isReloading { + SwiftUI.ProgressView() + } else { + Image(systemName: "arrow.clockwise") + } + } + .disabled(reloadModel.isReloading) + .accessibilityLabel(Text("Refresh from Platform")) + } + } + #endif } } diff --git a/DashWallet/Sources/UI/Menu/Tools/StorageExplorer/StorageRecordDetailViews.swift b/DashWallet/Sources/UI/Menu/Tools/StorageExplorer/StorageRecordDetailViews.swift index 238641769..f37def145 100644 --- a/DashWallet/Sources/UI/Menu/Tools/StorageExplorer/StorageRecordDetailViews.swift +++ b/DashWallet/Sources/UI/Menu/Tools/StorageExplorer/StorageRecordDetailViews.swift @@ -58,6 +58,10 @@ private func jsonString(_ data: Data?) -> String? { struct IdentityStorageDetailView: View { let record: PersistentIdentity + #if DASHPAY + @StateObject private var reloadModel = StorageIdentityReloadModel() + #endif + var body: some View { Form { Section("Core") { @@ -91,6 +95,28 @@ struct IdentityStorageDetailView: View { } .navigationTitle("Identity") .navigationBarTitleDisplayMode(.inline) + #if DASHPAY + // Pull-to-refresh re-fetches this identity from Platform — the + // local rows (public keys especially) lag keys added on another + // device. @Query keeps the form live as the reload persists. The + // toolbar button is the same action with a visible affordance. + .refreshable { await reloadModel.reload(identityIndex: record.identityIndex) } + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button { + Task { await reloadModel.reload(identityIndex: record.identityIndex) } + } label: { + if reloadModel.isReloading { + SwiftUI.ProgressView() + } else { + Image(systemName: "arrow.clockwise") + } + } + .disabled(reloadModel.isReloading) + .accessibilityLabel(Text("Refresh from Platform")) + } + } + #endif } }