Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
Original file line number Diff line number Diff line change
Expand Up @@ -67,10 +67,31 @@ 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 {
func reloadCurrentUserIdentity() async {
await DWIdentityReloader.reloadCurrentUserIdentity()
}

func reload(identityIndex: UInt32) async {
await DWIdentityReloader.reload(identityIndex: identityIndex)
}
}
#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 +104,11 @@ struct PublicKeyStorageListView: View {
}
.navigationTitle("Public Keys (\(records.count))")
.overlay { if records.isEmpty { ContentUnavailableView("No Records", systemImage: "key") } }
#if DASHPAY
// Pull-to-refresh re-fetches the current user's identity from
// Platform so keys added on another device appear here.
.refreshable { await reloadModel.reloadCurrentUserIdentity() }
#endif
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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") {
Expand Down Expand Up @@ -91,6 +95,12 @@ 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.
.refreshable { await reloadModel.reload(identityIndex: record.identityIndex) }
#endif
}
}

Expand Down
Loading