Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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<Data> = []

/// 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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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
Expand All @@ -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<PersistentIdentity>(
predicate: #Predicate { $0.identityId == ownerId })
descriptor.fetchLimit = 1
guard let row = (try? modelContainer.mainContext.fetch(descriptor))?.first else { return }
await reload(identityIndex: row.identityIndex)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -111,13 +111,43 @@ final class ContactsViewModel: ObservableObject {
service.refresh()
missingDashPayKeyCount = service.missingDashPayKeyCount()
needsDashPayEnable = missingDashPayKeyCount > 0
reconcileDashPayEnableWithPlatform()
let info = DWCurrentUserIdentityInfo.shared
ownDisplayName = info.displayName
ownUsername = info.username?.withoutDashSuffix
ownAvatarURL = info.avatarURL
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.
Expand Down
53 changes: 49 additions & 4 deletions DashWallet/Sources/UI/Menu/Security/Wallets/IdentitiesScreen.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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)
Expand All @@ -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)
}
Expand Down Expand Up @@ -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 {
Expand All @@ -717,14 +742,15 @@ struct IdentityPublicKeysScreen: View {

ScrollView {
VStack(spacing: 12) {
ForEach(row.publicKeys) { key in
ForEach(currentRow.publicKeys) { key in
keyCard(key)
}
}
.padding(.horizontal, 20)
.padding(.top, 4)
.padding(.bottom, 24)
}
.refreshable { await refreshKeys() }
}
}
.navigationBarHidden(true)
Expand All @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<PersistentIdentity>())) ?? []
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)) {
Expand All @@ -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
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
}

Expand Down
Loading
Loading