From 40d0d6c233bd68ccc16cc97f5ec48a9c55425c09 Mon Sep 17 00:00:00 2001 From: benk10 Date: Wed, 24 Jun 2026 11:33:38 +0300 Subject: [PATCH 1/4] refactor: integrate paykit sdk --- Bitkit.xcodeproj/project.pbxproj | 2 +- .../xcshareddata/swiftpm/Package.resolved | 4 +- Bitkit/AppScene.swift | 13 + Bitkit/Constants/Env.swift | 10 - Bitkit/FeatureFlags/PaykitFeatureFlags.swift | 19 +- Bitkit/Managers/ContactsManager.swift | 304 ++++---- Bitkit/Managers/PubkyProfileManager.swift | 307 +++----- Bitkit/Models/BackupPayloads.swift | 16 +- Bitkit/Models/PubkyProfile.swift | 75 +- Bitkit/Services/BackupService.swift | 40 +- .../PrivatePaykitService+Backup.swift | 108 +-- .../PrivatePaykitService+Contacts.swift | 633 ++++++--------- .../PrivatePaykitService+Endpoints.swift | 395 ++-------- .../PrivatePaykitService+Errors.swift | 81 +- .../PrivatePaykitService+Invoices.swift | 29 +- .../Services/PrivatePaykitService+Links.swift | 620 --------------- .../PrivatePaykitService+Models.swift | 197 +---- .../PrivatePaykitService+Payments.swift | 396 ++-------- .../Services/PrivatePaykitService+State.swift | 341 +------- Bitkit/Services/PrivatePaykitService.swift | 101 ++- Bitkit/Services/PubkyService.swift | 727 ++++++++++++++---- Bitkit/Services/PublicPaykitService.swift | 90 +-- Bitkit/Utilities/Keychain.swift | 4 +- Bitkit/ViewModels/SettingsViewModel.swift | 1 + Bitkit/ViewModels/WalletViewModel.swift | 5 +- Bitkit/Views/Profile/EditProfileView.swift | 9 +- Bitkit/Views/Profile/PayContactsView.swift | 2 + Bitkit/Views/Profile/ProfileView.swift | 6 +- .../DevSettings/LegacyRnRecoveryScreen.swift | 2 +- Bitkit/Views/Settings/DevSettingsView.swift | 2 +- .../General/PaymentPreferenceView.swift | 4 +- BitkitTests/PrivatePaykitServiceTests.swift | 538 +++---------- BitkitTests/PubkyProfileManagerTests.swift | 136 ++-- BitkitTests/PublicPaykitServiceTests.swift | 105 +-- 34 files changed, 1692 insertions(+), 3630 deletions(-) delete mode 100644 Bitkit/Services/PrivatePaykitService+Links.swift diff --git a/Bitkit.xcodeproj/project.pbxproj b/Bitkit.xcodeproj/project.pbxproj index fff6b6c4a..8e46fb169 100644 --- a/Bitkit.xcodeproj/project.pbxproj +++ b/Bitkit.xcodeproj/project.pbxproj @@ -1169,7 +1169,7 @@ repositoryURL = "https://github.com/pubky/paykit-rs"; requirement = { kind = exactVersion; - version = "0.1.0-rc8"; + version = "0.1.0-rc21"; }; }; 18D65DFE2EB9649F00252335 /* XCRemoteSwiftPackageReference "vss-rust-client-ffi" */ = { diff --git a/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 6a8a87a87..5e0e5f473 100644 --- a/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -42,8 +42,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pubky/paykit-rs", "state" : { - "revision" : "72aafa98aa447ab0c2f7e881f9f015380335d194", - "version" : "0.1.0-rc8" + "revision" : "a4a0901e84038e58c23fd35a99c021bb95ed4b15", + "version" : "0.1.0-rc21" } }, { diff --git a/Bitkit/AppScene.swift b/Bitkit/AppScene.swift index 1507d7d06..8ebd5bf3d 100644 --- a/Bitkit/AppScene.swift +++ b/Bitkit/AppScene.swift @@ -620,6 +620,19 @@ struct AppScene: View { } private func retryPendingPaykitEndpointRemoval() async { + if PublicPaykitService.isCleanupPending { + if UserDefaults.standard.bool(forKey: PublicPaykitService.publishingEnabledKey) { + PublicPaykitService.setCleanupPending(false) + } else { + do { + try await PublicPaykitService.removePublishedEndpoints() + PublicPaykitService.setCleanupPending(false) + } catch { + Logger.warn("Failed to retry public Paykit endpoint cleanup: \(error)", context: "AppScene") + } + } + } + await PrivatePaykitService.shared.retryPendingEndpointRemoval( wallet: wallet, savedPublicKeys: contactsManager.contacts.map(\.publicKey) diff --git a/Bitkit/Constants/Env.swift b/Bitkit/Constants/Env.swift index ce029cfd6..632df1a97 100644 --- a/Bitkit/Constants/Env.swift +++ b/Bitkit/Constants/Env.swift @@ -333,16 +333,6 @@ enum Env { } } - /// Pubky/Paykit capabilities — production for mainnet, staging for regtest/testnet/signet. - static var pubkyCapabilities: String { - switch network { - case .bitcoin: - return "/pub/bitkit.to/:rw,/pub/pubky.app/:r,/pub/paykit/v0/:rw" - default: - return "/pub/staging.bitkit.to/:rw,/pub/staging.pubky.app/:r,/pub/paykit/v0/:rw" - } - } - /// Homegate URL for auto-provisioned identity signup via IP verification. static var homegateUrl: String { if isLocalE2EBackend { diff --git a/Bitkit/FeatureFlags/PaykitFeatureFlags.swift b/Bitkit/FeatureFlags/PaykitFeatureFlags.swift index 728a49982..a1b7ea893 100644 --- a/Bitkit/FeatureFlags/PaykitFeatureFlags.swift +++ b/Bitkit/FeatureFlags/PaykitFeatureFlags.swift @@ -15,13 +15,15 @@ enum PaykitFeatureFlags { isUIAvailable && UserDefaults.standard.bool(forKey: uiEnabledKey) } - static func enforceBuildAvailability() { - let defaults = UserDefaults.standard - let hasPublishedState = defaults.bool(forKey: PublicPaykitService.publishingEnabledKey) || - defaults.bool(forKey: PrivatePaykitService.publishingEnabledKey) || + static func enforceBuildAvailability(defaults: UserDefaults = .standard, isUIEnabled: Bool = Self.isUIEnabled) { + let hasPublicPublishedState = defaults.bool(forKey: PublicPaykitService.publishingEnabledKey) || defaults.bool(forKey: "hasConfirmedPublicPaykitEndpoints") || !(defaults.string(forKey: "publicPaykitBolt11") ?? "").isEmpty - + let hasPrivatePublishedState = defaults.bool(forKey: PrivatePaykitService.publishingEnabledKey) + let hasPublishedState = hasPublicPublishedState || + hasPrivatePublishedState || + defaults.bool(forKey: PublicPaykitService.cleanupPendingKey) || + defaults.bool(forKey: PrivatePaykitService.cleanupPendingKey) guard !isUIEnabled, hasPublishedState else { return } defaults.set(false, forKey: uiEnabledKey) @@ -32,6 +34,11 @@ enum PaykitFeatureFlags { defaults.removeObject(forKey: "publicPaykitBolt11PaymentHash") defaults.removeObject(forKey: "publicPaykitBolt11ExpiresAt") - PrivatePaykitService.setContactSharingCleanupPending(true) + if hasPublicPublishedState { + defaults.set(true, forKey: PublicPaykitService.cleanupPendingKey) + } + if hasPrivatePublishedState { + defaults.set(true, forKey: PrivatePaykitService.cleanupPendingKey) + } } } diff --git a/Bitkit/Managers/ContactsManager.swift b/Bitkit/Managers/ContactsManager.swift index 388b6d35d..8db850b6b 100644 --- a/Bitkit/Managers/ContactsManager.swift +++ b/Bitkit/Managers/ContactsManager.swift @@ -1,4 +1,5 @@ import Foundation +import Paykit import SwiftUI private let pubkyPrefix = "pubky" @@ -7,10 +8,6 @@ private func ensurePubkyPrefix(_ key: String) -> String { key.hasPrefix(pubkyPrefix) ? key : "\(pubkyPrefix)\(key)" } -private func stripPubkyPrefix(_ key: String) -> String { - key.hasPrefix(pubkyPrefix) ? String(key.dropFirst(pubkyPrefix.count)) : key -} - enum AddContactValidationResult: Equatable { case empty case existingContact @@ -123,8 +120,7 @@ class ContactsManager: ObservableObject { @Published var loadErrorMessage: String? @Published var shouldOpenAddContactSheet = false - /// Temporarily holds contacts discovered during import (e.g., from pubky.app after Ring auth). - /// Cleared after import is completed or discarded. + /// Pending contacts discovered during import, such as pubky.app follows after Ring auth. @Published var pendingImportProfile: PubkyProfile? @Published var pendingImportContacts: [PubkyContact] = [] @@ -153,7 +149,7 @@ class ContactsManager: ObservableObject { pendingImportContacts = [] } - // MARK: - Load Contacts (from bitkit.to homeserver) + // MARK: - Load Contacts func loadContacts(for publicKey: String) async throws { guard !isLoading else { @@ -165,42 +161,26 @@ class ContactsManager: ObservableObject { loadErrorMessage = nil defer { isLoading = false } - let basePath = contactsBasePath - Logger.info("Loading contacts from \(basePath) for \(PubkyPublicKeyFormat.redacted(publicKey))", context: "ContactsManager") + Logger.info("Loading contacts for \(PubkyPublicKeyFormat.redacted(publicKey))", context: "ContactsManager") do { - let sessionSecret = try getSessionSecret() - - let contactPaths = try await Task.detached { - try await PubkyService.sessionList(sessionSecret: sessionSecret, dirPath: basePath) + let records = try await Task.detached { + try await PubkyService.contactRecords() }.value - let savedContactKeys = contactPaths - .map(extractPublicKey(from:)) - .filter { !$0.isEmpty } - .map(ensurePubkyPrefix) - Logger.debug("Listed \(contactPaths.count) contacts from homeserver", context: "ContactsManager") - - let strippedKey = stripPubkyPrefix(publicKey) + Logger.debug("Loaded \(records.count) SDK contact records", context: "ContactsManager") let loadedResult: (contacts: [PubkyContact], failures: Int, missingFailures: Int, firstError: Error?) = await withTaskGroup(of: Result.self) { group in - for path in contactPaths { - let contactKey = extractPublicKey(from: path) - guard !contactKey.isEmpty else { continue } - - let prefixedKey = ensurePubkyPrefix(contactKey) - let uri = "pubky://\(strippedKey)\(basePath)\(prefixedKey)" - + let overrides = Self.loadContactProfileOverrides() + for record in records { group.addTask { do { - let json = try await PubkyService.fetchFileString(uri: uri) - let profileData = try PubkyProfileData.decode(from: json) - let profile = profileData.toProfile(publicKey: prefixedKey) - return .success(PubkyContact(publicKey: prefixedKey, profile: profile)) + let contact = try await Self.contact(from: record, overrides: overrides, includePlaceholder: true) + return .success(contact) } catch { Logger.warn( - "Failed to load contact data for '\(PubkyPublicKeyFormat.redacted(prefixedKey))': \(error)", + "Failed to load contact data for '\(PubkyPublicKeyFormat.redacted(record.publicKey))': \(error)", context: "ContactsManager" ) return .failure(error) @@ -229,7 +209,7 @@ class ContactsManager: ObservableObject { return (results, failures, missingFailures, firstError) } - if !contactPaths.isEmpty, loadedResult.contacts.isEmpty { + if !records.isEmpty, loadedResult.contacts.isEmpty { if loadedResult.failures == loadedResult.missingFailures { await PrivatePaykitService.shared.pruneUnsavedContactState(savedPublicKeys: []) contacts = [] @@ -241,7 +221,8 @@ class ContactsManager: ObservableObject { } contacts = loadedResult.contacts.sorted { $0.displayName.localizedCaseInsensitiveCompare($1.displayName) == .orderedAscending } - await PrivatePaykitService.shared.pruneUnsavedContactState(savedPublicKeys: savedContactKeys) + await PrivatePaykitService.shared + .pruneUnsavedContactState(savedPublicKeys: records.compactMap { PubkyPublicKeyFormat.normalized($0.publicKey) }) hasLoaded = true if loadedResult.failures > 0 { @@ -270,7 +251,7 @@ class ContactsManager: ObservableObject { } } - // MARK: - Add Contact (prefer bitkit.to profile, then pubky.app, then placeholder) + // MARK: - Add Contact func addContact(publicKey: String, existingProfile: PubkyProfile? = nil, ownPublicKey: String? = nil) async throws { guard let prefixedKey = PubkyPublicKeyFormat.normalized(publicKey) else { @@ -285,8 +266,6 @@ class ContactsManager: ObservableObject { throw ContactsManagerError.alreadyExists } - // Use existing profile if provided (e.g., already fetched during preview), - // otherwise resolve remote profile with a placeholder fallback. let profile: PubkyProfile = if let existingProfile { PubkyProfile( publicKey: prefixedKey, @@ -301,9 +280,9 @@ class ContactsManager: ObservableObject { try await resolveContactProfile(publicKey: prefixedKey, includePlaceholder: true) } - // Build PubkyProfileData and write to bitkit.to - let contactData = PubkyProfileData.from(profile: profile) - try await savePubkyProfileData(publicKey: prefixedKey, data: contactData) + try await Task.detached { + _ = try await PubkyService.saveContact(publicKey: prefixedKey, label: profile.name) + }.value Logger.info("Added contact \(PubkyPublicKeyFormat.redacted(prefixedKey))", context: "ContactsManager") @@ -312,20 +291,18 @@ class ContactsManager: ObservableObject { contacts.sort { $0.displayName.localizedCaseInsensitiveCompare($1.displayName) == .orderedAscending } } - // MARK: - Import Contacts (prefer bitkit.to profiles, then pubky.app, then placeholder) + // MARK: - Import Contacts func importContacts(publicKeys: [String]) async throws { let prefixedKeys = Array(Set(publicKeys.compactMap(PubkyPublicKeyFormat.normalized))) - // Resolve profiles remotely, then write each to bitkit.to let loadedResult: (contacts: [PubkyContact], failures: Int, firstError: Error?) = await withTaskGroup(of: Result.self) { group in for key in prefixedKeys { group.addTask { [self] in do { - let profile = await resolveImportContactProfile(publicKey: key) - let contactData = PubkyProfileData.from(profile: profile) - try await savePubkyProfileData(publicKey: key, data: contactData) + let profile = try await resolveContactProfile(publicKey: key, includePlaceholder: true) + _ = try await PubkyService.saveContact(publicKey: key, label: profile.name) return .success(PubkyContact(publicKey: key, profile: profile)) } catch { Logger.warn("Failed to save imported contact '\(PubkyPublicKeyFormat.redacted(key))': \(error)", context: "ContactsManager") @@ -355,7 +332,6 @@ class ContactsManager: ObservableObject { throw loadedResult.firstError ?? PubkyServiceError.profileNotFound } - // Merge with existing contacts, avoiding duplicates let existingKeys = Set(contacts.map(\.publicKey)) let newContacts = loadedResult.contacts.filter { !existingKeys.contains($0.publicKey) } contacts.append(contentsOf: newContacts) @@ -368,7 +344,7 @@ class ContactsManager: ObservableObject { Logger.info("Imported \(newContacts.count) new contacts", context: "ContactsManager") } - // MARK: - Update Contact (edit and save back to bitkit.to) + // MARK: - Update Contact func updateContact(publicKey: String, name: String, bio: String, imageUrl: String?, links: [PubkyProfileLink], tags: [String]) async throws { let prefixedKey = ensurePubkyPrefix(publicKey) @@ -381,9 +357,11 @@ class ContactsManager: ObservableObject { tags: tags ) - try await savePubkyProfileData(publicKey: prefixedKey, data: contactData) + try await Task.detached { + _ = try await PubkyService.saveContact(publicKey: prefixedKey, label: name) + }.value + Self.upsertContactProfileOverride(publicKey: prefixedKey, data: contactData) - // Update local array let updatedProfile = contactData.toProfile(publicKey: prefixedKey) if let index = contacts.firstIndex(where: { $0.publicKey == prefixedKey }) { contacts[index] = PubkyContact(publicKey: prefixedKey, profile: updatedProfile) @@ -397,16 +375,11 @@ class ContactsManager: ObservableObject { func removeContact(publicKey: String) async throws { let prefixedKey = ensurePubkyPrefix(publicKey) - let path = "\(contactsBasePath)\(prefixedKey)" - - let sessionSecret = try getSessionSecret() try await Task.detached { - try await PubkyService.sessionDelete( - sessionSecret: sessionSecret, - path: path - ) + _ = try await PubkyService.removeContact(publicKey: prefixedKey) }.value + Self.removeContactProfileOverride(publicKey: prefixedKey) Logger.info("Removed contact \(PubkyPublicKeyFormat.redacted(prefixedKey))", context: "ContactsManager") @@ -414,41 +387,34 @@ class ContactsManager: ObservableObject { await PrivatePaykitService.shared.removeSavedContact(publicKey: prefixedKey) } - /// Delete all contacts from the homeserver and keep local state in sync with completed deletions. func deleteAllContacts() async throws { - let sessionSecret = try getSessionSecret() - - let basePath = contactsBasePath - - let contactPaths: [String] + let records: [ContactRecord] do { - contactPaths = try await Task.detached { - try await PubkyService.sessionList(sessionSecret: sessionSecret, dirPath: basePath) + records = try await Task.detached { + try await PubkyService.contactRecords() }.value } catch { - if Self.isMissingContactsDataError(error) { - await PrivatePaykitService.shared.pruneUnsavedContactState(savedPublicKeys: []) - contacts.removeAll() - return + guard Self.isMissingContactsDataError(error) else { + throw error } - Logger.error("Failed to list contacts for deletion: \(error)", context: "ContactsManager") - throw error + + Self.clearContactProfileOverrides() + await PrivatePaykitService.shared.pruneUnsavedContactState(savedPublicKeys: []) + contacts.removeAll() + Logger.info("Contacts storage missing, treating delete-all as empty", context: "ContactsManager") + return } var deletedKeys = Set() var firstError: Error? - for path in contactPaths { - let contactKey = extractPublicKey(from: path) - guard !contactKey.isEmpty else { continue } + for record in records { + guard let contactKey = PubkyPublicKeyFormat.normalized(record.publicKey) else { continue } do { try await Task.detached { - try await PubkyService.sessionDelete( - sessionSecret: sessionSecret, - path: "\(basePath)\(contactKey)" - ) + _ = try await PubkyService.removeContact(publicKey: contactKey) }.value - deletedKeys.insert(ensurePubkyPrefix(contactKey)) + deletedKeys.insert(contactKey) } catch { firstError = firstError ?? error Logger.warn("Failed to delete contact '\(PubkyPublicKeyFormat.redacted(contactKey))': \(error)", context: "ContactsManager") @@ -464,15 +430,14 @@ class ContactsManager: ObservableObject { } // All remote deletes succeeded, so clear any local-only contacts too. + Self.clearContactProfileOverrides() await PrivatePaykitService.shared.pruneUnsavedContactState(savedPublicKeys: []) contacts.removeAll() Logger.info("Deleted all contacts", context: "ContactsManager") } - // MARK: - Discover Remote Contacts (list from pubky.app, then resolve each profile) + // MARK: - Remote Contact Discovery - /// Discover profile and contacts from pubky.app, store as pending imports. - /// Returns true if any import data was found. @discardableResult func prepareImport(profile: PubkyProfile?, publicKey: String) async -> Bool { clearPendingImport() @@ -491,7 +456,6 @@ class ContactsManager: ObservableObject { return hasImportData ? .contactImportOverview : .payContacts } - /// Fetch the user's contacts from pubky.app and store as pending imports. func discoverRemoteContacts(publicKey: String) async { let prefixedKey = ensurePubkyPrefix(publicKey) @@ -506,8 +470,12 @@ class ContactsManager: ObservableObject { for key in contactKeys { let pk = ensurePubkyPrefix(key) group.addTask { [self] in - let profile = await resolveImportContactProfile(publicKey: pk) - return .success(PubkyContact(publicKey: pk, profile: profile)) + do { + let profile = try await resolveContactProfile(publicKey: pk, includePlaceholder: true) + return .success(PubkyContact(publicKey: pk, profile: profile)) + } catch { + return .failure(error) + } } } @@ -539,7 +507,7 @@ class ContactsManager: ObservableObject { } } - // MARK: - Fetch Contact Profile (prefer bitkit.to profile, then pubky.app) + // MARK: - Contact Profile Resolution func fetchContactProfile(publicKey: String, includePlaceholder: Bool = false) async -> PubkyProfile? { guard let normalizedKey = PubkyPublicKeyFormat.normalized(publicKey) else { @@ -555,95 +523,143 @@ class ContactsManager: ObservableObject { // MARK: - Helpers - private var contactsBasePath: String { - switch Env.network { - case .bitcoin: - return "/pub/bitkit.to/contacts/" - default: - return "/pub/staging.bitkit.to/contacts/" - } + nonisolated static func backupContactProfileOverrides() -> [String: PubkyProfileData]? { + let overrides = loadContactProfileOverrides() + return overrides.isEmpty ? nil : overrides } - private func getSessionSecret() throws -> String { - guard let sessionSecret = try? Keychain.loadString(key: .paykitSession), - !sessionSecret.isEmpty - else { - throw PubkyServiceError.sessionNotActive + nonisolated static func restoreContactProfileOverrides(_ overrides: [String: PubkyProfileData]?) { + guard let overrides, !overrides.isEmpty else { + UserDefaults.standard.removeObject(forKey: contactProfileOverridesKey) + notifyContactProfileOverridesChanged() + return } - return sessionSecret - } - - /// Write PubkyProfileData JSON to homeserver at /pub/bitkit.to/contacts/ - private func savePubkyProfileData(publicKey: String, data: PubkyProfileData) async throws { - let path = "\(contactsBasePath)\(publicKey)" - let sessionSecret = try getSessionSecret() - - let jsonData = try data.encoded() - try await Task.detached { - try await PubkyService.sessionPut( - sessionSecret: sessionSecret, - path: path, - content: jsonData - ) - }.value + saveContactProfileOverrides(overrides) } - /// Resolve a contact profile using bitkit.to first, then pubky.app, optionally falling back to a placeholder. private func resolveContactProfile(publicKey: String, includePlaceholder: Bool = false) async throws -> PubkyProfile { - let prefixedKey = ensurePubkyPrefix(publicKey) - do { - return try await PubkyProfileManager.resolveRemoteProfile(publicKey: prefixedKey) - } catch { - if includePlaceholder, Self.isMissingContactsDataError(error) { - Logger.info( - "No remote profile found for '\(PubkyPublicKeyFormat.redacted(prefixedKey))', using placeholder", - context: "ContactsManager" - ) - return PubkyProfile.placeholder(publicKey: prefixedKey) - } - - Logger.warn("Failed to resolve contact profile for '\(PubkyPublicKeyFormat.redacted(prefixedKey))': \(error)", context: "ContactsManager") - throw error - } + try await Self.resolveContactProfile(publicKey: publicKey, includePlaceholder: includePlaceholder) } - private func resolveImportContactProfile(publicKey: String) async -> PubkyProfile { + private nonisolated static func resolveContactProfile(publicKey: String, includePlaceholder: Bool = false) async throws -> PubkyProfile { let prefixedKey = ensurePubkyPrefix(publicKey) - for attempt in 0 ..< 2 { do { - return try await resolveContactProfile(publicKey: prefixedKey, includePlaceholder: true) + if let resolution = try await PubkyService.resolveContactProfile(publicKey: prefixedKey, allowPubkyProfileFallback: true) { + return PubkyProfile(resolution: resolution) + } + throw PubkyServiceError.profileNotFound } catch { if attempt == 0, !(error is CancellationError) { Logger.warn( - "Retrying imported contact profile resolution for '\(PubkyPublicKeyFormat.redacted(prefixedKey))' after transient error: \(error)", + "Retrying contact profile resolution for '\(PubkyPublicKeyFormat.redacted(prefixedKey))' after transient error: \(error)", context: "ContactsManager" ) try? await Task.sleep(nanoseconds: 250_000_000) continue } + if includePlaceholder, !(error is CancellationError) { + let message = Self.isMissingContactsDataError(error) + ? "No remote profile found" + : "Failed to resolve remote profile" + Logger.warn( + "\(message) for '\(PubkyPublicKeyFormat.redacted(prefixedKey))', using placeholder: \(error)", + context: "ContactsManager" + ) + return PubkyProfile.placeholder(publicKey: prefixedKey) + } + Logger.warn( - "Falling back to placeholder while importing contact '\(PubkyPublicKeyFormat.redacted(prefixedKey))': \(error)", + "Failed to resolve contact profile for '\(PubkyPublicKeyFormat.redacted(prefixedKey))': \(error)", context: "ContactsManager" ) - return PubkyProfile.placeholder(publicKey: prefixedKey) + throw error + } + } + + throw PubkyServiceError.profileNotFound + } + + private nonisolated static let contactProfileOverridesKey = "pubkyContactProfileOverrides" + + private nonisolated static func contact( + from record: Paykit.ContactRecord, + overrides: [String: PubkyProfileData], + includePlaceholder: Bool + ) async throws -> PubkyContact { + let prefixedKey = PubkyPublicKeyFormat.normalized(record.publicKey) ?? ensurePubkyPrefix(record.publicKey) + + if let override = overrides[prefixedKey] { + return PubkyContact(publicKey: prefixedKey, profile: override.toProfile(publicKey: prefixedKey)) + } + + if let profile = record.profile { + return PubkyContact(publicKey: prefixedKey, profile: PubkyProfile(publicKey: prefixedKey, paykitProfile: profile)) + } + + do { + return try await PubkyContact( + publicKey: prefixedKey, + profile: resolveContactProfile(publicKey: prefixedKey, includePlaceholder: includePlaceholder) + ) + } catch { + if !includePlaceholder { + throw error } } - return PubkyProfile.placeholder(publicKey: prefixedKey) + if includePlaceholder { + return PubkyContact(publicKey: prefixedKey, profile: PubkyProfile.placeholder(publicKey: prefixedKey)) + } + + throw PubkyServiceError.profileNotFound + } + + private nonisolated static func loadContactProfileOverrides() -> [String: PubkyProfileData] { + guard let data = UserDefaults.standard.data(forKey: contactProfileOverridesKey), + let overrides = try? JSONDecoder().decode([String: PubkyProfileData].self, from: data) + else { + return [:] + } + return overrides + } + + private nonisolated static func saveContactProfileOverrides(_ overrides: [String: PubkyProfileData]) { + if overrides.isEmpty { + UserDefaults.standard.removeObject(forKey: contactProfileOverridesKey) + } else if let data = try? JSONEncoder().encode(overrides) { + UserDefaults.standard.set(data, forKey: contactProfileOverridesKey) + } + notifyContactProfileOverridesChanged() + } + + private nonisolated static func upsertContactProfileOverride(publicKey: String, data: PubkyProfileData) { + guard let prefixedKey = PubkyPublicKeyFormat.normalized(publicKey) else { return } + var overrides = loadContactProfileOverrides() + overrides[prefixedKey] = data + saveContactProfileOverrides(overrides) } - /// Extract the public key from a path returned by sessionList - private func extractPublicKey(from path: String) -> String { - // sessionList returns paths like "/pub/bitkit.to/contacts/pubkyXYZ" — extract last component - let components = path.split(separator: "/") - guard let last = components.last else { return "" } - return String(last) + private nonisolated static func removeContactProfileOverride(publicKey: String) { + guard let prefixedKey = PubkyPublicKeyFormat.normalized(publicKey) else { return } + var overrides = loadContactProfileOverrides() + overrides.removeValue(forKey: prefixedKey) + saveContactProfileOverrides(overrides) + } + + private nonisolated static func clearContactProfileOverrides() { + saveContactProfileOverrides([:]) + } + + private nonisolated static func notifyContactProfileOverridesChanged() { + Task { @MainActor in + SettingsViewModel.shared.notifyAppStateChanged() + } } - static func isMissingContactsDataError(_ error: Error) -> Bool { + nonisolated static func isMissingContactsDataError(_ error: Error) -> Bool { if case .profileNotFound = error as? PubkyServiceError { return true } @@ -688,7 +704,7 @@ class ContactsManager: ObservableObject { return false } - private static func isMissingContactsDataMessage(_ message: String?) -> Bool { + private nonisolated static func isMissingContactsDataMessage(_ message: String?) -> Bool { guard let message else { return false } diff --git a/Bitkit/Managers/PubkyProfileManager.swift b/Bitkit/Managers/PubkyProfileManager.swift index 4f473efbc..32fb2287a 100644 --- a/Bitkit/Managers/PubkyProfileManager.swift +++ b/Bitkit/Managers/PubkyProfileManager.swift @@ -1,4 +1,5 @@ import Foundation +import Paykit import SwiftUI enum PubkyAuthState: Equatable { @@ -220,31 +221,9 @@ class PubkyProfileManager: ObservableObject { /// Upload an avatar image to the user's homeserver blob storage. Returns the `pubky://` URI. func uploadAvatar(image: UIImage) async throws -> String { - guard let sessionSecret = try? Keychain.loadString(key: .paykitSession), - !sessionSecret.isEmpty - else { - // If no session yet (creating identity), use secret key to upload - guard let secretKeyHex = try? Keychain.loadString(key: .pubkySecretKey), - !secretKeyHex.isEmpty - else { - throw PubkyServiceError.sessionNotActive - } - - let rawKey = try PubkyService.pubkyPublicKeyFromSecret(secretKeyHex: secretKeyHex) - let publicKey = rawKey.hasPrefix("pubky") ? rawKey : "pubky\(rawKey)" - return try await uploadAvatar(image: image, secretKeyHex: secretKeyHex, publicKey: publicKey) - } - - guard let publicKey, !publicKey.isEmpty else { - throw PubkyServiceError.sessionNotActive - } - - return try await uploadAvatar(image: image, sessionSecret: sessionSecret, publicKey: publicKey) - } - - /// Strip the `pubky` prefix from a public key for use in `pubky://` URIs. - private nonisolated static func stripPubkyPrefix(_ key: String) -> String { - key.hasPrefix("pubky") ? String(key.dropFirst(5)) : key + _ = try activeSessionSecret() + let imageData = try compressAvatar(image) + return try await PubkyService.uploadProfileAvatar(bytes: imageData, contentType: "image/jpeg") } private func compressAvatar(_ image: UIImage, maxSize: CGFloat = 400) throws -> Data { @@ -262,36 +241,6 @@ class PubkyProfileManager: ObservableObject { return jpegData } - private func avatarBlobPath() -> String { - let timestamp = Int(Date().timeIntervalSince1970 * 1000) - switch Env.network { - case .bitcoin: - return "/pub/bitkit.to/blobs/\(timestamp).jpg" - default: - return "/pub/staging.bitkit.to/blobs/\(timestamp).jpg" - } - } - - private func uploadAvatar(image: UIImage, sessionSecret: String, publicKey: String) async throws -> String { - let imageData = try compressAvatar(image) - let blobPath = avatarBlobPath() - let blobUri = "pubky://\(Self.stripPubkyPrefix(publicKey))\(blobPath)" - - try await PubkyService.sessionPut(sessionSecret: sessionSecret, path: blobPath, content: imageData) - return blobUri - } - - private func uploadAvatar(image: UIImage, secretKeyHex: String, publicKey: String) async throws -> String { - let imageData = try compressAvatar(image) - let blobPath = avatarBlobPath() - let blobUri = "pubky://\(Self.stripPubkyPrefix(publicKey))\(blobPath)" - - try await PubkyService.putWithSecretKey(secretKeyHex: secretKeyHex, path: blobPath, content: imageData) - return blobUri - } - - /// Create a new Pubky identity: fetch signup code from Homegate, signup on homeserver, - /// persist keys + session, upload avatar, write profile. Falls back to signIn if already registered. nonisolated static func resolvedImageUrl(newImageUrl: String?, existingImageUrl: String?) -> String? { newImageUrl ?? existingImageUrl } @@ -306,12 +255,9 @@ class PubkyProfileManager: ObservableObject { ) async throws { let (publicKeyZ32, secretKeyHex) = try await deriveKeys() - // Sign up on homeserver via Homegate - let sessionSecret = try await Task.detached { - // 1. Get signup code from Homegate + _ = try await Task.detached { let homegate = try await Self.fetchHomegateSignupCode() - // 2. Sign up — if already registered, fall back to signIn var session: String do { session = try await PubkyService.signUp( @@ -327,26 +273,36 @@ class PubkyProfileManager: ObservableObject { return session }.value - var avatarUri: String? - if let avatarImage { - avatarUri = try await uploadAvatar(image: avatarImage, sessionSecret: sessionSecret, publicKey: publicKeyZ32) - } - let resolvedImageUrl = Self.resolvedImageUrl(newImageUrl: avatarUri, existingImageUrl: existingImageUrl) - - try await writeProfile( - sessionSecret: sessionSecret, - name: name, - bio: bio, - imageUrl: resolvedImageUrl, - links: links, - tags: tags - ) - do { - try Self.upsertKeychainString(.pubkySecretKey, value: secretKeyHex) - try Self.upsertKeychainString(.paykitSession, value: sessionSecret) - _ = try await PubkyService.importSession(secret: sessionSecret) + var avatarUri: String? + if let avatarImage { + avatarUri = try await uploadAvatar(image: avatarImage) + } + let resolvedImageUrl = Self.resolvedImageUrl(newImageUrl: avatarUri, existingImageUrl: existingImageUrl) + + try await writeProfile( + name: name, + bio: bio, + imageUrl: resolvedImageUrl, + links: links, + tags: tags + ) Self.notifyAppStateBackupChanged() + + let createdProfile = PubkyProfile( + publicKey: publicKeyZ32, + name: name, + bio: bio, + imageUrl: resolvedImageUrl, + links: links, + tags: tags, + status: nil + ) + + publicKey = publicKeyZ32 + authState = .authenticated + profile = createdProfile + cacheProfileMetadata(createdProfile) } catch { try? Keychain.delete(key: .pubkySecretKey) try? Keychain.delete(key: .paykitSession) @@ -354,25 +310,9 @@ class PubkyProfileManager: ObservableObject { throw error } - let createdProfile = PubkyProfile( - publicKey: publicKeyZ32, - name: name, - bio: bio, - imageUrl: resolvedImageUrl, - links: links, - tags: tags, - status: nil - ) - - publicKey = publicKeyZ32 - authState = .authenticated - profile = createdProfile - cacheProfileMetadata(createdProfile) - Logger.info("Pubky identity created for \(publicKeyZ32)", context: "PubkyProfileManager") } - /// Update profile data on the homeserver (for edit mode). func saveProfile( name: String, bio: String, @@ -380,12 +320,11 @@ class PubkyProfileManager: ObservableObject { tags: [String] = [], newImageUrl: String? = nil ) async throws { - let sessionSecret = try activeSessionSecret() + _ = try activeSessionSecret() let resolvedImageUrl = Self.resolvedImageUrl(newImageUrl: newImageUrl, existingImageUrl: profile?.imageUrl) try await writeProfile( - sessionSecret: sessionSecret, name: name, bio: bio, imageUrl: resolvedImageUrl, @@ -393,7 +332,6 @@ class PubkyProfileManager: ObservableObject { tags: tags ) - // Update profile locally from the data we just wrote let pk = publicKey ?? "" let updatedProfile = PubkyProfile( publicKey: pk, @@ -409,15 +347,9 @@ class PubkyProfileManager: ObservableObject { } func deleteProfile() async throws { - let sessionSecret = try activeSessionSecret() - let path = Self.profilePath - do { try await Task.detached { - try await PubkyService.sessionDelete( - sessionSecret: sessionSecret, - path: path - ) + try await PubkyService.deletePaykitProfile() }.value } catch { guard Self.isMissingBitkitProfileStorageError(error) else { @@ -427,12 +359,10 @@ class PubkyProfileManager: ObservableObject { Logger.info("Bitkit profile storage already missing, continuing sign out", context: "PubkyProfileManager") } - await signOut() + try await signOut() } - /// Serialize profile JSON and PUT to homeserver. private func writeProfile( - sessionSecret: String, name: String, bio: String, imageUrl: String?, @@ -447,27 +377,11 @@ class PubkyProfileManager: ObservableObject { tags: tags ) - let jsonData = try profileData.encoded() - let path = Self.profilePath - try await Task.detached { - try await PubkyService.sessionPut( - sessionSecret: sessionSecret, - path: path, - content: jsonData - ) + try await PubkyService.publishPaykitProfile(profileData.toPaykitProfile()) }.value } - private nonisolated static var profilePath: String { - switch Env.network { - case .bitcoin: - return "/pub/bitkit.to/profile.json" - default: - return "/pub/staging.bitkit.to/profile.json" - } - } - static func isRingAvailable() -> Bool { guard let url = URL(string: "pubkyauth://check") else { return false @@ -581,7 +495,7 @@ class PubkyProfileManager: ObservableObject { } } - /// Long-polls the relay, persists + imports the session, then loads the profile. + /// Long-polls the relay, activates the SDK session, then loads the profile. @discardableResult func completeAuthentication() async throws -> String { guard let attemptID = activeAuthAttemptID else { @@ -589,28 +503,22 @@ class PubkyProfileManager: ObservableObject { } do { - let sessionSecret = try await PubkyService.completeAuth() + _ = try await PubkyService.completeAuth() try Task.checkCancellation() guard activeAuthAttemptID == attemptID else { throw CancellationError() } - let pk = try await PubkyService.importSession(secret: sessionSecret) + guard let pk = await PubkyService.currentPublicKey() else { + throw PubkyServiceError.sessionNotActive + } try Task.checkCancellation() guard activeAuthAttemptID == attemptID else { throw CancellationError() } - do { - try? Keychain.delete(key: .pubkySecretKey) - try Self.upsertKeychainString(.paykitSession, value: sessionSecret) - UserDefaults.standard.set(false, forKey: PrivatePaykitService.publishingEnabledKey) - PrivatePaykitService.setProfileRecoveryPending(false) - Self.notifyAppStateBackupChanged() - } catch { - await PubkyService.forceSignOut() - throw error - } + UserDefaults.standard.set(false, forKey: PrivatePaykitService.publishingEnabledKey) + Self.notifyAppStateBackupChanged() activeAuthAttemptID = nil publicKey = pk @@ -705,58 +613,18 @@ class PubkyProfileManager: ObservableObject { } nonisolated static func resolveRemoteProfile(publicKey: String) async throws -> PubkyProfile { - try await resolveRemoteProfile( - publicKey: publicKey, - fetchBitkitProfile: { key in - await fetchBitkitProfile(publicKey: key) - }, - fetchPubkyProfile: { key in - try await fetchPubkyProfile(publicKey: key) - } - ) - } - - nonisolated static func resolveRemoteProfile( - publicKey: String, - fetchBitkitProfile: @escaping @Sendable (String) async -> PubkyProfile?, - fetchPubkyProfile: @escaping @Sendable (String) async throws -> PubkyProfile - ) async throws -> PubkyProfile { - if let bitkitProfile = await fetchBitkitProfile(publicKey) { - return bitkitProfile - } - - return try await fetchPubkyProfile(publicKey) - } - - /// Read the user's bitkit profile.json which contains the complete profile data we wrote. - private nonisolated static func fetchBitkitProfile(publicKey: String) async -> PubkyProfile? { - let strippedKey = stripPubkyPrefix(publicKey) - let uri = "pubky://\(strippedKey)\(profilePath)" - - do { - let jsonString = try await PubkyService.fetchFileString(uri: uri) - let profileData = try PubkyProfileData.decode(from: jsonString) - Logger.debug("Fetched bitkit profile.json for \(publicKey)", context: "PubkyProfileManager") - return profileData.toProfile(publicKey: publicKey) - } catch { - Logger.debug("Could not fetch bitkit profile.json: \(error)", context: "PubkyProfileManager") - return nil + let normalizedKey = PubkyPublicKeyFormat.normalized(publicKey) ?? publicKey + if let resolution = try await PubkyService.resolveContactProfile(publicKey: normalizedKey, allowPubkyProfileFallback: true) { + return PubkyProfile(resolution: resolution) } - } - private nonisolated static func fetchPubkyProfile(publicKey: String) async throws -> PubkyProfile { - let profileDto = try await PubkyService.getProfile(publicKey: publicKey) - Logger.debug( - "Profile loaded from pubky FFI — name: \(profileDto.name), image: \(profileDto.image ?? "nil")", - context: "PubkyProfileManager" - ) - return PubkyProfile(publicKey: publicKey, ffiProfile: profileDto) + throw PubkyServiceError.profileNotFound } // MARK: - Sign Out static func clearLocalState() async { - await PrivatePaykitService.shared.closeAndClear(markProfileRecoveryPending: true) + await PrivatePaykitService.shared.closeAndClear() await PrivatePaykitAddressReservationStore.shared.clearContactAssignments() await PubkyService.forceSignOut() try? Keychain.delete(key: .paykitSession) @@ -764,6 +632,7 @@ class PubkyProfileManager: ObservableObject { await PubkyImageCache.shared.clear() UserDefaults.standard.removeObject(forKey: cachedNameKey) UserDefaults.standard.removeObject(forKey: cachedImageUriKey) + ContactsManager.restoreContactProfileOverrides(nil) clearPublicPaykitSharingState() notifyAppStateBackupChanged() } @@ -790,21 +659,31 @@ class PubkyProfileManager: ObservableObject { } static func removePublicPaykitEndpointsBestEffort(context: String) async { - try? await removePublicPaykitEndpoints(context: context) + do { + try await removePublicPaykitEndpoints(context: context) + PublicPaykitService.setCleanupPending(false) + } catch { + PublicPaykitService.setCleanupPending(true) + } } - static func removePrivatePaykitEndpointsBestEffort(context: String) async { + static func removePrivatePaykitEndpoints(context: String) async throws { do { try await PrivatePaykitService.shared.removePublishedEndpoints() } catch { Logger.warn("Failed to remove private Paykit endpoints before clearing session: \(error)", context: context) + throw error } } - func signOut() async { - await Task.detached { + static func removePrivatePaykitEndpointsBestEffort(context: String) async { + try? await removePrivatePaykitEndpoints(context: context) + } + + func signOut() async throws { + try await Task.detached { await Self.removePublicPaykitEndpointsBestEffort(context: "PubkyProfileManager.signOut") - await Self.removePrivatePaykitEndpointsBestEffort(context: "PubkyProfileManager.signOut") + try await Self.removePrivatePaykitEndpoints(context: "PubkyProfileManager.signOut") do { try await PubkyService.signOut() } catch { @@ -820,8 +699,7 @@ class PubkyProfileManager: ObservableObject { await Self.refreshSessionIfPossible( after: error, loadKeychainString: { try Keychain.loadString(key: $0) }, - signInWithSecretKey: { try await PubkyService.signIn(secretKeyHex: $0) }, - persistSessionSecret: { try Self.upsertKeychainString(.paykitSession, value: $0) } + signInWithSecretKey: { try await PubkyService.signIn(secretKeyHex: $0) } ) } @@ -916,17 +794,20 @@ class PubkyProfileManager: ObservableObject { loadKeychainString: (KeychainEntryType) throws -> String? = { try Keychain.loadString(key: $0) }, - persistKeychainString: (KeychainEntryType, String) throws -> Void = { key, value in - try PubkyProfileManager.upsertKeychainString(key, value: value) - }, deleteKeychainValue: (KeychainEntryType) throws -> Void = { try Keychain.delete(key: $0) }, - forceSignOut: @escaping () async -> Void = { - await PubkyService.forceSignOut() + clearSessionAccess: @escaping () async -> Void = { + await PubkyService.clearSessionAccess() + }, + signInWithSecretKey: @escaping (String) async throws -> String = { + try await PubkyService.signIn(secretKeyHex: $0) + }, + importExternalSession: @escaping (String) async throws -> String = { + try await PubkyService.importExternalSession(secret: $0) } ) async throws { - await forceSignOut() + await clearSessionAccess() switch backup?.kind { case .none: @@ -935,16 +816,14 @@ class PubkyProfileManager: ObservableObject { try? deleteKeychainValue(.pubkySecretKey) case .localSeed: let secretKeyHex = try deriveLocalSecretKeyFromWalletSeed(loadKeychainString: loadKeychainString) - try persistKeychainString(.pubkySecretKey, secretKeyHex) - try? deleteKeychainValue(.paykitSession) + _ = try await signInWithSecretKey(secretKeyHex) case .externalSession: guard let sessionSecret = backup?.sessionSecret, !sessionSecret.isEmpty else { throw PubkyServiceError.authFailed("Missing session secret in backup") } - try persistKeychainString(.paykitSession, sessionSecret) - try? deleteKeychainValue(.pubkySecretKey) + _ = try await importExternalSession(sessionSecret) } } @@ -958,10 +837,6 @@ class PubkyProfileManager: ObservableObject { } } - private nonisolated static func upsertKeychainString(_ key: KeychainEntryType, value: String) throws { - try Keychain.upsert(key: key, data: Data(value.utf8)) - } - private nonisolated static func initializePersistedSession() async throws -> SessionInitializationResult { try await PubkyService.initialize() @@ -972,9 +847,6 @@ class PubkyProfileManager: ObservableObject { storedSecretKeyHex: secretKeyHex, importSession: { try await PubkyService.importSession(secret: $0) }, signInWithSecretKey: { try await PubkyService.signIn(secretKeyHex: $0) }, - persistSessionSecret: { secret in - try upsertKeychainString(.paykitSession, value: secret) - }, deleteSessionSecret: { try? Keychain.delete(key: .paykitSession) } @@ -1003,6 +875,14 @@ class PubkyProfileManager: ObservableObject { return try PubkyService.derivePubkySecretKey(seed: seed) } + nonisolated static func publicKeyFromSecretKey(_ secretKeyHex: String) throws -> String { + let publicKey = try PubkyService.pubkyPublicKeyFromSecret(secretKeyHex: secretKeyHex) + guard let normalized = PubkyPublicKeyFormat.normalized(publicKey) else { + throw PubkyServiceError.authFailed("Invalid Pubky public key") + } + return normalized + } + nonisolated static func isMissingBitkitProfileStorageError(_ error: Error) -> Bool { if case .profileNotFound = error as? PubkyServiceError { return true @@ -1048,7 +928,9 @@ class PubkyProfileManager: ObservableObject { try Keychain.loadString(key: $0) }, signInWithSecretKey: (String) async throws -> String, - persistSessionSecret: (String) throws -> Void + publicKeyFromSecretKey: (String) throws -> String = { + try PubkyProfileManager.publicKeyFromSecretKey($0) + } ) async -> Bool { guard isSessionRefreshableError(error) else { return false @@ -1062,8 +944,8 @@ class PubkyProfileManager: ObservableObject { } do { - let newSessionSecret = try await signInWithSecretKey(secretKeyHex) - try persistSessionSecret(newSessionSecret) + _ = try await signInWithSecretKey(secretKeyHex) + _ = try publicKeyFromSecretKey(secretKeyHex) Logger.info("Refreshed pubky session from local secret key", context: "PubkyProfileManager") return true } catch { @@ -1077,7 +959,9 @@ class PubkyProfileManager: ObservableObject { storedSecretKeyHex: String?, importSession: (String) async throws -> String, signInWithSecretKey: (String) async throws -> String, - persistSessionSecret: (String) throws -> Void, + publicKeyFromSecretKey: (String) throws -> String = { + try PubkyProfileManager.publicKeyFromSecretKey($0) + }, deleteSessionSecret: () -> Void ) async -> SessionInitializationResult { if let savedSessionSecret, @@ -1106,9 +990,8 @@ class PubkyProfileManager: ObservableObject { } do { - let newSession = try await signInWithSecretKey(storedSecretKeyHex) - try persistSessionSecret(newSession) - let publicKey = try await importSession(newSession) + _ = try await signInWithSecretKey(storedSecretKeyHex) + let publicKey = try publicKeyFromSecretKey(storedSecretKeyHex) Logger.info("Re-signed in and restored session for \(publicKey)", context: "PubkyProfileManager") return .restored(publicKey: publicKey) } catch { diff --git a/Bitkit/Models/BackupPayloads.swift b/Bitkit/Models/BackupPayloads.swift index 49e46695e..cfe41e312 100644 --- a/Bitkit/Models/BackupPayloads.swift +++ b/Bitkit/Models/BackupPayloads.swift @@ -8,20 +8,7 @@ struct WalletBackupV1: Codable { let createdAt: UInt64 let transfers: [Transfer] let privatePaykitHighestReservedReceiveIndexByAddressType: [String: UInt32]? - let privatePaykitContactLinks: [String: PrivatePaykitContactLinkBackupV1]? -} - -struct PrivatePaykitContactLinkBackupV1: Codable, Equatable { - let publicKey: String - let linkSnapshotHex: String? - let handshakeSnapshotHex: String? - let remoteEndpoints: [String: String] - let linkCompletedAt: UInt64? - let handshakeUpdatedAt: UInt64? - let recoveryStartedAt: UInt64? - let mainRecoveryAttemptId: String? - let responderRecoveryAttemptId: String? - var awaitingRecoveredRemoteEndpoints: Bool? = nil + let paykitSdkBackupState: String? } struct MetadataBackupV1: Codable { @@ -30,6 +17,7 @@ struct MetadataBackupV1: Codable { let tagMetadata: [PreActivityMetadata] let cache: AppCacheData let pubkySession: PubkySessionBackupV1? + let pubkyContactProfileOverrides: [String: PubkyProfileData]? } struct PubkySessionBackupV1: Codable, Equatable { diff --git a/Bitkit/Models/PubkyProfile.swift b/Bitkit/Models/PubkyProfile.swift index 5445d1c5e..553b9091f 100644 --- a/Bitkit/Models/PubkyProfile.swift +++ b/Bitkit/Models/PubkyProfile.swift @@ -1,16 +1,16 @@ -import BitkitCore import Foundation +import Paykit -// MARK: - PubkyProfileData (shared Codable format for profile & contact JSON on homeserver) +// MARK: - PubkyProfileData -struct PubkyProfileData: Codable { +struct PubkyProfileData: Codable, Equatable { var name: String var bio: String var image: String? var links: [Link] var tags: [String] - struct Link: Codable { + struct Link: Codable, Equatable { let label: String let url: String @@ -65,6 +65,26 @@ struct PubkyProfileData: Codable { ) } + static func from(paykitProfile: Paykit.PaykitProfile) -> PubkyProfileData { + let extra = paykitProfile.extraJson.flatMap { try? PubkyProfileData.decode(from: $0) } + return PubkyProfileData( + name: paykitProfile.displayName ?? extra?.name ?? "", + bio: extra?.bio ?? "", + image: paykitProfile.imageUri ?? extra?.image, + links: extra?.links ?? [], + tags: extra?.tags ?? [] + ) + } + + func toPaykitProfile() throws -> Paykit.PaykitProfile { + let extraJson = try String(data: encoded(), encoding: .utf8) + return Paykit.PaykitProfile( + displayName: name, + imageUri: image, + extraJson: extraJson + ) + } + func encoded() throws -> Data { try JSONEncoder().encode(self) } @@ -100,22 +120,31 @@ struct PubkyProfile { Self.truncate(publicKey) } - init(publicKey: String, ffiProfile: BitkitCore.PubkyProfile) { + init(publicKey: String, pubkyProfile: Paykit.PubkyProfile) { self.publicKey = publicKey - name = ffiProfile.name - bio = ffiProfile.bio ?? "" - status = ffiProfile.status + name = pubkyProfile.name + bio = pubkyProfile.bio ?? "" + imageUrl = pubkyProfile.image + links = pubkyProfile.links.map { PubkyProfileLink(label: $0.title, url: $0.url) } tags = [] + status = pubkyProfile.status + } - imageUrl = ffiProfile.image + init(publicKey: String, paykitProfile: Paykit.PaykitProfile) { + self = PubkyProfileData.from(paykitProfile: paykitProfile).toProfile(publicKey: publicKey) + } - if let ffiLinks = ffiProfile.links { - links = ffiLinks.map { link in - PubkyProfileLink(label: link.title, url: link.url) - } - } else { - links = [] + init(resolution: Paykit.ContactProfileResolution) { + let publicKey = Self.normalizedPublicKey(resolution.publicKey) + if let paykitProfile = resolution.paykitProfile { + self.init(publicKey: publicKey, paykitProfile: paykitProfile) + return } + if let pubkyProfile = resolution.pubkyProfile { + self.init(publicKey: publicKey, pubkyProfile: pubkyProfile) + return + } + self = Self.forDisplay(publicKey: publicKey, name: resolution.displayName, imageUrl: resolution.imageUri) } init(publicKey: String, name: String, bio: String, imageUrl: String?, links: [PubkyProfileLink], tags: [String] = [], status: String?) { @@ -139,8 +168,24 @@ struct PubkyProfile { ) } + static func forDisplay(publicKey: String, name: String?, imageUrl: String?) -> PubkyProfile { + PubkyProfile( + publicKey: publicKey, + name: name ?? PubkyProfile.truncate(publicKey), + bio: "", + imageUrl: imageUrl, + links: [], + status: nil + ) + } + private static func truncate(_ key: String) -> String { guard key.count > 10 else { return key } return "\(key.prefix(4))...\(key.suffix(4))" } + + private static func normalizedPublicKey(_ key: String) -> String { + let trimmedKey = key.trimmingCharacters(in: .whitespacesAndNewlines) + return PubkyPublicKeyFormat.normalized(trimmedKey) ?? (trimmedKey.hasPrefix("pubky") ? trimmedKey : "pubky\(trimmedKey)") + } } diff --git a/Bitkit/Services/BackupService.swift b/Bitkit/Services/BackupService.swift index 5a8c0c386..072190442 100644 --- a/Bitkit/Services/BackupService.swift +++ b/Bitkit/Services/BackupService.swift @@ -200,12 +200,15 @@ class BackupService { UserDefaults.standard.set(encodedData, forKey: "savedWidgets") } + var pendingPaykitSdkBackupState: String? + var didRestoreWalletBackup = false + try await performRestore(category: .wallet) { dataBytes in let payload = try JSONDecoder().decode(WalletBackupV1.self, from: dataBytes) try TransferStorage.shared.upsertList(payload.transfers) await PrivatePaykitAddressReservationStore.shared.restoreBackup(payload.privatePaykitHighestReservedReceiveIndexByAddressType) - await PrivatePaykitService.shared.restoreBackup(payload.privatePaykitContactLinks) - await PrivatePaykitAddressReservationStore.shared.reconcileReservedIndexesWithLdk() + pendingPaykitSdkBackupState = payload.paykitSdkBackupState + didRestoreWalletBackup = true Logger.debug("Restored \(payload.transfers.count) transfers", context: "BackupService") } @@ -235,6 +238,7 @@ class BackupService { } catch { Logger.warn("Failed to restore pubky session backup state: \(error)", context: "BackupService") } + ContactsManager.restoreContactProfileOverrides(payload.pubkyContactProfileOverrides) // Force address rotation by clearing onchain address UserDefaults.standard.set("", forKey: "onchainAddress") @@ -242,6 +246,20 @@ class BackupService { Logger.debug("Restored caches, \(payload.tagMetadata.count) pre-activity metadata", context: "BackupService") } + if didRestoreWalletBackup { + if pendingPaykitSdkBackupState != nil { + try await PrivatePaykitService.shared.restoreBackup(pendingPaykitSdkBackupState) + await PrivatePaykitAddressReservationStore.shared.reconcileReservedIndexesWithLdk() + } else { + do { + try await PrivatePaykitService.shared.restoreBackup(nil) + await PrivatePaykitAddressReservationStore.shared.reconcileReservedIndexesWithLdk() + } catch { + Logger.warn("Failed to clear missing Paykit SDK backup state: \(error)", context: "BackupService") + } + } + } + try await performRestore(category: .blocktank) { dataBytes in let payload = try JSONDecoder().decode(BlocktankBackupV1.self, from: dataBytes) @@ -341,6 +359,14 @@ class BackupService { } .store(in: &cancellables) + PaykitSdkService.walletBackupDataChangedPublisher + .debounce(for: .milliseconds(500), scheduler: DispatchQueue.main) + .sink { [weak self] _ in + guard let self, !self.shouldSkipBackup() else { return } + markBackupRequired(category: .wallet) + } + .store(in: &cancellables) + // ACTIVITIES CoreService.shared.activity.activitiesChangedPublisher .debounce(for: .milliseconds(500), scheduler: DispatchQueue.main) @@ -394,7 +420,7 @@ class BackupService { } .store(in: &cancellables) - Logger.debug("Started 9 data store listeners", context: "BackupService") + Logger.debug("Started data store listeners", context: "BackupService") } private func startPeriodicBackupFailureCheck() { @@ -674,13 +700,13 @@ class BackupService { case .wallet: let transfers = try TransferStorage.shared.getAll() let privatePaykitHighestReservedReceiveIndexByAddressType = await PrivatePaykitAddressReservationStore.shared.backupSnapshot() - let privatePaykitContactLinks = await PrivatePaykitService.shared.backupSnapshot() + let paykitSdkBackupState = try await PrivatePaykitService.shared.backupSnapshot() let payload = WalletBackupV1( version: 1, createdAt: UInt64(Date().timeIntervalSince1970 * 1000), transfers: transfers, privatePaykitHighestReservedReceiveIndexByAddressType: privatePaykitHighestReservedReceiveIndexByAddressType, - privatePaykitContactLinks: privatePaykitContactLinks + paykitSdkBackupState: paykitSdkBackupState ) return try JSONEncoder().encode(payload) @@ -688,6 +714,7 @@ class BackupService { let currentTime = UInt64(Date().timeIntervalSince1970 * 1000) let cache = await SettingsViewModel.shared.getAppCacheData() let pubkySession = try PubkyProfileManager.snapshotSessionBackupState() + let pubkyContactProfileOverrides = ContactsManager.backupContactProfileOverrides() let preActivityMetadata = try await CoreService.shared.activity.getAllPreActivityMetadata() @@ -696,7 +723,8 @@ class BackupService { createdAt: currentTime, tagMetadata: preActivityMetadata, cache: cache, - pubkySession: pubkySession + pubkySession: pubkySession, + pubkyContactProfileOverrides: pubkyContactProfileOverrides ) return try JSONEncoder().encode(payload) diff --git a/Bitkit/Services/PrivatePaykitService+Backup.swift b/Bitkit/Services/PrivatePaykitService+Backup.swift index dc1ac7fb1..4dedbfe49 100644 --- a/Bitkit/Services/PrivatePaykitService+Backup.swift +++ b/Bitkit/Services/PrivatePaykitService+Backup.swift @@ -3,103 +3,23 @@ import Foundation // MARK: - Backup extension PrivatePaykitService { - func backupSnapshot() -> [String: PrivatePaykitContactLinkBackupV1]? { - let contacts: [String: PrivatePaykitContactLinkBackupV1] = Dictionary( - uniqueKeysWithValues: state.contacts.compactMap { publicKey, contactState in - guard contactState.hasBackupState else { - return nil - } - - return ( - publicKey, - PrivatePaykitContactLinkBackupV1( - publicKey: publicKey, - linkSnapshotHex: contactState.linkSnapshotHex, - handshakeSnapshotHex: contactState.handshakeSnapshotHex, - remoteEndpoints: contactState.remoteEndpointMap, - linkCompletedAt: contactState.linkCompletedAt, - handshakeUpdatedAt: contactState.handshakeUpdatedAt, - recoveryStartedAt: contactState.recoveryStartedAt, - mainRecoveryAttemptId: contactState.mainRecoveryAttemptId, - responderRecoveryAttemptId: contactState.responderRecoveryAttemptId, - awaitingRecoveredRemoteEndpoints: contactState.awaitingRecoveredRemoteEndpoints ? true : nil - ) - ) - } - ) - - guard !contacts.isEmpty else { return nil } - - return contacts - } - - func restoreBackup(_ backup: [String: PrivatePaykitContactLinkBackupV1]?) async { - resetInFlightWork() - await closeActivePaykitHandles() - activeHandlesByContact.removeAll() - knownSavedContactKeys.removeAll() - - guard let backup else { - state = PrivatePaykitState(contacts: [:]) - persistState() - Self.setProfileRecoveryPending(false) - return - } - - var restoredContacts: [String: ContactState] = [:] - for (publicKey, contactBackup) in backup { - guard let normalizedKey = PubkyPublicKeyFormat.normalized(publicKey) else { continue } - let linkSnapshotHex = await validatedSnapshot( - contactBackup.linkSnapshotHex, - publicKey: normalizedKey, - recipient: PubkyService.encryptedLinkSnapshotRecipient - ) - let handshakeSnapshotHex = await validatedSnapshot( - contactBackup.handshakeSnapshotHex, - publicKey: normalizedKey, - recipient: PubkyService.encryptedLinkHandshakeSnapshotRecipient - ) - - var contactState = ContactState() - contactState.linkSnapshotHex = linkSnapshotHex - contactState.handshakeSnapshotHex = handshakeSnapshotHex - contactState.remoteEndpoints = Self.storedPaymentEntries(from: contactBackup.remoteEndpoints) - contactState.linkCompletedAt = contactBackup.linkCompletedAt - contactState.handshakeUpdatedAt = contactBackup.handshakeUpdatedAt - contactState.recoveryStartedAt = contactBackup.recoveryStartedAt - contactState.mainRecoveryAttemptId = contactBackup.mainRecoveryAttemptId - contactState.responderRecoveryAttemptId = contactBackup.responderRecoveryAttemptId - contactState.awaitingRecoveredRemoteEndpoints = contactBackup.awaitingRecoveredRemoteEndpoints == true - restoredContacts[normalizedKey] = contactState - } - - state = PrivatePaykitState(contacts: restoredContacts) - persistState() - Self.setProfileRecoveryPending(false) - } - - func validatedSnapshot( - _ snapshotHex: String?, - publicKey: String, - recipient: (String) async throws -> String - ) async -> String? { - guard let snapshotHex else { return nil } - - do { - try await validateSnapshot(snapshotHex, publicKey: publicKey, recipient: recipient) - return snapshotHex - } catch { - Logger.warn( - "Dropping private Paykit snapshot with mismatched recipient for \(PubkyPublicKeyFormat.redacted(publicKey)): \(error)", - context: "PrivatePaykit" - ) + func backupSnapshot() async throws -> String? { + guard await PubkyService.currentPublicKey() != nil else { return nil } + return try await PaykitSdkService.shared.exportBackupState() } - static func storedPaymentEntries(from endpoints: [String: String]) -> [StoredPaymentEntry] { - endpoints - .sorted { $0.key < $1.key } - .map { StoredPaymentEntry(methodId: $0.key, endpointData: $0.value) } + func restoreBackup(_ backup: String?) async throws { + pendingMessageDrainRetryTask?.cancel() + pendingMessageDrainRetryTask = nil + state = PrivatePaykitState(contacts: [:]) + knownSavedContactKeys.removeAll() + if let backup { + try await PaykitSdkService.shared.restoreBackupState(backup) + } else { + await PaykitSdkService.shared.clearState() + } + persistState(markWalletBackup: true) } } diff --git a/Bitkit/Services/PrivatePaykitService+Contacts.swift b/Bitkit/Services/PrivatePaykitService+Contacts.swift index 09f80c08b..ec5b69c00 100644 --- a/Bitkit/Services/PrivatePaykitService+Contacts.swift +++ b/Bitkit/Services/PrivatePaykitService+Contacts.swift @@ -1,4 +1,5 @@ import Foundation +import Paykit // MARK: - Saved Contacts @@ -13,125 +14,86 @@ extension PrivatePaykitService { guard await canPublishPrivateEndpoints(wallet: wallet) else { return requireImmediatePublication && !publicKeys.isEmpty ? PrivatePaykitError.privateUnavailable : nil } - if Self.isProfileRecoveryPending, !publicKeys.isEmpty { - return await recoverSavedContactsAfterProfileRecreation( - publicKeys, - wallet: wallet, - requireImmediatePublication: requireImmediatePublication - ) - } + await PrivatePaykitAddressReservationStore.shared.reconcileReservedIndexesWithLdk() - return await publishLocalEndpoints( + + return await syncLocalEndpointPublication( for: publicKeys, wallet: wallet, - maxAdvanceSteps: 3, reason: "prepare", requireImmediatePublication: requireImmediatePublication ) } - @discardableResult - func recoverSavedContactsAfterProfileRecreation( - _ publicKeys: [String], - wallet: WalletViewModel, - requireImmediatePublication: Bool = false - ) async -> Error? { - let publicKeys = rememberSavedContacts(publicKeys, replacing: true) - guard !publicKeys.isEmpty else { return nil } - guard await canPublishPrivateEndpoints(wallet: wallet) else { return nil } - - invalidateLinkEstablishmentWork() - guard await purgePrivatePaymentOutboxForProfileRecovery(reason: "profile recovery") else { - return handleProfileRecoveryPurgeFailure(requireImmediatePublication: requireImmediatePublication) - } - - let startedAt = UInt64(Date().timeIntervalSince1970) - for publicKey in publicKeys { - if let linkId = activeHandlesByContact[publicKey]?.linkId { - try? await PubkyService.closeEncryptedLink(linkId: linkId) - } - if let handshakeId = activeHandlesByContact[publicKey]?.handshakeId { - try? await PubkyService.dropEncryptedLinkHandshake(handshakeId: handshakeId) - } - - markContactForProfileRecovery(publicKey, startedAt: startedAt) - } - - persistState(markWalletBackup: true) - Self.setProfileRecoveryPending(false) - await PrivatePaykitAddressReservationStore.shared.reconcileReservedIndexesWithLdk() - - return await publishLocalEndpoints( + func refreshSavedContactEndpoints(for publicKeys: [String], wallet: WalletViewModel, forceRefreshLightning: Bool = false) async { + _ = await refreshSavedContactEndpointsReturningError( for: publicKeys, wallet: wallet, - maxAdvanceSteps: 3, - reason: "profile recovery", - forceLocalPublishWhenRemoteEmpty: true, - requireImmediatePublication: requireImmediatePublication + forceRefreshLightning: forceRefreshLightning, + requireImmediatePublication: false ) } - func handleProfileRecoveryPurgeFailure(requireImmediatePublication: Bool) -> Error? { - Self.setProfileRecoveryPending(true) - return requireImmediatePublication ? PrivatePaykitError.privateUnavailable : nil - } - - func markContactForProfileRecovery(_ publicKey: String, startedAt: UInt64) { - activeHandlesByContact[publicKey] = ContactPaykitHandles() + func refreshKnownSavedContactEndpoints(wallet: WalletViewModel, reason: String, forceRefreshLightning: Bool = false) async { + let publicKeys = Array(knownSavedContactKeys) + guard !publicKeys.isEmpty else { return } - var contactState = ContactState() - contactState.recoveryStartedAt = startedAt - state.contacts[publicKey] = contactState - cancelPendingPublicationRetry(for: publicKey) + _ = await refreshSavedContactEndpointsReturningError( + for: publicKeys, + wallet: wallet, + forceRefreshLightning: forceRefreshLightning, + requireImmediatePublication: false, + reason: reason + ) } - func refreshSavedContactEndpoints(for publicKeys: [String], wallet: WalletViewModel) async { - let publicKeys = rememberSavedContacts(publicKeys, replacing: true) - guard await canPublishPrivateEndpoints(wallet: wallet) else { return } - if Self.isProfileRecoveryPending, !publicKeys.isEmpty { - await recoverSavedContactsAfterProfileRecreation(publicKeys, wallet: wallet) - return + @discardableResult + func refreshSavedContactEndpointsReturningError( + for publicKeys: [String], + wallet: WalletViewModel, + forceRefreshLightning: Bool, + requireImmediatePublication: Bool, + reason: String = "refresh" + ) async -> Error? { + guard await canPublishPrivateEndpoints(wallet: wallet) else { + return requireImmediatePublication && !publicKeys.isEmpty ? PrivatePaykitError.privateUnavailable : nil } - await publishLocalEndpoints(for: publicKeys, wallet: wallet, maxAdvanceSteps: 1, reason: "refresh") - } - func refreshKnownSavedContactEndpoints(wallet: WalletViewModel, reason: String, forceRefreshLightning: Bool = false) async { - guard !knownSavedContactKeys.isEmpty else { return } - guard await canPublishPrivateEndpoints(wallet: wallet) else { return } - if Self.isProfileRecoveryPending { - await recoverSavedContactsAfterProfileRecreation(Array(knownSavedContactKeys), wallet: wallet) - return - } - await publishLocalEndpoints( - for: Array(knownSavedContactKeys), + return await syncLocalEndpointPublication( + for: publicKeys, wallet: wallet, - maxAdvanceSteps: 1, reason: reason, - forceRefreshLightning: forceRefreshLightning + forceRefreshLightning: forceRefreshLightning, + requireImmediatePublication: requireImmediatePublication ) } func removePublishedEndpoints() async throws { - try await removePublishedEndpoints(for: Array(state.contacts.keys)) + let publicKeys = Set(knownSavedContactKeys) + .union(state.contacts.keys) + .union(Self.pendingDeletedContactCleanupKeys()) + try await removePublishedEndpoints(for: Array(publicKeys)) } func removePublishedEndpoints(for publicKeys: [String]) async throws { - invalidateLinkEstablishmentWork() var firstError: Error? - - for publicKey in publicKeys { - let generation = stateGeneration + for publicKey in normalizedSavedContactKeys(publicKeys) { do { - try await removePublishedEndpoints(for: publicKey, generation: generation) - } catch { - await recordLinkFailure(publicKey: publicKey, error: error, generation: generation) - if firstError == nil { - firstError = error + let report = try await PaykitSdkService.shared.clearPrivatePaymentList(to: publicKey) + if !report.failedToQueue.isEmpty || !report.failedToDeliver.isEmpty { + throw PrivatePaykitError.privateUnavailable } - Logger.warn( - "Failed to remove private Paykit endpoints for \(PubkyPublicKeyFormat.redacted(publicKey)): \(error)", - context: "PrivatePaykit" - ) + if var contactState = state.contacts[publicKey] { + contactState.cachedResolvedEndpoints = [] + contactState.localInvoice = nil + contactState.hasPublishedPrivatePaymentList = false + state.contacts[publicKey] = contactState.hasCacheState ? contactState : nil + } + Self.clearDeletedContactCleanupPending([publicKey]) + persistState(markWalletBackup: true) + } catch { + firstError = firstError ?? error + Self.markDeletedContactCleanupPending([publicKey]) } } @@ -142,295 +104,274 @@ extension PrivatePaykitService { func removeSavedContact(publicKey: String) async { guard let normalizedKey = PubkyPublicKeyFormat.normalized(publicKey) else { return } - invalidateLinkEstablishment(for: normalizedKey) knownSavedContactKeys.remove(normalizedKey) - let generation = stateGeneration - do { - try await removePublishedEndpoints(for: normalizedKey, generation: generation) + try await removePublishedEndpoints(for: [normalizedKey]) + await clearContactState(publicKey: normalizedKey) } catch { - await recordLinkFailure(publicKey: normalizedKey, error: error, generation: generation) - Self.setContactSharingCleanupPending(true) Logger.warn( - "Failed to tombstone private Paykit endpoints for removed contact \(PubkyPublicKeyFormat.redacted(normalizedKey)): \(error)", + "Failed to remove private Paykit endpoints for deleted contact \(PubkyPublicKeyFormat.redacted(normalizedKey)): \(error)", context: "PrivatePaykit" ) - return } - - await clearContactState(publicKey: normalizedKey) - await PrivatePaykitAddressReservationStore.shared.clearContactAssignment(publicKey: normalizedKey) } func removeSavedContacts(publicKeys: [String]) async { - for publicKey in normalizedSavedContactKeys(publicKeys) { - await removeSavedContact(publicKey: publicKey) + let normalizedKeys = normalizedSavedContactKeys(publicKeys) + for publicKey in normalizedKeys { + knownSavedContactKeys.remove(publicKey) + } + do { + try await removePublishedEndpoints(for: normalizedKeys) + for publicKey in normalizedKeys { + await clearContactState(publicKey: publicKey) + } + } catch { + Logger.warn("Failed to remove private Paykit endpoints for deleted contacts: \(error)", context: "PrivatePaykit") } } func pruneUnsavedContactState(savedPublicKeys publicKeys: [String]) async { - let savedKeys = Set(rememberSavedContacts(publicKeys, replacing: true)) - let staleKeys = state.contacts.keys.filter { !savedKeys.contains($0) } + let savedKeys = Set(normalizedSavedContactKeys(publicKeys)) + knownSavedContactKeys = savedKeys - for publicKey in staleKeys { - await removeSavedContact(publicKey: publicKey) - } + let staleKeys = Set(state.contacts.keys).subtracting(savedKeys) + let cleanupKeys = staleKeys.union(Self.pendingDeletedContactCleanupKeys().subtracting(savedKeys)) + guard !cleanupKeys.isEmpty else { return } - await PrivatePaykitAddressReservationStore.shared.clearContactAssignments(excludingPublicKeys: Array(savedKeys)) + do { + try await removePublishedEndpoints(for: Array(cleanupKeys)) + for publicKey in staleKeys { + await clearContactState(publicKey: publicKey) + } + } catch { + Logger.warn("Failed to prune private Paykit endpoints for unsaved contacts: \(error)", context: "PrivatePaykit") + } } - func retryPendingEndpointRemoval(wallet: WalletViewModel, savedPublicKeys: [String]) async { - guard UserDefaults.standard.bool(forKey: Self.cleanupPendingKey) else { return } + func retryPendingEndpointRemoval(wallet _: WalletViewModel, savedPublicKeys publicKeys: [String]) async { + let savedKeys = Set(normalizedSavedContactKeys(publicKeys)) + let isFullCleanupPending = UserDefaults.standard.bool(forKey: Self.cleanupPendingKey) + let cleanupKeys = isFullCleanupPending + ? Set(knownSavedContactKeys).union(state.contacts.keys).union(Self.pendingDeletedContactCleanupKeys()) + : Set(pendingPrivateEndpointRemovalKeys(savedPublicKeys: publicKeys)) + + guard !cleanupKeys.isEmpty else { + if isFullCleanupPending { + Self.setContactSharingCleanupPending(false) + } + return + } do { - if !UserDefaults.standard.bool(forKey: PublicPaykitService.publishingEnabledKey) { - try await PublicPaykitService.syncPublishedEndpoints(wallet: wallet, publish: false) + try await removePublishedEndpoints(for: Array(cleanupKeys)) + for publicKey in cleanupKeys where !savedKeys.contains(publicKey) { + await clearContactState(publicKey: publicKey) } - - let publicKeys = pendingPrivateEndpointRemovalKeys(savedPublicKeys: savedPublicKeys) - if !publicKeys.isEmpty { - try await removePublishedEndpoints(for: publicKeys) + if isFullCleanupPending { + Self.setContactSharingCleanupPending(false) } - await clearUnsavedContactState(savedPublicKeys: savedPublicKeys) - Self.setContactSharingCleanupPending(false) } catch { - Logger.warn("Failed to retry pending Paykit contact endpoint removal: \(error)", context: "PrivatePaykit") + Logger.warn("Failed to retry private Paykit endpoint cleanup: \(error)", context: "PrivatePaykit") } } func pendingPrivateEndpointRemovalKeys(savedPublicKeys publicKeys: [String]) -> [String] { - if !UserDefaults.standard.bool(forKey: Self.publishingEnabledKey) { - return Array(state.contacts.keys) - } - let savedKeys = Set(normalizedSavedContactKeys(publicKeys)) - return state.contacts.keys.filter { !savedKeys.contains($0) } + return Array(Self.pendingDeletedContactCleanupKeys().subtracting(savedKeys)).sorted() } func clearUnsavedContactState(savedPublicKeys publicKeys: [String]) async { - let savedKeys = Set(normalizedSavedContactKeys(publicKeys)) - let staleKeys = state.contacts.keys.filter { !savedKeys.contains($0) } + await pruneUnsavedContactState(savedPublicKeys: publicKeys) + } - for publicKey in staleKeys { - await clearContactState(publicKey: publicKey) + func publishLocalEndpoints( + for publicKey: String, + wallet: WalletViewModel, + forceRefreshLightning: Bool = false + ) async throws { + if let error = await syncLocalEndpointPublication( + for: [publicKey], + wallet: wallet, + reason: "publish", + forceRefreshLightning: forceRefreshLightning, + requireImmediatePublication: true + ) { + throw error } + } - await PrivatePaykitAddressReservationStore.shared.clearContactAssignments(excludingPublicKeys: Array(savedKeys)) + private func syncLocalEndpointPublication( + for publicKeys: [String], + wallet: WalletViewModel, + reason: String, + forceRefreshLightning: Bool = false, + requireImmediatePublication: Bool + ) async -> Error? { + do { + return try await publicationLock.withLock { + await syncLocalEndpointPublicationLocked( + for: publicKeys, + wallet: wallet, + reason: reason, + forceRefreshLightning: forceRefreshLightning, + requireImmediatePublication: requireImmediatePublication + ) + } + } catch { + return requireImmediatePublication ? error : nil + } } - @discardableResult - func publishLocalEndpoints( + private func syncLocalEndpointPublicationLocked( for publicKeys: [String], wallet: WalletViewModel, - maxAdvanceSteps: Int, reason: String, - scheduleRetries: Bool = true, - forceLocalPublishWhenRemoteEmpty: Bool = false, forceRefreshLightning: Bool = false, - requireImmediatePublication: Bool = false + requireImmediatePublication: Bool ) async -> Error? { - let generation = stateGeneration - var firstError: Error? + let publicKeys = normalizedSavedContactKeys(publicKeys) + guard !publicKeys.isEmpty else { return nil } + guard await PubkyService.currentPublicKey() != nil else { + return requireImmediatePublication ? PubkyServiceError.sessionNotActive : nil + } + + var firstError: Error? + var updates = [PrivatePaymentListReservationUpdateInput]() for publicKey in publicKeys { - var retryPublicKey = PubkyPublicKeyFormat.normalized(publicKey) ?? publicKey do { - guard let normalizedKey = knownSavedContact(publicKey) else { - continue - } - retryPublicKey = normalizedKey - - guard let linkId = try await establishedLinkId(for: normalizedKey, maxAdvanceSteps: maxAdvanceSteps, generation: generation) else { - if scheduleRetries { - schedulePendingPublicationRetry(for: normalizedKey, wallet: wallet) - } - if requireImmediatePublication, firstError == nil { - firstError = PrivatePaykitError.privateUnavailable - } - continue - } - - if state.contacts[normalizedKey]?.lastLocalPayloadHash == nil { - if await shouldPublishLocalEndpoints(publicKey: normalizedKey, fetchedRemoteCount: 0), - !shouldDeferInitialLocalPublish(publicKey: normalizedKey, fetchedRemoteCount: 0) - { - try await publishLocalEndpoints( - to: normalizedKey, - linkId: linkId, - wallet: wallet, - generation: generation, - forceRefreshLightning: forceRefreshLightning - ) - if scheduleRetries { - schedulePendingPublicationRetry(for: normalizedKey, wallet: wallet) - } - continue - } - - let fetchedCount: Int - do { - fetchedCount = try await fetchRemoteEndpoints(publicKey: normalizedKey, linkId: linkId, generation: generation) - } catch { - if requireImmediatePublication, firstError == nil { - firstError = error - } - if shouldCountAsStaleLinkFailure(error) { - if scheduleRetries { - schedulePendingPublicationRetry(for: normalizedKey, wallet: wallet) - } - continue - } - throw error - } - - let shouldForcePublish = forceLocalPublishWhenRemoteEmpty && - fetchedCount == 0 && - state.contacts[normalizedKey]?.remoteEndpoints.isEmpty != false - let shouldPublish = if shouldForcePublish { - true - } else { - await shouldPublishLocalEndpoints(publicKey: normalizedKey, fetchedRemoteCount: fetchedCount) - } - guard shouldPublish else { - if requireImmediatePublication, firstError == nil { - firstError = PrivatePaykitError.privateUnavailable - } - if scheduleRetries { - schedulePendingPublicationRetry(for: normalizedKey, wallet: wallet) - } - continue - } - - try await publishLocalEndpoints( - to: normalizedKey, - linkId: linkId, - wallet: wallet, - generation: generation, - force: shouldForcePublish, - forceRefreshLightning: forceRefreshLightning - ) - if fetchedCount == 0, state.contacts[normalizedKey]?.remoteEndpoints.isEmpty != false { - if scheduleRetries { - schedulePendingPublicationRetry(for: normalizedKey, wallet: wallet) - } - } else if scheduleRetries, await shouldRetryMissingPrivateLightningEndpoint(for: normalizedKey, wallet: wallet) { - schedulePendingPublicationRetry(for: normalizedKey, wallet: wallet) - } else { - cancelPendingPublicationRetry(for: normalizedKey) - } - continue - } - - let fetchedCount: Int - do { - fetchedCount = try await fetchRemoteEndpoints(publicKey: normalizedKey, linkId: linkId, generation: generation) - } catch { - if requireImmediatePublication, firstError == nil { - firstError = error - } - if shouldCountAsStaleLinkFailure(error) { - if scheduleRetries { - schedulePendingPublicationRetry(for: normalizedKey, wallet: wallet) - } - continue - } - throw error - } - - guard await shouldPublishLocalEndpoints(publicKey: normalizedKey, fetchedRemoteCount: fetchedCount) else { - if requireImmediatePublication, firstError == nil { - firstError = PrivatePaykitError.privateUnavailable - } - if scheduleRetries { - schedulePendingPublicationRetry(for: normalizedKey, wallet: wallet) - } - continue - } + _ = try await PaykitSdkService.shared.ensureLinkWithPeer(publicKey) + } catch { + Logger.warn( + "Failed to prepare private Paykit link for \(PubkyPublicKeyFormat.redacted(publicKey)) during \(reason): \(error)", + context: "PrivatePaykit" + ) + } - // Recovery retries may need to resend the same map after a link is re-established and remote state is empty. - let shouldForcePublish = forceLocalPublishWhenRemoteEmpty && - fetchedCount == 0 && - state.contacts[normalizedKey]?.remoteEndpoints.isEmpty != false - try await publishLocalEndpoints( - to: normalizedKey, - linkId: linkId, + do { + let endpoints = try await buildLocalEndpoints( + for: publicKey, wallet: wallet, - generation: generation, - force: shouldForcePublish, forceRefreshLightning: forceRefreshLightning ) - if fetchedCount == 0, state.contacts[normalizedKey]?.remoteEndpoints.isEmpty != false { - if scheduleRetries { - schedulePendingPublicationRetry(for: normalizedKey, wallet: wallet) - } - } else if scheduleRetries, await shouldRetryMissingPrivateLightningEndpoint(for: normalizedKey, wallet: wallet) { - schedulePendingPublicationRetry(for: normalizedKey, wallet: wallet) - } else { - cancelPendingPublicationRetry(for: normalizedKey) - } + let reservations = reservations(from: endpoints, publicKey: publicKey) + updates.append(PrivatePaymentListReservationUpdateInput(counterparty: publicKey, reservations: reservations)) } catch { - if scheduleRetries { - schedulePendingPublicationRetry(for: retryPublicKey, wallet: wallet) - } - if firstError == nil { - firstError = error - } Logger.warn( - "Failed to \(reason) private Paykit endpoints for \(PubkyPublicKeyFormat.redacted(retryPublicKey)): \(error)", + "Failed to prepare private Paykit endpoints for \(PubkyPublicKeyFormat.redacted(publicKey)) during \(reason): \(error)", context: "PrivatePaykit" ) + firstError = firstError ?? error } } - return firstError + guard !updates.isEmpty else { + return requireImmediatePublication ? firstError ?? PrivatePaykitError.privateUnavailable : nil + } + + do { + let report = try await PaykitSdkService.shared.syncPrivatePaymentListsWithReservations( + updates, + clearUnlistedLinkedPeers: false + ) + firstError = firstError ?? applyPrivatePaymentListDeliveryReport(report, reason: reason) + let retryKeys = privatePaymentListDeliveryRetryKeys(from: report) + await drainPendingPrivateMessages(reason: reason, advancingLinksFor: retryKeys) + if !retryKeys.isEmpty { + schedulePendingPrivateMessageDrainRetries(reason: reason, publicKeys: retryKeys) + } + } catch { + Logger.warn("Failed to sync private Paykit endpoint publications during \(reason): \(error)", context: "PrivatePaykit") + firstError = firstError ?? error + } + + return requireImmediatePublication ? firstError : nil } - func schedulePendingPublicationRetry( - for publicKey: String, - wallet: WalletViewModel, - remainingAttempts: Int = PrivatePaykitService.pendingPublicationRetryAttempts - ) { - guard remainingAttempts > 0, isKnownSavedContact(publicKey), pendingPublicationRetryTasks[publicKey] == nil else { - return + private func privatePaymentListDeliveryRetryKeys(from report: PrivatePaymentListDeliveryReport) -> [String] { + normalizedSavedContactKeys(report.queued.map(\.counterparty) + report.failedToDeliver.map(\.counterparty)) + } + + private func drainPendingPrivateMessages(reason: String, advancingLinksFor publicKeys: [String] = []) async { + do { + for publicKey in normalizedSavedContactKeys(publicKeys) { + do { + _ = try await PaykitSdkService.shared.ensureLinkWithPeer(publicKey) + } catch { + Logger.warn( + "Failed to advance private Paykit link for \(PubkyPublicKeyFormat.redacted(publicKey)) during \(reason): \(error)", + context: "PrivatePaykit" + ) + } + } + try await PaykitSdkService.shared.processPendingPrivateMessages() + try await PaykitSdkService.shared.receivePrivateMessagesFromLinkedPeers() + try await PaykitSdkService.shared.processPendingPrivateMessages() + try await PaykitSdkService.shared.receivePrivateMessagesFromLinkedPeers() + } catch { + Logger.warn("Failed to process pending private Paykit messages during \(reason): \(error)", context: "PrivatePaykit") } + } - let task = Task { [weak self, weak wallet] in - try? await Task.sleep(nanoseconds: Self.pendingPublicationRetryDelay) - guard !Task.isCancelled, let self, let wallet else { return } - await runPendingPublicationRetry(for: publicKey, wallet: wallet, remainingAttempts: remainingAttempts) + private func schedulePendingPrivateMessageDrainRetries(reason: String, publicKeys: [String]) { + pendingMessageDrainRetryTask?.cancel() + pendingMessageDrainRetryTask = Task { [reason, publicKeys] in + for delay in Self.privateMessageDrainRetryDelays { + guard !Task.isCancelled else { return } + try? await Task.sleep(nanoseconds: delay) + guard !Task.isCancelled else { return } + await PrivatePaykitService.shared.drainPendingPrivateMessages(reason: "\(reason) retry", advancingLinksFor: publicKeys) + } } - pendingPublicationRetryTasks[publicKey] = task } - func runPendingPublicationRetry(for publicKey: String, wallet: WalletViewModel, remainingAttempts: Int) async { - guard pendingPublicationRetryTasks[publicKey] != nil else { return } - pendingPublicationRetryTasks[publicKey] = nil - guard isKnownSavedContact(publicKey), await canPublishPrivateEndpoints(wallet: wallet) else { return } + private func applyPrivatePaymentListDeliveryReport(_ report: PrivatePaymentListDeliveryReport, reason: String) -> Error? { + var firstError: Error? + var didChangeState = false - await publishLocalEndpoints( - for: [publicKey], - wallet: wallet, - maxAdvanceSteps: 3, - reason: "retry", - scheduleRetries: false, - forceLocalPublishWhenRemoteEmpty: true - ) + for change in report.queued { + guard let publicKey = PubkyPublicKeyFormat.normalized(change.counterparty) else { continue } + state.contacts[publicKey, default: ContactState()].hasPublishedPrivatePaymentList = true + Self.clearDeletedContactCleanupPending([publicKey]) + didChangeState = true + } - let contactState = state.contacts[publicKey] - let needsAnotherRetryFromLinkState = contactState?.linkCompletedAt == nil || - contactState?.lastLocalPayloadHash == nil || - contactState?.remoteEndpoints.isEmpty != false - var needsAnotherRetry = needsAnotherRetryFromLinkState - if !needsAnotherRetry { - needsAnotherRetry = await shouldRetryMissingPrivateLightningEndpoint(for: publicKey, wallet: wallet) + for change in report.cleared { + guard let publicKey = PubkyPublicKeyFormat.normalized(change.counterparty) else { continue } + if var contactState = state.contacts[publicKey] { + contactState.cachedResolvedEndpoints = [] + contactState.localInvoice = nil + contactState.hasPublishedPrivatePaymentList = false + state.contacts[publicKey] = contactState.hasCacheState ? contactState : nil + didChangeState = true + } + Self.clearDeletedContactCleanupPending([publicKey]) } - if needsAnotherRetry { - schedulePendingPublicationRetry(for: publicKey, wallet: wallet, remainingAttempts: remainingAttempts - 1) + + for change in report.failedToQueue { + let publicKey = PubkyPublicKeyFormat.normalized(change.counterparty) ?? change.counterparty + Logger.warn( + "Failed to queue private Paykit endpoints for \(PubkyPublicKeyFormat.redacted(publicKey)) during \(reason): \(change.error ?? "unknown error")", + context: "PrivatePaykit" + ) + firstError = firstError ?? PrivatePaykitError.privateUnavailable } - } - func cancelPendingPublicationRetry(for publicKey: String) { - pendingPublicationRetryTasks.removeValue(forKey: publicKey)?.cancel() + for failure in report.failedToDeliver { + let publicKey = PubkyPublicKeyFormat.normalized(failure.counterparty) ?? failure.counterparty + Logger.warn( + "Failed to deliver private Paykit endpoints for \(PubkyPublicKeyFormat.redacted(publicKey)) during \(reason): \(failure.error)", + context: "PrivatePaykit" + ) + firstError = firstError ?? PrivatePaykitError.privateUnavailable + } + + if didChangeState { + persistState(markWalletBackup: true) + } + + return firstError } func normalizedSavedContactKeys(_ publicKeys: [String]) -> [String] { @@ -466,74 +407,4 @@ extension PrivatePaykitService { guard let normalizedKey = PubkyPublicKeyFormat.normalized(publicKey) else { return false } return knownSavedContactKeys.contains(normalizedKey) } - - func removePublishedEndpoints(for publicKey: String, generation: UInt64) async throws { - let normalizedKey = PubkyPublicKeyFormat.normalized(publicKey) ?? publicKey - let previousTask = publicationTasks[normalizedKey]?.task - let taskId = UUID() - let task = Task { [weak self] in - if let previousTask { - try? await previousTask.value - } - guard let self else { throw PrivatePaykitError.privateUnavailable } - try Task.checkCancellation() - try await removePublishedEndpointsUnlocked(for: normalizedKey, generation: generation) - } - publicationTasks[normalizedKey] = PublicationTask(id: taskId, task: task) - - do { - try await task.value - if publicationTasks[normalizedKey]?.id == taskId { - publicationTasks[normalizedKey] = nil - } - } catch { - if publicationTasks[normalizedKey]?.id == taskId { - publicationTasks[normalizedKey] = nil - } - throw error - } - } - - func removePublishedEndpointsUnlocked(for publicKey: String, generation: UInt64) async throws { - guard let linkId = try await existingOrRecoveredLinkIdForRemoval(for: publicKey, generation: generation) else { - if shouldRequirePrivateEndpointRemoval(publicKey: publicKey) { - throw PrivatePaykitError.privateUnavailable - } - return - } - - try ensureCurrentGeneration(generation) - let removalEntries = privateEndpointRemovalEntries() - try validateNoisePayload(entries: removalEntries) - try await PubkyService.setPrivatePayments(linkId: linkId, entries: removalEntries) - try ensureCurrentGeneration(generation) - state.contacts[publicKey]?.lastLocalPayloadHash = nil - try await persistLinkSnapshot(linkId: linkId, publicKey: publicKey, generation: generation) - let ownPublicKey = await (PubkyService.currentPublicKey()).flatMap(PubkyPublicKeyFormat.normalized) - if let ownPublicKey { - await clearRecoveryMarker(from: ownPublicKey, to: publicKey) - } - } - - func existingOrRecoveredLinkIdForRemoval(for publicKey: String, generation: UInt64) async throws -> String? { - if let linkId = try await existingLinkId(for: publicKey, generation: generation) { - return linkId - } - - guard shouldRequirePrivateEndpointRemoval(publicKey: publicKey) else { - return nil - } - - return try await establishedLinkId(for: publicKey, maxAdvanceSteps: 5, generation: generation) - } - - func shouldRequirePrivateEndpointRemoval(publicKey: String) -> Bool { - guard let contactState = state.contacts[publicKey] else { return false } - - return contactState.linkSnapshotHex != nil || - contactState.lastLocalPayloadHash != nil || - contactState.localInvoice != nil || - contactState.linkCompletedAt != nil || - contactState.recoveryStartedAt != nil - } } diff --git a/Bitkit/Services/PrivatePaykitService+Endpoints.swift b/Bitkit/Services/PrivatePaykitService+Endpoints.swift index 43db4bcc3..af09cfe8c 100644 --- a/Bitkit/Services/PrivatePaykitService+Endpoints.swift +++ b/Bitkit/Services/PrivatePaykitService+Endpoints.swift @@ -2,308 +2,56 @@ import CryptoKit import Foundation import Paykit -// MARK: - Endpoint Publishing +// MARK: - Endpoint Preparation extension PrivatePaykitService { - private static let privatePaymentsEnvelopeKind = "paykit.private_payments" - private static let privatePaymentsReferencePlaceholder = "550e8400-e29b-41d4-a716-446655440000" - - static func isNoisePayloadWithinLimit(_ paymentMap: [String: String]) -> Bool { - let envelope: [String: Any] = [ - "version": 1, - "kind": privatePaymentsEnvelopeKind, - "reference": privatePaymentsReferencePlaceholder, - "entries": paymentMap, - ] - guard let data = try? JSONSerialization.data(withJSONObject: envelope) else { - return false - } - return data.count <= maxNoisePayloadBytes - } - func handleReceivedPayment(paymentHash: String, wallet: WalletViewModel) async { - let matchingContacts = state.contacts.compactMap { publicKey, contactState -> String? in - guard isKnownSavedContact(publicKey) else { return nil } - return contactState.localInvoice?.paymentHash == paymentHash ? publicKey : nil - } - - guard !matchingContacts.isEmpty else { return } - - for publicKey in matchingContacts { - rememberReceivedInvoicePaymentHash(paymentHash, publicKey: publicKey) - if state.contacts[publicKey]?.localInvoice?.paymentHash == paymentHash { - state.contacts[publicKey]?.localInvoice = nil - } - } - persistState() - - guard await canPublishPrivateEndpoints(wallet: wallet) else { return } - - for publicKey in matchingContacts { - let generation = stateGeneration - do { - guard let linkId = try await establishedLinkId(for: publicKey, maxAdvanceSteps: 1, generation: generation) else { - continue - } - try await publishLocalEndpoints(to: publicKey, linkId: linkId, wallet: wallet, generation: generation) - } catch { - Logger.warn( - "Failed to rotate private Paykit invoice for \(PubkyPublicKeyFormat.redacted(publicKey)): \(error)", - context: "PrivatePaykit" - ) - } - } + guard let publicKey = contactPublicKey(forPrivateInvoicePaymentHash: paymentHash) else { return } + rememberReceivedInvoicePaymentHash(paymentHash, publicKey: publicKey) + await refreshSavedContactEndpoints(for: [publicKey], wallet: wallet) } func reconcileReceivedPayments(wallet: WalletViewModel) async { - for paymentHash in await settledPrivateInvoicePaymentHashes() { + let settledHashes = await settledPrivateInvoicePaymentHashes() + guard !settledHashes.isEmpty else { return } + + for paymentHash in settledHashes { await handleReceivedPayment(paymentHash: paymentHash, wallet: wallet) } } func handleOnchainActivity(wallet: WalletViewModel) async { - guard await canPublishPrivateEndpoints(wallet: wallet) else { return } let publicKeys = await PrivatePaykitAddressReservationStore.shared.contactsWithUsedReservedAddresses() - .filter(isKnownSavedContact) + .compactMap(knownSavedContact) guard !publicKeys.isEmpty else { return } - await rotateOnchainEndpoints(for: publicKeys, wallet: wallet, reason: "on-chain rotation", forceRotate: false) + await refreshSavedContactEndpoints(for: publicKeys, wallet: wallet, forceRefreshLightning: false) } func handleOnchainActivity(receivedAddresses: [String], wallet: WalletViewModel) async { - let receivedAddresses = receivedAddresses.filter { !$0.isEmpty } - guard !receivedAddresses.isEmpty else { - await handleOnchainActivity(wallet: wallet) - return - } - guard await canPublishPrivateEndpoints(wallet: wallet) else { return } - - var publicKeys = Set() + var publicKeys: [String] = [] for address in receivedAddresses { if let publicKey = await PrivatePaykitAddressReservationStore.shared.currentContactPublicKey(forReservedAddress: address), - isKnownSavedContact(publicKey) + let savedKey = knownSavedContact(publicKey) { - publicKeys.insert(publicKey) + publicKeys.append(savedKey) } } - guard !publicKeys.isEmpty else { return } - await rotateOnchainEndpoints(for: Array(publicKeys), wallet: wallet, reason: "on-chain transaction output rotation", forceRotate: true) - } - - private func rotateOnchainEndpoints(for publicKeys: [String], wallet: WalletViewModel, reason: String, forceRotate: Bool) async { - var rotatedPublicKeys: [String] = [] - for publicKey in publicKeys { - do { - if forceRotate { - _ = try await PrivatePaykitAddressReservationStore.shared.rotateAddress(for: publicKey) - } else { - _ = try await PrivatePaykitAddressReservationStore.shared.currentOrRotatedAddress(for: publicKey) - } - rotatedPublicKeys.append(publicKey) - } catch { - Logger.warn( - "Failed to rotate used private Paykit address for \(PubkyPublicKeyFormat.redacted(publicKey)): \(error)", - context: "PrivatePaykit" - ) - } - } - - guard !rotatedPublicKeys.isEmpty else { return } - await publishLocalEndpoints(for: rotatedPublicKeys, wallet: wallet, maxAdvanceSteps: 1, reason: reason) - } - - func publishLocalEndpointsBestEffort(to publicKey: String, linkId: String, wallet: WalletViewModel, - generation: UInt64, context: String, fetchedRemoteCount: Int) async throws - { - guard await canPublishPrivateEndpoints(wallet: wallet) else { return } - guard await shouldPublishLocalEndpoints(publicKey: publicKey, fetchedRemoteCount: fetchedRemoteCount) else { return } - guard !shouldDeferInitialLocalPublish(publicKey: publicKey, fetchedRemoteCount: fetchedRemoteCount) else { return } - - do { - try await publishLocalEndpoints(to: publicKey, linkId: linkId, wallet: wallet, generation: generation) - } catch { - try Task.checkCancellation() - Logger.warn( - "Failed to publish local private Paykit endpoints during \(context) for \(PubkyPublicKeyFormat.redacted(publicKey)); continuing with remote fetch: \(error)", - context: "PrivatePaykit" - ) - } - } - - func shouldPublishLocalEndpoints(publicKey: String, fetchedRemoteCount: Int) async -> Bool { - let contactState = state.contacts[publicKey] - if contactState?.lastLocalPayloadHash != nil { - return true - } - - if fetchedRemoteCount > 0 || contactState?.remoteEndpoints.isEmpty == false { - return true - } - - guard let ownPublicKey = await PubkyService.currentPublicKey() else { - return false - } - - return Self.shouldInitiate(ownPublicKey: ownPublicKey, remotePublicKey: publicKey) - } - - func shouldDeferInitialLocalPublish(publicKey: String, fetchedRemoteCount: Int) -> Bool { - guard fetchedRemoteCount == 0, - let contactState = state.contacts[publicKey], - contactState.lastLocalPayloadHash == nil, - contactState.remoteEndpoints.isEmpty, - let linkCompletedAt = contactState.linkCompletedAt - else { - return false - } - - let now = UInt64(Date().timeIntervalSince1970) - return now <= linkCompletedAt + Self.freshLinkInitialPublishDelaySeconds + guard !publicKeys.isEmpty else { return } + await refreshSavedContactEndpoints(for: publicKeys, wallet: wallet, forceRefreshLightning: false) } - func publishLocalEndpoints( - to publicKey: String, - linkId: String, + @MainActor + func buildLocalEndpoints( + for publicKey: String, wallet: WalletViewModel, - generation: UInt64, - force: Bool = false, forceRefreshLightning: Bool = false - ) async throws { - let normalizedKey = PubkyPublicKeyFormat.normalized(publicKey) ?? publicKey - let previousTask = publicationTasks[normalizedKey]?.task - let taskId = UUID() - let task = Task { [weak self] in - if let previousTask { - try? await previousTask.value - } - guard let self else { throw PrivatePaykitError.privateUnavailable } - try Task.checkCancellation() - try await publishLocalEndpointsUnlocked( - to: normalizedKey, - linkId: linkId, - wallet: wallet, - generation: generation, - force: force, - forceRefreshLightning: forceRefreshLightning - ) - } - publicationTasks[normalizedKey] = PublicationTask(id: taskId, task: task) - - do { - try await task.value - if publicationTasks[normalizedKey]?.id == taskId { - publicationTasks[normalizedKey] = nil - } - } catch { - if publicationTasks[normalizedKey]?.id == taskId { - publicationTasks[normalizedKey] = nil - } - throw error - } - } - - func publishLocalEndpointsUnlocked( - to publicKey: String, - linkId: String, - wallet: WalletViewModel, - generation: UInt64, - force: Bool, - forceRefreshLightning: Bool - ) async throws { - guard await canPublishPrivateEndpoints(wallet: wallet), - isKnownSavedContact(publicKey) - else { return } - try ensureCurrentGeneration(generation) - let endpoints: [PublicPaykitService.Endpoint] - do { - endpoints = try await buildLocalEndpoints( - for: publicKey, - wallet: wallet, - generation: generation, - forceRefreshLightning: forceRefreshLightning - ) - } catch let error as PrivatePaykitError { - guard case .privateUnavailable = error else { - throw error - } - try await publishLocalEndpointRemovalTombstone( - to: publicKey, - linkId: linkId, - wallet: wallet, - generation: generation, - force: force - ) - return - } - try ensureCurrentGeneration(generation) - guard !endpoints.isEmpty else { return } - - let entries = try entriesWithinNoiseLimit(from: endpoints, publicKey: publicKey) - let payloadHash = localPayloadHash(entries: entries) - guard force || state.contacts[publicKey]?.lastLocalPayloadHash != payloadHash else { - return - } - - try ensureCurrentGeneration(generation) - guard await canPublishPrivateEndpoints(wallet: wallet), - isKnownSavedContact(publicKey) - else { return } - - do { - try await PubkyService.setPrivatePayments(linkId: linkId, entries: entries) - try ensureCurrentGeneration(generation) - } catch { - await recordLinkFailure(publicKey: publicKey, error: error, generation: generation) - throw error - } - - try await persistLinkSnapshot(linkId: linkId, publicKey: publicKey, generation: generation) - state.contacts[publicKey, default: ContactState()].lastLocalPayloadHash = payloadHash - persistState() - } - - func publishLocalEndpointRemovalTombstone( - to publicKey: String, - linkId: String, - wallet: WalletViewModel, - generation: UInt64, - force: Bool - ) async throws { - guard shouldRequirePrivateEndpointRemoval(publicKey: publicKey) else { return } - - let entries = privateEndpointRemovalEntries() - try validateNoisePayload(entries: entries) - let payloadHash = localPayloadHash(entries: entries) - guard force || state.contacts[publicKey]?.lastLocalPayloadHash != payloadHash else { - return - } - - try ensureCurrentGeneration(generation) - guard await canPublishPrivateEndpoints(wallet: wallet), - isKnownSavedContact(publicKey) - else { return } - - do { - try await PubkyService.setPrivatePayments(linkId: linkId, entries: entries) - try ensureCurrentGeneration(generation) - } catch { - await recordLinkFailure(publicKey: publicKey, error: error, generation: generation) - throw error - } - - try await persistLinkSnapshot(linkId: linkId, publicKey: publicKey, generation: generation) - state.contacts[publicKey, default: ContactState()].lastLocalPayloadHash = payloadHash - persistState() - } - - func buildLocalEndpoints(for publicKey: String, wallet: WalletViewModel, - generation: UInt64, forceRefreshLightning: Bool = false) async throws -> [PublicPaykitService.Endpoint] - { + ) async throws -> [PublicPaykitService.Endpoint] { var endpoints: [PublicPaykitService.Endpoint] = [] + if PublicPaykitService.isOnchainPaymentOptionEnabled() { let reservedAddress = try await PrivatePaykitAddressReservationStore.shared.currentOrRotatedAddress(for: publicKey) - try ensureCurrentGeneration(generation) let onchainPayload = try PublicPaykitService.serializePayload(value: reservedAddress) endpoints.append( PublicPaykitService.Endpoint( @@ -316,15 +64,9 @@ extension PrivatePaykitService { ) } - if PublicPaykitService.isLightningPaymentOptionEnabled(), await walletHasUsableChannels(wallet) { + if PublicPaykitService.isLightningPaymentOptionEnabled(), walletHasUsableChannels(wallet) { do { - let invoice = try await currentOrRotatedInvoice( - for: publicKey, - wallet: wallet, - generation: generation, - forceRefresh: forceRefreshLightning - ) - try ensureCurrentGeneration(generation) + let invoice = try await currentOrRotatedInvoice(for: publicKey, wallet: wallet, forceRefresh: forceRefreshLightning) let invoicePayload = try PublicPaykitService.serializePayload(value: invoice.bolt11) endpoints.append( PublicPaykitService.Endpoint( @@ -335,77 +77,60 @@ extension PrivatePaykitService { rawPayload: invoicePayload ) ) - } catch { - try ensureCurrentGeneration(generation) - if let privateError = error as? PrivatePaykitError, - case .routeHintsUnavailable = privateError - { - schedulePendingPublicationRetry(for: publicKey, wallet: wallet) - } - Logger.warn( - "Failed to prepare private Paykit Lightning invoice for \(PubkyPublicKeyFormat.redacted(publicKey)); publishing on-chain only: \(error)", - context: "PrivatePaykit" - ) + } catch PrivatePaykitError.routeHintsUnavailable { + Logger.warn("Private Paykit Lightning invoice has no route hints; publishing on-chain endpoint only", context: "PrivatePaykit") } } guard !endpoints.isEmpty else { - throw PrivatePaykitError.privateUnavailable + throw PublicPaykitError.noSupportedEndpoint } return endpoints } - func validateNoisePayload(entries: [FfiPaymentEntry]) throws { - let map = Dictionary(uniqueKeysWithValues: entries.map { ($0.methodId, $0.endpointData) }) - guard Self.isNoisePayloadWithinLimit(map) else { - throw PrivatePaykitError.payloadTooLarge + func reservations(from endpoints: [PublicPaykitService.Endpoint], publicKey: String) -> [PaymentEndpointReservationInput] { + endpoints.map { endpoint in + let paymentHash = state.contacts[publicKey]?.localInvoice?.takeIfBolt11(endpoint)?.paymentHash + let attribution = [ + "type": "private_paykit", + "counterparty": publicKey, + ].merging(paymentHash.map { ["payment_hash": $0] } ?? [:]) { current, _ in current } + + return PaymentEndpointReservationInput( + reservationId: reservationId(for: endpoint, publicKey: publicKey), + identifier: endpoint.methodId.rawValue, + payload: endpoint.rawPayload, + expiresAt: endpoint.methodId == .bitcoinLightningBolt11 ? state.contacts[publicKey]?.localInvoice?.expiresAt.rfc3339Text : nil, + attribution: attribution + ) } } - func entriesWithinNoiseLimit(from endpoints: [PublicPaykitService.Endpoint], publicKey: String) throws -> [FfiPaymentEntry] { - let entries = endpoints.map { - FfiPaymentEntry(methodId: $0.methodId.rawValue, endpointData: $0.rawPayload) - } - - do { - try validateNoisePayload(entries: entries) - return entries - } catch let error as PrivatePaykitError { - guard case .payloadTooLarge = error else { - throw error - } - - let onchainOnlyEntries = entries.filter { $0.methodId != PublicPaykitService.MethodId.bitcoinLightningBolt11.rawValue } - guard onchainOnlyEntries.count < entries.count, !onchainOnlyEntries.isEmpty else { - throw error - } - - try validateNoisePayload(entries: onchainOnlyEntries) - Logger.warn( - "Private Paykit endpoint map is too large with Lightning invoice for \(PubkyPublicKeyFormat.redacted(publicKey)); publishing on-chain only.", - context: "PrivatePaykit" - ) - return onchainOnlyEntries - } + private func reservationId(for endpoint: PublicPaykitService.Endpoint, publicKey: String) -> String { + let payloadHashPrefix = SHA256.hash(data: Data(endpoint.rawPayload.utf8)) + .prefix(8) + .map { String(format: "%02x", $0) } + .joined() + return "\(publicKey):\(endpoint.methodId.rawValue):\(payloadHashPrefix)" } - func privateEndpointRemovalEntries() -> [FfiPaymentEntry] { - PublicPaykitService.MethodId.publishableMethodIds.map { - FfiPaymentEntry(methodId: $0.rawValue, endpointData: Self.privateEndpointRemovalPayload) - } + func cacheResolvedEndpoints(_ endpoints: [PublicPaykitService.Endpoint], publicKey: String) { + var contactState = state.contacts[publicKey, default: ContactState()] + contactState.cachedResolvedEndpoints = endpoints.map(StoredPaymentEntry.init(endpoint:)) + state.contacts[publicKey] = contactState + persistState(markWalletBackup: true) } +} - func localPayloadHash(entries: [FfiPaymentEntry]) -> String { - let payload = entries - .sorted { $0.methodId < $1.methodId } - .map { entry in - "\(entry.methodId.count):\(entry.methodId)\(entry.endpointData.count):\(entry.endpointData)" - } - .joined() +private extension PrivatePaykitService.StoredInvoice { + func takeIfBolt11(_ endpoint: PublicPaykitService.Endpoint) -> Self? { + endpoint.methodId == .bitcoinLightningBolt11 && bolt11 == endpoint.value ? self : nil + } +} - return SHA256.hash(data: Data(payload.utf8)) - .map { String(format: "%02x", $0) } - .joined() +private extension Double { + var rfc3339Text: String { + ISO8601DateFormatter().string(from: Date(timeIntervalSince1970: self)) } } diff --git a/Bitkit/Services/PrivatePaykitService+Errors.swift b/Bitkit/Services/PrivatePaykitService+Errors.swift index fd9cfbf47..cea9e05dc 100644 --- a/Bitkit/Services/PrivatePaykitService+Errors.swift +++ b/Bitkit/Services/PrivatePaykitService+Errors.swift @@ -1,35 +1,26 @@ import Foundation import LDKNode -import Paykit enum PrivatePaykitError: LocalizedError { case privateUnavailable - case payloadTooLarge - case staleLinkState case routeHintsUnavailable var errorDescription: String? { switch self { case .privateUnavailable: "Private Paykit is not available." - case .payloadTooLarge: - "The private Paykit payload is too large." - case .staleLinkState: - "The private Paykit link state changed." case .routeHintsUnavailable: "A reachable private Lightning endpoint is not available yet." } } } -// MARK: - Error Classification +// MARK: - Error Helpers extension PrivatePaykitService { static func isDuplicatePaymentError(_ error: Error) -> Bool { - if let nodeError = error as? NodeError { - if case .DuplicatePayment = nodeError { - return true - } + if let nodeError = error as? NodeError, case .DuplicatePayment = nodeError { + return true } let reason: String = if let appError = error as? AppError { @@ -43,70 +34,4 @@ extension PrivatePaykitService { let lowercasedReason = reason.lowercased() return lowercasedReason.contains("duplicate payment") || lowercasedReason.contains("duplicatepayment") } - - func shouldCountAsStaleLinkFailure(_ error: Error) -> Bool { - if let paykitError = error as? PaykitFfiError { - switch paykitError { - case let .Transport(reason): - return isNoiseStateFailure(reason) || isEncryptedLinkStateFailure(reason) - case let .InvalidData(reason), let .NotFound(reason), let .Validation(reason): - return isEncryptedLinkStateFailure(reason) - case .Session: - return false - } - } - - let wrappedReason = staleLinkFailureReason(from: error) - return isNoiseStateFailure(wrappedReason) || isEncryptedLinkStateFailure(wrappedReason) - } - - func staleLinkFailureReason(from error: Error) -> String { - if let appError = error as? AppError { - return [appError.message, appError.debugMessage] - .compactMap { $0 } - .joined(separator: " ") - } - - return error.localizedDescription - } - - func isNoiseStateFailure(_ reason: String) -> Bool { - let lowercasedReason = reason.lowercased() - return [ - "decrypt", - "decryption", - "cipher", - "invalid tag", - "bad mac", - ].contains { lowercasedReason.contains($0) } - } - - func isEncryptedLinkStateFailure(_ reason: String) -> Bool { - let lowercasedReason = reason.lowercased() - return [ - "unknown encrypted-link handle", - "unknown encrypted link handle", - "encrypted-link handle is closed", - "encrypted link handle is closed", - "failed to restore encrypted link", - "encrypted link restore requires transport-phase snapshot", - "remote_pubkey does not match snapshot recipient", - ].contains { lowercasedReason.contains($0) } - } - - func isEncryptedHandshakeStateFailure(_ error: Error) -> Bool { - let lowercasedReason = staleLinkFailureReason(from: error).lowercased() - return isNoiseStateFailure(lowercasedReason) || - isEncryptedLinkStateFailure(lowercasedReason) || - [ - "restoreplayerror", - "handshake restore failed", - ].contains { lowercasedReason.contains($0) } - } - - func isEncryptedHandshakePendingError(_ error: Error) -> Bool { - let lowercasedReason = staleLinkFailureReason(from: error).lowercased() - return lowercasedReason.contains("transition_transport failed") && - lowercasedReason.contains("ishandshake") - } } diff --git a/Bitkit/Services/PrivatePaykitService+Invoices.swift b/Bitkit/Services/PrivatePaykitService+Invoices.swift index a0caae8fb..7fa638656 100644 --- a/Bitkit/Services/PrivatePaykitService+Invoices.swift +++ b/Bitkit/Services/PrivatePaykitService+Invoices.swift @@ -6,19 +6,16 @@ import UIKit // MARK: - Invoice Rotation extension PrivatePaykitService { - func currentOrRotatedInvoice(for publicKey: String, wallet: WalletViewModel, generation: UInt64, - forceRefresh: Bool = false) async throws -> StoredInvoice - { + func currentOrRotatedInvoice( + for publicKey: String, + wallet: WalletViewModel, + forceRefresh: Bool = false + ) async throws -> StoredInvoice { if !forceRefresh, let invoice = await reusablePrivateInvoice(for: publicKey) { return invoice } let bolt11 = try await createVariableInvoice(wallet) - try ensureCurrentGeneration(generation) - if !forceRefresh, let invoice = await reusablePrivateInvoice(for: publicKey) { - return invoice - } - guard case let .lightning(decodedInvoice) = try await decode(invoice: bolt11) else { throw PublicPaykitError.invalidPayload } @@ -44,9 +41,9 @@ extension PrivatePaykitService { contactState.receivedInvoicePaymentHashes.append(paymentHash) if contactState.receivedInvoicePaymentHashes.count > Self.maxReceivedInvoicePaymentHashesPerContact { - contactState - .receivedInvoicePaymentHashes = Array(contactState.receivedInvoicePaymentHashes - .suffix(Self.maxReceivedInvoicePaymentHashesPerContact)) + contactState.receivedInvoicePaymentHashes = Array( + contactState.receivedInvoicePaymentHashes.suffix(Self.maxReceivedInvoicePaymentHashesPerContact) + ) } state.contacts[publicKey] = contactState persistState() @@ -84,16 +81,6 @@ extension PrivatePaykitService { wallet.hasUsableChannels } - func shouldRetryMissingPrivateLightningEndpoint(for publicKey: String, wallet: WalletViewModel) async -> Bool { - guard PublicPaykitService.isLightningPaymentOptionEnabled(), - await walletHasUsableChannels(wallet) - else { - return false - } - - return await reusablePrivateInvoice(for: publicKey) == nil - } - @MainActor func canPublishPrivateEndpoints(wallet: WalletViewModel) async -> Bool { guard PaykitFeatureFlags.isUIEnabled, diff --git a/Bitkit/Services/PrivatePaykitService+Links.swift b/Bitkit/Services/PrivatePaykitService+Links.swift deleted file mode 100644 index 05b16c444..000000000 --- a/Bitkit/Services/PrivatePaykitService+Links.swift +++ /dev/null @@ -1,620 +0,0 @@ -import CryptoKit -import Foundation -import Paykit - -// MARK: - Link Lifecycle - -extension PrivatePaykitService { - func establishedLinkId(for publicKey: String, maxAdvanceSteps: Int, generation: UInt64) async throws -> String? { - guard let normalizedKey = PubkyPublicKeyFormat.normalized(publicKey) else { - throw PrivatePaykitError.privateUnavailable - } - - while true { - try Task.checkCancellation() - if let inFlight = linkEstablishmentTasks[normalizedKey] { - do { - let linkId = try await inFlight.task.value - if linkEstablishmentTasks[normalizedKey]?.id == inFlight.id { - linkEstablishmentTasks[normalizedKey] = nil - } - if linkId != nil || inFlight.maxAdvanceSteps >= maxAdvanceSteps { - return linkId - } - - continue - } catch { - if linkEstablishmentTasks[normalizedKey]?.id == inFlight.id { - linkEstablishmentTasks[normalizedKey] = nil - } - throw error - } - } - - let taskId = UUID() - let task = Task { [weak self] in - guard let self else { throw PrivatePaykitError.privateUnavailable } - try Task.checkCancellation() - return try await establishedLinkIdUnlocked(for: normalizedKey, maxAdvanceSteps: maxAdvanceSteps, generation: generation) - } - linkEstablishmentTasks[normalizedKey] = LinkEstablishmentTask(id: taskId, maxAdvanceSteps: maxAdvanceSteps, task: task) - - do { - let linkId = try await task.value - if linkEstablishmentTasks[normalizedKey]?.id == taskId { - linkEstablishmentTasks[normalizedKey] = nil - } - return linkId - } catch { - if linkEstablishmentTasks[normalizedKey]?.id == taskId { - linkEstablishmentTasks[normalizedKey] = nil - } - throw error - } - } - } - - func establishedLinkIdUnlocked(for normalizedKey: String, maxAdvanceSteps: Int, generation: UInt64) async throws -> String? { - try ensureCurrentGeneration(generation) - guard - let secretKeyHex = try Keychain.loadString(key: .pubkySecretKey), - !secretKeyHex.isEmpty, - let ownPublicKeyRaw = await PubkyService.currentPublicKey(), - let ownPublicKey = PubkyPublicKeyFormat.normalized(ownPublicKeyRaw) - else { - throw PrivatePaykitError.privateUnavailable - } - - if let linkId = activeHandlesByContact[normalizedKey]?.linkId { - if let remoteRecoveryMarker = await freshRecoveryMarker(from: normalizedKey, to: ownPublicKey, stages: [Self.recoveryMarkerStageInit]) { - if shouldReplaceUsableLink(with: remoteRecoveryMarker, publicKey: normalizedKey) { - guard await discardLinkForRecovery(publicKey: normalizedKey, linkId: linkId, startedAt: remoteRecoveryMarker.createdAt) else { - return nil - } - try ensureCurrentGeneration(generation) - } else { - try ensureCurrentGeneration(generation) - return linkId - } - } else { - try ensureCurrentGeneration(generation) - return linkId - } - } - - if let linkId = activeHandlesByContact[normalizedKey]?.linkId { - try ensureCurrentGeneration(generation) - return linkId - } - - if let snapshotHex = state.contacts[normalizedKey]?.linkSnapshotHex { - do { - try await validateSnapshot(snapshotHex, publicKey: normalizedKey, recipient: PubkyService.encryptedLinkSnapshotRecipient) - let linkId = try await PubkyService.restoreEncryptedLink(secretKeyHex: secretKeyHex, snapshotHex: snapshotHex) - try ensureCurrentGeneration(generation) - activeHandlesByContact[normalizedKey] = ContactPaykitHandles(linkId: linkId, handshakeId: nil) - if let remoteRecoveryMarker = await freshRecoveryMarker( - from: normalizedKey, - to: ownPublicKey, - stages: [Self.recoveryMarkerStageInit] - ) { - if shouldReplaceUsableLink(with: remoteRecoveryMarker, publicKey: normalizedKey) { - guard await discardLinkForRecovery(publicKey: normalizedKey, linkId: linkId, startedAt: remoteRecoveryMarker.createdAt) else { - return nil - } - try ensureCurrentGeneration(generation) - } else { - try ensureCurrentGeneration(generation) - return linkId - } - } else { - try ensureCurrentGeneration(generation) - return linkId - } - } catch { - try ensureCurrentGeneration(generation) - Logger.warn("Failed to restore private Paykit link, restarting handshake: \(error)", context: "PrivatePaykit") - state.contacts[normalizedKey]?.linkSnapshotHex = nil - state.contacts[normalizedKey]?.handshakeSnapshotHex = nil - state.contacts[normalizedKey]?.lastLocalPayloadHash = nil - state.contacts[normalizedKey]?.mainRecoveryAttemptId = nil - state.contacts[normalizedKey]?.responderRecoveryAttemptId = nil - persistState(markWalletBackup: true) - } - } - - let isRecovering = await shouldStartRecoveryHandshake(publicKey: normalizedKey) - let remoteRecoveryInitMarker = await freshRecoveryMarker(from: normalizedKey, to: ownPublicKey, stages: [Self.recoveryMarkerStageInit]) - .flatMap { isCompletedRecoveryMarker($0, publicKey: normalizedKey) ? nil : $0 } - let remoteRecoveryFinalForResponder: RecoveryMarker? = if let responderAttemptId = state.contacts[normalizedKey]?.responderRecoveryAttemptId { - await freshRecoveryMarker( - from: normalizedKey, - to: ownPublicKey, - stages: [Self.recoveryMarkerStageFinal], - attemptId: responderAttemptId - ) - } else { - nil - } - let remoteRecoveryMarker = remoteRecoveryInitMarker ?? remoteRecoveryFinalForResponder - - let initialMainRecoveryAttemptId = state.contacts[normalizedKey]?.mainRecoveryAttemptId - let localMainRecoveryMarker: RecoveryMarker? = if let mainRecoveryAttemptId = initialMainRecoveryAttemptId { - await freshRecoveryMarker( - from: ownPublicKey, - to: normalizedKey, - stages: [Self.recoveryMarkerStageInit, Self.recoveryMarkerStageFinal], - attemptId: mainRecoveryAttemptId - ) - } else { - nil - } - - let shouldAcceptRemoteRecovery = if remoteRecoveryFinalForResponder != nil { - true - } else { - remoteRecoveryMarker.map { - shouldAcceptRemoteRecoveryMarker( - remoteMarker: $0, - localMarker: localMainRecoveryMarker, - ownPublicKey: ownPublicKey, - remotePublicKey: normalizedKey - ) - } ?? false - } - - if shouldAcceptRemoteRecovery, let remoteRecoveryMarker { - let isNewResponderAttempt = state.contacts[normalizedKey]?.responderRecoveryAttemptId != remoteRecoveryMarker.attemptId - if isNewResponderAttempt { - guard await purgePrivatePaymentOutbox(for: normalizedKey, reason: "recovery responder") else { - return nil - } - try ensureCurrentGeneration(generation) - - if let handshakeId = activeHandlesByContact[normalizedKey]?.handshakeId { - try? await PubkyService.dropEncryptedLinkHandshake(handshakeId: handshakeId) - } - - var handles = activeHandlesByContact[normalizedKey, default: ContactPaykitHandles()] - handles.linkId = nil - handles.handshakeId = nil - activeHandlesByContact[normalizedKey] = handles - - state.contacts[normalizedKey, default: ContactState()].handshakeSnapshotHex = nil - state.contacts[normalizedKey]?.mainRecoveryAttemptId = nil - state.contacts[normalizedKey]?.responderRecoveryAttemptId = remoteRecoveryMarker.attemptId - state.contacts[normalizedKey]?.recoveryStartedAt = remoteRecoveryMarker.createdAt - state.contacts[normalizedKey]?.lastLocalPayloadHash = nil - state.contacts[normalizedKey]?.remoteEndpoints = [] - state.contacts[normalizedKey]?.awaitingRecoveredRemoteEndpoints = false - persistState(markWalletBackup: true) - } - - await publishRecoveryMarker( - from: ownPublicKey, - to: normalizedKey, - stage: Self.recoveryMarkerStageResponse, - attemptId: remoteRecoveryMarker.attemptId, - createdAt: UInt64(Date().timeIntervalSince1970) - ) - } - - let shouldInitiateRecovery = isRecovering && !shouldAcceptRemoteRecovery - if shouldInitiateRecovery, state.contacts[normalizedKey]?.mainRecoveryAttemptId == nil { - guard await purgePrivatePaymentOutbox(for: normalizedKey, reason: "recovery initiator") else { - return nil - } - try ensureCurrentGeneration(generation) - - if let handshakeId = activeHandlesByContact[normalizedKey]?.handshakeId { - try? await PubkyService.dropEncryptedLinkHandshake(handshakeId: handshakeId) - } - - var handles = activeHandlesByContact[normalizedKey, default: ContactPaykitHandles()] - handles.linkId = nil - handles.handshakeId = nil - activeHandlesByContact[normalizedKey] = handles - - let attemptId = UUID().uuidString - let createdAt = UInt64(Date().timeIntervalSince1970) - state.contacts[normalizedKey, default: ContactState()].handshakeSnapshotHex = nil - state.contacts[normalizedKey]?.mainRecoveryAttemptId = attemptId - state.contacts[normalizedKey]?.responderRecoveryAttemptId = nil - state.contacts[normalizedKey]?.recoveryStartedAt = createdAt - state.contacts[normalizedKey]?.lastLocalPayloadHash = nil - state.contacts[normalizedKey]?.remoteEndpoints = [] - state.contacts[normalizedKey]?.awaitingRecoveredRemoteEndpoints = false - persistState(markWalletBackup: true) - - await publishRecoveryMarker( - from: ownPublicKey, - to: normalizedKey, - stage: Self.recoveryMarkerStageInit, - attemptId: attemptId, - createdAt: createdAt - ) - } - - if shouldInitiateRecovery, - initialMainRecoveryAttemptId != nil, - let mainRecoveryAttemptId = state.contacts[normalizedKey]?.mainRecoveryAttemptId, - localMainRecoveryMarker == nil - { - await publishRecoveryMarker( - from: ownPublicKey, - to: normalizedKey, - stage: Self.recoveryMarkerStageInit, - attemptId: mainRecoveryAttemptId, - createdAt: UInt64(Date().timeIntervalSince1970) - ) - } - - if isRecovering, !shouldAcceptRemoteRecovery, - state.contacts[normalizedKey]?.responderRecoveryAttemptId != nil - { - state.contacts[normalizedKey]?.responderRecoveryAttemptId = nil - persistState(markWalletBackup: true) - } - - if shouldInitiateRecovery, - let attemptId = state.contacts[normalizedKey]?.mainRecoveryAttemptId, - state.contacts[normalizedKey]?.handshakeSnapshotHex != nil - { - let hasPeerProgress = await freshRecoveryMarker( - from: normalizedKey, - to: ownPublicKey, - stages: [Self.recoveryMarkerStageResponse, Self.recoveryMarkerStageFinal], - attemptId: attemptId - ) != nil - guard hasPeerProgress else { - return nil - } - } - - if shouldAcceptRemoteRecovery, - let attemptId = state.contacts[normalizedKey]?.responderRecoveryAttemptId, - state.contacts[normalizedKey]?.handshakeSnapshotHex != nil - { - let hasPeerFinal = await freshRecoveryMarker( - from: normalizedKey, - to: ownPublicKey, - stages: [Self.recoveryMarkerStageFinal], - attemptId: attemptId - ) != nil - guard hasPeerFinal else { - await publishRecoveryMarker( - from: ownPublicKey, - to: normalizedKey, - stage: Self.recoveryMarkerStageResponse, - attemptId: attemptId, - createdAt: UInt64(Date().timeIntervalSince1970) - ) - return nil - } - } - - var handshakeId = activeHandlesByContact[normalizedKey]?.handshakeId - - if handshakeId == nil, let snapshotHex = state.contacts[normalizedKey]?.handshakeSnapshotHex { - do { - try await validateSnapshot(snapshotHex, publicKey: normalizedKey, recipient: PubkyService.encryptedLinkHandshakeSnapshotRecipient) - handshakeId = try await PubkyService.restoreEncryptedLinkHandshake(secretKeyHex: secretKeyHex, snapshotHex: snapshotHex) - } catch { - try ensureCurrentGeneration(generation) - Logger.warn("Failed to restore private Paykit handshake, restarting: \(error)", context: "PrivatePaykit") - state.contacts[normalizedKey]?.handshakeSnapshotHex = nil - state.contacts[normalizedKey]?.mainRecoveryAttemptId = nil - persistState(markWalletBackup: true) - } - } - - if handshakeId == nil { - let shouldInitiate = shouldInitiateRecovery || (!shouldAcceptRemoteRecovery && Self.shouldInitiate( - ownPublicKey: ownPublicKey, - remotePublicKey: normalizedKey - )) - if shouldInitiate { - handshakeId = try await PubkyService.initiateEncryptedLink(secretKeyHex: secretKeyHex, receiverPublicKey: normalizedKey) - try ensureCurrentGeneration(generation) - if isRecovering { - state.contacts[normalizedKey, default: ContactState()].recoveryStartedAt = UInt64(Date().timeIntervalSince1970) - persistState(markWalletBackup: true) - } - } else { - handshakeId = try await PubkyService.acceptEncryptedLink(secretKeyHex: secretKeyHex, senderPublicKey: normalizedKey) - } - } - - let isRecoveryHandshake = shouldInitiateRecovery || shouldAcceptRemoteRecovery - guard var handshakeId else { return nil } - try ensureCurrentGeneration(generation) - var handles = activeHandlesByContact[normalizedKey, default: ContactPaykitHandles()] - handles.linkId = nil - handles.handshakeId = handshakeId - activeHandlesByContact[normalizedKey] = handles - - for _ in 0 ..< maxAdvanceSteps { - let progress: FfiHandshakeProgress - do { - progress = try await PubkyService.advanceHandshake(handshakeId: handshakeId) - } catch { - try ensureCurrentGeneration(generation) - if isEncryptedHandshakePendingError(error) { - let snapshotHex = try await PubkyService.serializeEncryptedLinkHandshake(handshakeId: handshakeId) - try ensureCurrentGeneration(generation) - state.contacts[normalizedKey, default: ContactState()].handshakeSnapshotHex = snapshotHex - state.contacts[normalizedKey]?.handshakeUpdatedAt = UInt64(Date().timeIntervalSince1970) - persistState(markWalletBackup: true) - return nil - } - if isEncryptedHandshakeStateFailure(error) { - activeHandlesByContact[normalizedKey]?.handshakeId = nil - state.contacts[normalizedKey]?.handshakeSnapshotHex = nil - state.contacts[normalizedKey]?.mainRecoveryAttemptId = nil - persistState(markWalletBackup: true) - } - throw error - } - try ensureCurrentGeneration(generation) - if progress.status == "complete" { - let linkId = progress.handleId - let attemptId = state.contacts[normalizedKey]?.mainRecoveryAttemptId ?? state.contacts[normalizedKey]?.responderRecoveryAttemptId - activeHandlesByContact[normalizedKey] = ContactPaykitHandles(linkId: linkId, handshakeId: nil) - state.contacts[normalizedKey, default: ContactState()].handshakeSnapshotHex = nil - state.contacts[normalizedKey]?.recoveryStartedAt = nil - try await persistLinkSnapshot(linkId: linkId, publicKey: normalizedKey, generation: generation, linkWasReplaced: true) - if isRecoveryHandshake, let attemptId { - await publishRecoveryMarker( - from: ownPublicKey, - to: normalizedKey, - stage: Self.recoveryMarkerStageFinal, - attemptId: attemptId, - createdAt: UInt64(Date().timeIntervalSince1970) - ) - } - return linkId - } - - handshakeId = progress.handleId - handles = activeHandlesByContact[normalizedKey, default: ContactPaykitHandles()] - handles.linkId = nil - handles.handshakeId = handshakeId - activeHandlesByContact[normalizedKey] = handles - let snapshotHex = try await PubkyService.serializeEncryptedLinkHandshake(handshakeId: handshakeId) - try ensureCurrentGeneration(generation) - state.contacts[normalizedKey, default: ContactState()].handshakeSnapshotHex = snapshotHex - state.contacts[normalizedKey]?.handshakeUpdatedAt = UInt64(Date().timeIntervalSince1970) - persistState(markWalletBackup: true) - - if isRecoveryHandshake { - let createdAt = UInt64(Date().timeIntervalSince1970) - if shouldInitiateRecovery, let attemptId = state.contacts[normalizedKey]?.mainRecoveryAttemptId { - await publishRecoveryMarker( - from: ownPublicKey, - to: normalizedKey, - stage: Self.recoveryMarkerStageInit, - attemptId: attemptId, - createdAt: createdAt - ) - } else if shouldAcceptRemoteRecovery, let attemptId = state.contacts[normalizedKey]?.responderRecoveryAttemptId { - await publishRecoveryMarker( - from: ownPublicKey, - to: normalizedKey, - stage: Self.recoveryMarkerStageResponse, - attemptId: attemptId, - createdAt: createdAt - ) - } - return nil - } - } - - return nil - } - - func shouldStartRecoveryHandshake(publicKey: String) async -> Bool { - guard let contactState = state.contacts[publicKey], - contactState.linkSnapshotHex == nil - else { - return false - } - - if contactState.recoveryStartedAt != nil || contactState.mainRecoveryAttemptId != nil { - return true - } - - guard contactState.handshakeSnapshotHex == nil else { - return false - } - - if contactState.linkCompletedAt != nil || contactState.handshakeUpdatedAt != nil { - return true - } - - return await PrivatePaykitAddressReservationStore.shared.hasContactAssignment(for: publicKey) - } - - func discardLinkForRecovery(publicKey: String, linkId: String?, startedAt: UInt64) async -> Bool { - if let linkId { - try? await PubkyService.closeEncryptedLink(linkId: linkId) - } - - var handles = activeHandlesByContact[publicKey, default: ContactPaykitHandles()] - handles.linkId = nil - handles.handshakeId = nil - activeHandlesByContact[publicKey] = handles - state.contacts[publicKey]?.linkSnapshotHex = nil - state.contacts[publicKey]?.handshakeSnapshotHex = nil - state.contacts[publicKey]?.lastLocalPayloadHash = nil - state.contacts[publicKey]?.remoteEndpoints = [] - state.contacts[publicKey]?.recoveryStartedAt = startedAt - state.contacts[publicKey]?.mainRecoveryAttemptId = nil - state.contacts[publicKey]?.responderRecoveryAttemptId = nil - state.contacts[publicKey]?.awaitingRecoveredRemoteEndpoints = false - persistState(markWalletBackup: true) - return true - } - - func shouldAcceptRemoteRecoveryMarker(remoteMarker: RecoveryMarker, localMarker: RecoveryMarker?, - ownPublicKey: String, remotePublicKey: String) -> Bool - { - guard let localMarker else { return true } - - if remoteMarker.createdAt != localMarker.createdAt { - return remoteMarker.createdAt < localMarker.createdAt - } - - if remoteMarker.attemptId != localMarker.attemptId { - return remoteMarker.attemptId < localMarker.attemptId - } - - return remotePublicKey < ownPublicKey - } - - func isCompletedRecoveryMarker(_ marker: RecoveryMarker, publicKey: String) -> Bool { - state.contacts[publicKey]?.lastCompletedRecoveryAttemptId == marker.attemptId - } - - func shouldReplaceUsableLink(with marker: RecoveryMarker, publicKey: String) -> Bool { - guard !isCompletedRecoveryMarker(marker, publicKey: publicKey) else { - return false - } - - guard let linkCompletedAt = state.contacts[publicKey]?.linkCompletedAt else { - return true - } - - return marker.createdAt > linkCompletedAt - } - - func validateSnapshot( - _ snapshotHex: String, - publicKey: String, - recipient: (String) async throws -> String - ) async throws { - let snapshotRecipient = try await recipient(snapshotHex) - guard PubkyPublicKeyFormat.normalized(snapshotRecipient) == PubkyPublicKeyFormat.normalized(publicKey) else { - throw PrivatePaykitError.privateUnavailable - } - } - - static func recoveryMarkerPath(from writerPublicKey: String, to readerPublicKey: String) -> String? { - guard let writerPublicKey = PubkyPublicKeyFormat.normalized(writerPublicKey), - let readerPublicKey = PubkyPublicKeyFormat.normalized(readerPublicKey) - else { return nil } - - let material = "bitkit-private-paykit-recovery-v1|\(writerPublicKey)|\(readerPublicKey)" - let markerId = SHA256.hash(data: Data(material.utf8)) - .map { String(format: "%02x", $0) } - .joined() - return "/pub/paykit/v0/private-recovery/\(markerId).json" - } - - func freshRecoveryMarker(from writerPublicKey: String, to readerPublicKey: String, stages: Set, - attemptId: String? = nil) async -> RecoveryMarker? - { - guard let markerUri = Self.recoveryMarkerUri(from: writerPublicKey, to: readerPublicKey), - let markerPath = Self.recoveryMarkerPath(from: writerPublicKey, to: readerPublicKey), - let payload = try? await PubkyService.fetchFileString(uri: markerUri), - let data = payload.data(using: .utf8), - let marker = try? JSONDecoder().decode(RecoveryMarker.self, from: data), - marker.version == 1, - marker.path == markerPath, - stages.contains(marker.stage), - !marker.attemptId.isEmpty - else { - return nil - } - - let contactKey = [writerPublicKey, readerPublicKey] - .compactMap(PubkyPublicKeyFormat.normalized) - .first { state.contacts[$0] != nil } - let linkCompletedAt = contactKey.flatMap { state.contacts[$0]?.linkCompletedAt } ?? 0 - guard marker.createdAt > linkCompletedAt else { - return nil - } - - if let attemptId, marker.attemptId != attemptId { - return nil - } - - return marker - } - - func publishRecoveryMarker(from writerPublicKey: String, to readerPublicKey: String, stage: String, attemptId: String, createdAt: UInt64) async { - guard let markerPath = Self.recoveryMarkerPath(from: writerPublicKey, to: readerPublicKey), - let sessionSecret = try? Keychain.loadString(key: .paykitSession), - !sessionSecret.isEmpty, - !attemptId.isEmpty - else { return } - - let marker = RecoveryMarker(version: 1, path: markerPath, stage: stage, attemptId: attemptId, createdAt: createdAt) - do { - let data = try JSONEncoder().encode(marker) - try await PubkyService.sessionPut(sessionSecret: sessionSecret, path: markerPath, content: data) - } catch { - Logger.warn( - "Failed to publish private Paykit recovery marker for \(PubkyPublicKeyFormat.redacted(readerPublicKey)): \(error)", - context: "PrivatePaykit" - ) - } - } - - func clearRecoveryMarker(from writerPublicKey: String, to readerPublicKey: String) async { - guard let markerPath = Self.recoveryMarkerPath(from: writerPublicKey, to: readerPublicKey), - let sessionSecret = try? Keychain.loadString(key: .paykitSession), - !sessionSecret.isEmpty - else { return } - - try? await PubkyService.sessionDelete(sessionSecret: sessionSecret, path: markerPath) - } - - private static func recoveryMarkerUri(from writerPublicKey: String, to readerPublicKey: String) -> String? { - guard let writerPublicKey = PubkyPublicKeyFormat.normalized(writerPublicKey), - let path = recoveryMarkerPath(from: writerPublicKey, to: readerPublicKey) - else { return nil } - - return "pubky://\(writerPublicKey.dropFirst("pubky".count))\(path)" - } - - @discardableResult - func existingLinkId(for publicKey: String, generation: UInt64) async throws -> String? { - try ensureCurrentGeneration(generation) - if let linkId = activeHandlesByContact[publicKey]?.linkId { - return linkId - } - - guard let snapshotHex = state.contacts[publicKey]?.linkSnapshotHex, - let secretKeyHex = try Keychain.loadString(key: .pubkySecretKey), - !secretKeyHex.isEmpty - else { - return nil - } - - let linkId = try await PubkyService.restoreEncryptedLink(secretKeyHex: secretKeyHex, snapshotHex: snapshotHex) - try ensureCurrentGeneration(generation) - activeHandlesByContact[publicKey] = ContactPaykitHandles(linkId: linkId, handshakeId: nil) - return linkId - } - - func restoreLinkHandleForReadRetry(publicKey: String, generation: UInt64) async throws -> String? { - try ensureCurrentGeneration(generation) - guard let snapshotHex = state.contacts[publicKey]?.linkSnapshotHex, - let secretKeyHex = try Keychain.loadString(key: .pubkySecretKey), - !secretKeyHex.isEmpty - else { - return nil - } - - if let linkId = activeHandlesByContact[publicKey]?.linkId { - try? await PubkyService.closeEncryptedLink(linkId: linkId) - } - activeHandlesByContact[publicKey]?.linkId = nil - - try ensureCurrentGeneration(generation) - let restoredLinkId = try await PubkyService.restoreEncryptedLink(secretKeyHex: secretKeyHex, snapshotHex: snapshotHex) - try ensureCurrentGeneration(generation) - activeHandlesByContact[publicKey] = ContactPaykitHandles(linkId: restoredLinkId, handshakeId: nil) - return restoredLinkId - } -} diff --git a/Bitkit/Services/PrivatePaykitService+Models.swift b/Bitkit/Services/PrivatePaykitService+Models.swift index 29fa1c8bf..bc0bbbdf3 100644 --- a/Bitkit/Services/PrivatePaykitService+Models.swift +++ b/Bitkit/Services/PrivatePaykitService+Models.swift @@ -1,192 +1,25 @@ import Foundation -import Paykit // MARK: - State Models extension PrivatePaykitService { - struct ContactPaykitHandles { - var linkId: String? - var handshakeId: String? - } - - struct LinkEstablishmentTask { - var id: UUID - var maxAdvanceSteps: Int - var task: Task - } - - struct PublicationTask { - var id: UUID - var task: Task - } - - struct PrivateStoragePurgeResult { - var deletedCount: Int - var didHitLimit: Bool - var didFail: Bool - } - - struct PrivatePaymentAttempt { - var result: Result - var shouldDeferPublicFallback: Bool - } - - struct PrivatePaykitState { + struct PrivatePaykitState: Codable { var contacts: [String: ContactState] - - init(contacts: [String: ContactState]) { - self.contacts = contacts - } - - init(secretState: PrivatePaykitSecretState, cacheState: PrivatePaykitCacheState) { - var contacts = cacheState.contacts.mapValues(ContactState.init(cacheState:)) - - for (publicKey, secretState) in secretState.contacts { - var contactState = contacts[publicKey, default: ContactState()] - contactState.linkSnapshotHex = secretState.linkSnapshotHex - contactState.handshakeSnapshotHex = secretState.handshakeSnapshotHex - contacts[publicKey] = contactState - } - - self.contacts = contacts - } - - var secretState: PrivatePaykitSecretState { - PrivatePaykitSecretState( - contacts: contacts.compactMapValues { contactState in - let secretState = ContactSecretState(contactState: contactState) - return secretState.hasSecretState ? secretState : nil - } - ) - } - - var cacheState: PrivatePaykitCacheState { - PrivatePaykitCacheState( - contacts: contacts.compactMapValues { contactState in - let cacheState = ContactCacheState(contactState: contactState) - return cacheState.hasCacheState ? cacheState : nil - } - ) - } - } - - struct PrivatePaykitSecretState: Codable { - var contacts: [String: ContactSecretState] - } - - struct PrivatePaykitCacheState: Codable { - var contacts: [String: ContactCacheState] } struct ContactState: Codable { - var linkSnapshotHex: String? - var handshakeSnapshotHex: String? - var remoteEndpoints: [StoredPaymentEntry] = [] + var cachedResolvedEndpoints: [StoredPaymentEntry] = [] var localInvoice: StoredInvoice? var receivedInvoicePaymentHashes: [String] = [] - var lastLocalPayloadHash: String? - var linkCompletedAt: UInt64? - var handshakeUpdatedAt: UInt64? - var recoveryStartedAt: UInt64? - var mainRecoveryAttemptId: String? - var responderRecoveryAttemptId: String? - var lastCompletedRecoveryAttemptId: String? - var awaitingRecoveredRemoteEndpoints = false - var linkFailureCount: Int = 0 + var hasPublishedPrivatePaymentList = false init() {} - init(cacheState: ContactCacheState) { - remoteEndpoints = cacheState.remoteEndpoints - localInvoice = cacheState.localInvoice - receivedInvoicePaymentHashes = cacheState.receivedInvoicePaymentHashes - lastLocalPayloadHash = cacheState.lastLocalPayloadHash - linkCompletedAt = cacheState.linkCompletedAt - handshakeUpdatedAt = cacheState.handshakeUpdatedAt - recoveryStartedAt = cacheState.recoveryStartedAt - mainRecoveryAttemptId = cacheState.mainRecoveryAttemptId - responderRecoveryAttemptId = cacheState.responderRecoveryAttemptId - lastCompletedRecoveryAttemptId = cacheState.lastCompletedRecoveryAttemptId - awaitingRecoveredRemoteEndpoints = cacheState.awaitingRecoveredRemoteEndpoints == true - linkFailureCount = cacheState.linkFailureCount - } - - var remoteEndpointMap: [String: String] { - remoteEndpoints.reduce(into: [:]) { map, entry in - map[entry.methodId] = entry.endpointData - } - } - - var hasBackupState: Bool { - linkSnapshotHex != nil || - handshakeSnapshotHex != nil || - !remoteEndpoints.isEmpty || - linkCompletedAt != nil || - handshakeUpdatedAt != nil || - recoveryStartedAt != nil || - mainRecoveryAttemptId != nil || - responderRecoveryAttemptId != nil || - lastCompletedRecoveryAttemptId != nil - } - } - - struct ContactSecretState: Codable { - var linkSnapshotHex: String? - var handshakeSnapshotHex: String? - - init(contactState: ContactState) { - linkSnapshotHex = contactState.linkSnapshotHex - handshakeSnapshotHex = contactState.handshakeSnapshotHex - } - - var hasSecretState: Bool { - linkSnapshotHex != nil || - handshakeSnapshotHex != nil - } - } - - struct ContactCacheState: Codable { - var remoteEndpoints: [StoredPaymentEntry] = [] - var localInvoice: StoredInvoice? - var receivedInvoicePaymentHashes: [String] = [] - var lastLocalPayloadHash: String? - var linkCompletedAt: UInt64? - var handshakeUpdatedAt: UInt64? - var recoveryStartedAt: UInt64? - var mainRecoveryAttemptId: String? - var responderRecoveryAttemptId: String? - var lastCompletedRecoveryAttemptId: String? - var awaitingRecoveredRemoteEndpoints: Bool? - var linkFailureCount: Int = 0 - - init(contactState: ContactState) { - remoteEndpoints = contactState.remoteEndpoints - localInvoice = contactState.localInvoice - receivedInvoicePaymentHashes = contactState.receivedInvoicePaymentHashes - lastLocalPayloadHash = contactState.lastLocalPayloadHash - linkCompletedAt = contactState.linkCompletedAt - handshakeUpdatedAt = contactState.handshakeUpdatedAt - recoveryStartedAt = contactState.recoveryStartedAt - mainRecoveryAttemptId = contactState.mainRecoveryAttemptId - responderRecoveryAttemptId = contactState.responderRecoveryAttemptId - lastCompletedRecoveryAttemptId = contactState.lastCompletedRecoveryAttemptId - awaitingRecoveredRemoteEndpoints = contactState.awaitingRecoveredRemoteEndpoints ? true : nil - linkFailureCount = contactState.linkFailureCount - } - var hasCacheState: Bool { - !remoteEndpoints.isEmpty || + hasPublishedPrivatePaymentList || + !cachedResolvedEndpoints.isEmpty || localInvoice != nil || - !receivedInvoicePaymentHashes.isEmpty || - lastLocalPayloadHash != nil || - linkCompletedAt != nil || - handshakeUpdatedAt != nil || - recoveryStartedAt != nil || - mainRecoveryAttemptId != nil || - responderRecoveryAttemptId != nil || - lastCompletedRecoveryAttemptId != nil || - awaitingRecoveredRemoteEndpoints == true || - linkFailureCount != 0 + !receivedInvoicePaymentHashes.isEmpty } } @@ -194,15 +27,15 @@ extension PrivatePaykitService { var methodId: String var endpointData: String - init(entry: FfiPaymentEntry) { - methodId = entry.methodId - endpointData = entry.endpointData - } - init(methodId: String, endpointData: String) { self.methodId = methodId self.endpointData = endpointData } + + init(endpoint: PublicPaykitService.Endpoint) { + methodId = endpoint.methodId.rawValue + endpointData = endpoint.rawPayload + } } struct StoredInvoice: Codable { @@ -210,12 +43,4 @@ extension PrivatePaykitService { var paymentHash: String var expiresAt: Double } - - struct RecoveryMarker: Codable { - var version: Int - var path: String - var stage: String - var attemptId: String - var createdAt: UInt64 - } } diff --git a/Bitkit/Services/PrivatePaykitService+Payments.swift b/Bitkit/Services/PrivatePaykitService+Payments.swift index fee3fdc64..5804ab666 100644 --- a/Bitkit/Services/PrivatePaykitService+Payments.swift +++ b/Bitkit/Services/PrivatePaykitService+Payments.swift @@ -4,36 +4,6 @@ import Paykit // MARK: - Payment Resolution extension PrivatePaykitService { - func hasCachedPrivateEndpoint(publicKey: String) async -> Bool { - guard let normalizedKey = PubkyPublicKeyFormat.normalized(publicKey), - let contactState = state.contacts[normalizedKey] - else { return false } - - let endpoints = contactState.remoteEndpoints.compactMap { - PublicPaykitService.parseEndpoint(methodId: $0.methodId, endpointData: $0.endpointData) - } - let payableEndpoints = await privatePayableEndpoints(from: endpoints, publicKey: normalizedKey) - return !payableEndpoints.isEmpty - } - - func cachedPrivatePaymentResult(publicKey: String) async -> PublicPaykitPaymentLaunchResult { - guard let normalizedKey = PubkyPublicKeyFormat.normalized(publicKey) else { - return .noEndpoint - } - - let cachedEntries = state.contacts[normalizedKey]?.remoteEndpoints ?? [] - let endpoints = cachedEntries.compactMap { - PublicPaykitService.parseEndpoint(methodId: $0.methodId, endpointData: $0.endpointData) - } - let payableEndpoints = await privatePayableEndpoints(from: endpoints, publicKey: normalizedKey) - - guard !payableEndpoints.isEmpty else { - return cachedEntries.isEmpty ? .noEndpoint : .notOpened - } - - return .opened(paymentRequest: PublicPaykitService.paymentRequest(from: payableEndpoints)) - } - func contactPublicKey(forPrivateInvoicePaymentHash paymentHash: String) -> String? { guard !paymentHash.isEmpty else { return nil } @@ -48,261 +18,76 @@ extension PrivatePaykitService { return await (try? PublicPaykitService.hasPayablePublicEndpoint(publicKey: publicKey)) == true } - return await resolvePayableEndpoint(publicKey: normalizedKey, wallet: wallet) - } - - func resolvePayableEndpoint(publicKey: String, wallet: WalletViewModel) async -> Bool { - let generation = stateGeneration - guard let normalizedKey = PubkyPublicKeyFormat.normalized(publicKey) else { - return await (try? PublicPaykitService.hasPayablePublicEndpoint(publicKey: publicKey)) == true - } - - let hadCachedPrivateEndpoint = await hasCachedPrivateEndpoint(publicKey: normalizedKey) - do { - guard let linkId = try await establishedLinkId(for: normalizedKey, maxAdvanceSteps: 3, generation: generation) else { - if hadCachedPrivateEndpoint { - return true - } - return await (try? PublicPaykitService.hasPayablePublicEndpoint(publicKey: normalizedKey)) == true - } - - if state.contacts[normalizedKey]?.lastLocalPayloadHash == nil { - try await publishLocalEndpointsBestEffort( - to: normalizedKey, - linkId: linkId, - wallet: wallet, - generation: generation, - context: "resolve", - fetchedRemoteCount: 0 - ) - } - - let fetchedCount = try await fetchRemoteEndpoints(publicKey: normalizedKey, linkId: linkId, generation: generation) - let publishLinkId = activeHandlesByContact[normalizedKey]?.linkId ?? linkId - try await publishLocalEndpointsBestEffort( - to: normalizedKey, - linkId: publishLinkId, - wallet: wallet, - generation: generation, - context: "resolve", - fetchedRemoteCount: fetchedCount - ) - - if await hasCachedPrivateEndpoint(publicKey: normalizedKey) { + let result = try await beginPrivateOrPublicPayment(to: normalizedKey, wallet: wallet) + if case .opened = result { return true } + return false } catch { - Logger.warn( - "Failed to resolve private Paykit endpoints for \(PubkyPublicKeyFormat.redacted(normalizedKey)): \(error)", - context: "PrivatePaykit" - ) - if hadCachedPrivateEndpoint { - if shouldCountAsStaleLinkFailure(error) { - schedulePendingPublicationRetry(for: normalizedKey, wallet: wallet) - } - return true - } + return false } + } - return await (try? PublicPaykitService.hasPayablePublicEndpoint(publicKey: normalizedKey)) == true + func resolvePayableEndpoint(publicKey: String, wallet: WalletViewModel) async -> Bool { + await resolveSavedContactPayableEndpoint(publicKey: publicKey, wallet: wallet) } func beginSavedContactPayment(to publicKey: String, wallet: WalletViewModel) async throws -> PublicPaykitPaymentLaunchResult { guard let normalizedKey = knownSavedContact(publicKey) else { return try await PublicPaykitService.beginPayment(to: publicKey) } - guard let ownPublicKey = await PubkyService.currentPublicKey(), - PubkyProfileManager.hasLocalSecretKey(for: ownPublicKey) - else { - return try await PublicPaykitService.beginPayment(to: publicKey) - } - - let privateAttempt = try await beginPrivatePaymentWithRecoveryRetry( - to: normalizedKey, - wallet: wallet - ) - let privateResult: PublicPaykitPaymentLaunchResult? - let privateError: Error? - switch privateAttempt.result { - case let .success(result): - privateResult = result - privateError = nil - case let .failure(error): - if error is CancellationError { - throw CancellationError() - } - privateResult = nil - privateError = error - } - - if let privateResult, case .opened = privateResult { - return privateResult - } - - if privateAttempt.shouldDeferPublicFallback || shouldDeferPublicFallbackForPrivateRecovery(publicKey: normalizedKey) { - if let privateError { - Logger.warn( - "Deferring public Paykit fallback for \(PubkyPublicKeyFormat.redacted(normalizedKey)) while private payment recovery completes: \(privateError)", - context: "PrivatePaykit" - ) - } - return privateResult ?? .noEndpoint - } - if let privateError { - Logger.warn( - "Falling back to public Paykit for \(PubkyPublicKeyFormat.redacted(normalizedKey)) after private payment failed: \(privateError)", - context: "PrivatePaykit" - ) - } - - return try await PublicPaykitService.beginPayment(to: publicKey) + return try await beginPrivateOrPublicPayment(to: normalizedKey, wallet: wallet) } - func beginPrivatePaymentWithRecoveryRetry(to publicKey: String, wallet: WalletViewModel) async throws -> PrivatePaymentAttempt { - var shouldDeferPublicFallback = shouldDeferPublicFallbackForPrivateRecovery(publicKey: publicKey) - var result = await privatePaymentAttempt(to: publicKey, wallet: wallet) - - for _ in 0 ..< Self.privatePaymentRecoveryRetryAttempts { - shouldDeferPublicFallback = shouldDeferPublicFallback || shouldDeferPublicFallbackForPrivateRecovery(publicKey: publicKey) - guard try shouldRetryPrivatePaymentBeforePublicFallback( - publicKey: publicKey, - result: result, - shouldDeferPublicFallback: shouldDeferPublicFallback - ) else { - return PrivatePaymentAttempt(result: result, shouldDeferPublicFallback: shouldDeferPublicFallback) - } + private func beginPrivateOrPublicPayment(to publicKey: String, wallet: WalletViewModel) async throws -> PublicPaykitPaymentLaunchResult { + let isPrivateCapable = await hasLocalSecretKeyForCurrentProfile() - try await Task.sleep(nanoseconds: Self.privatePaymentRecoveryRetryDelay) - result = await privatePaymentAttempt(to: publicKey, wallet: wallet) + if isPrivateCapable, await canPublishPrivateEndpoints(wallet: wallet) { + _ = await refreshSavedContactEndpointsReturningError( + for: [publicKey], + wallet: wallet, + forceRefreshLightning: false, + requireImmediatePublication: false + ) } - shouldDeferPublicFallback = shouldDeferPublicFallback || shouldDeferPublicFallbackForPrivateRecovery(publicKey: publicKey) - return PrivatePaymentAttempt(result: result, shouldDeferPublicFallback: shouldDeferPublicFallback) - } + do { + let prepared = try await PaykitSdkService.shared.prepareAndResolveContactPayment(counterparty: publicKey, includePublicEndpoints: true) + let resolution = prepared.resolution + let privateEndpoints = resolvedEndpoints(from: resolution, source: .privatePaymentList) + cacheResolvedEndpoints(privateEndpoints, publicKey: publicKey) - func shouldRetryPrivatePaymentBeforePublicFallback( - publicKey: String, - result: Result, - shouldDeferPublicFallback: Bool - ) throws -> Bool { - if case .success(.opened) = result { - return false - } + let payableEndpoints = await privatePayableEndpoints(from: privateEndpoints, publicKey: publicKey) - if case let .failure(error) = result { - if error is CancellationError { - throw CancellationError() - } - if let privateError = error as? PrivatePaykitError, - case .privateUnavailable = privateError - { - return shouldDeferPublicFallback || shouldDeferPublicFallbackForPrivateRecovery(publicKey: publicKey) + if !payableEndpoints.isEmpty { + return .opened(paymentRequest: PublicPaykitService.paymentRequest(from: payableEndpoints)) } - } - return shouldDeferPublicFallback || shouldDeferPublicFallbackForPrivateRecovery(publicKey: publicKey) - } - - func shouldDeferPublicFallbackForPrivateRecovery(publicKey: String) -> Bool { - let normalizedKey = PubkyPublicKeyFormat.normalized(publicKey) ?? publicKey - return shouldDeferPublicFallbackForPrivateRecovery(contactState: state.contacts[normalizedKey]) - } - - func shouldDeferPublicFallbackForPrivateRecovery(contactState: ContactState?) -> Bool { - guard let contactState else { return false } - - return contactState.recoveryStartedAt != nil || - contactState.mainRecoveryAttemptId != nil || - contactState.responderRecoveryAttemptId != nil || - contactState.awaitingRecoveredRemoteEndpoints - } - - func clearAwaitingRecoveredRemoteEndpoints(publicKey: String) { - guard state.contacts[publicKey]?.awaitingRecoveredRemoteEndpoints == true else { - return - } - - state.contacts[publicKey]?.awaitingRecoveredRemoteEndpoints = false - persistState(markWalletBackup: true) - } - - private func privatePaymentAttempt(to publicKey: String, wallet: WalletViewModel) async -> Result { - do { - let result = try await beginPrivatePayment( - to: publicKey, - wallet: wallet - ) - return .success(result) - } catch { - return .failure(error) - } - } - - func beginPrivatePayment(to publicKey: String, wallet: WalletViewModel) async throws -> PublicPaykitPaymentLaunchResult { - let generation = stateGeneration - guard let normalizedKey = PubkyPublicKeyFormat.normalized(publicKey), - let linkId = try await establishedLinkId(for: normalizedKey, maxAdvanceSteps: 5, generation: generation) - else { - throw PrivatePaykitError.privateUnavailable - } - - if state.contacts[normalizedKey]?.lastLocalPayloadHash == nil { - try await publishLocalEndpointsBestEffort( - to: normalizedKey, - linkId: linkId, - wallet: wallet, - generation: generation, - context: "payment", - fetchedRemoteCount: 0 - ) - } + let publicEndpoints = resolvedEndpoints(from: resolution, source: .publicPaymentEndpoint) + let publicPayableEndpoints = await PublicPaykitService.payableEndpoints(from: publicEndpoints) + if !publicPayableEndpoints.isEmpty { + return .opened(paymentRequest: PublicPaykitService.paymentRequest(from: publicPayableEndpoints)) + } - var fetchedCount = 0 - var staleFetchError: Error? - do { - fetchedCount = try await fetchRemoteEndpoints(publicKey: normalizedKey, linkId: linkId, generation: generation) + return (privateEndpoints.isEmpty && publicEndpoints.isEmpty) ? .noEndpoint : .notOpened } catch { - try Task.checkCancellation() - if shouldCountAsStaleLinkFailure(error) { - Logger.warn( - "Private Paykit link is stale for \(PubkyPublicKeyFormat.redacted(normalizedKey)); using cached private endpoints if available while recovery retries: \(error)", - context: "PrivatePaykit" - ) - staleFetchError = error - schedulePendingPublicationRetry(for: normalizedKey, wallet: wallet) - } else { - Logger.warn( - "Failed to refresh private Paykit endpoints for \(PubkyPublicKeyFormat.redacted(normalizedKey)); using cached private endpoints if available: \(error)", - context: "PrivatePaykit" - ) + if error is CancellationError || Task.isCancelled { + throw error } - } - if staleFetchError == nil { - let publishLinkId = activeHandlesByContact[normalizedKey]?.linkId ?? linkId - try await publishLocalEndpointsBestEffort( - to: normalizedKey, - linkId: publishLinkId, - wallet: wallet, - generation: generation, - context: "payment", - fetchedRemoteCount: fetchedCount + Logger.warn( + "Failed to resolve Paykit contact payment for \(PubkyPublicKeyFormat.redacted(publicKey)): \(error)", + context: "PrivatePaykit" ) + return .noEndpoint } + } - let cachedResult = await cachedPrivatePaymentResult(publicKey: normalizedKey) - if case .opened = cachedResult { - clearAwaitingRecoveredRemoteEndpoints(publicKey: normalizedKey) - return cachedResult - } - - if let staleFetchError { - throw staleFetchError - } - - return cachedResult + private func hasLocalSecretKeyForCurrentProfile() async -> Bool { + guard let status = try? await PaykitSdkService.shared.identityStatus() else { return false } + return status.privateLinkCapable } func privatePayableEndpoints(from endpoints: [PublicPaykitService.Endpoint], publicKey: String) async -> [PublicPaykitService.Endpoint] { @@ -316,21 +101,10 @@ extension PrivatePaykitService { continue } - guard PublicPaykitService.hasLightningRouteHints(bolt11: endpoint.value) else { + guard PublicPaykitService.hasLightningRouteHints(bolt11: endpoint.value), + await !hasAttemptedOutboundBolt11Payment(paymentHash: paymentHash) + else { staleLightningPaymentHashes.insert(paymentHash) - Logger.warn( - "Ignoring private Paykit Lightning endpoint without route hints from \(PubkyPublicKeyFormat.redacted(publicKey))", - context: "PrivatePaykit" - ) - continue - } - - if await hasAttemptedOutboundBolt11Payment(paymentHash: paymentHash) { - staleLightningPaymentHashes.insert(paymentHash) - Logger.warn( - "Ignoring already-attempted private Paykit Lightning endpoint from \(PubkyPublicKeyFormat.redacted(publicKey))", - context: "PrivatePaykit" - ) continue } @@ -345,14 +119,9 @@ extension PrivatePaykitService { do { let isUsed = try await CoreService.shared.utility.isAddressUsed(address: endpoint.value) - guard !isUsed else { - Logger.warn( - "Ignoring used private Paykit on-chain endpoint from \(PubkyPublicKeyFormat.redacted(publicKey))", - context: "PrivatePaykit" - ) - continue + if !isUsed { + reusableEndpoints.append(endpoint) } - reusableEndpoints.append(endpoint) } catch { Logger.warn( "Failed to verify private Paykit on-chain endpoint usage for \(PubkyPublicKeyFormat.redacted(publicKey)): \(error)", @@ -368,65 +137,16 @@ extension PrivatePaykitService { return reusableEndpoints } - @discardableResult - func fetchRemoteEndpoints(publicKey: String, linkId: String, generation: UInt64) async throws -> Int { - do { - return try await readRemoteEndpoints(publicKey: publicKey, linkId: linkId, generation: generation) - } catch { - try Task.checkCancellation() - if shouldCountAsStaleLinkFailure(error), - let restoredLinkId = try? await restoreLinkHandleForReadRetry(publicKey: publicKey, generation: generation) - { - do { - Logger.info( - "Retrying private Paykit endpoint fetch after restoring link snapshot for \(PubkyPublicKeyFormat.redacted(publicKey))", - context: "PrivatePaykit" - ) - return try await readRemoteEndpoints(publicKey: publicKey, linkId: restoredLinkId, generation: generation) - } catch { - await recordLinkFailure(publicKey: publicKey, error: error, generation: generation) - throw error - } - } - - await recordLinkFailure(publicKey: publicKey, error: error, generation: generation) - throw error - } - } - - @discardableResult - func readRemoteEndpoints(publicKey: String, linkId: String, generation: UInt64) async throws -> Int { - let remotePayload = try await PubkyService.getPrivatePayments(linkId: linkId) - try ensureCurrentGeneration(generation) - recordLinkSuccess(publicKey: publicKey) - try await persistLinkSnapshot(linkId: linkId, publicKey: publicKey, generation: generation) - try ensureCurrentGeneration(generation) - - guard let remotePayload else { - // No unread private-payment envelope. Keep the cached map so transient empty reads do not drop the last known endpoints. - return 0 - } - - return cacheRemoteEndpoints(remotePayload.entries, publicKey: publicKey) - } - - @discardableResult - func cacheRemoteEndpoints(_ remoteEntries: [FfiPaymentEntry], publicKey: String) -> Int { - state.contacts[publicKey, default: ContactState()].remoteEndpoints = remoteEntries.map(StoredPaymentEntry.init(entry:)) - persistState(markWalletBackup: true) - return remoteEntries.count - } - func discardRemoteLightningEndpoints(publicKey: String, paymentHashes: Set) async { guard let normalizedKey = PubkyPublicKeyFormat.normalized(publicKey), var contactState = state.contacts[normalizedKey], !paymentHashes.isEmpty else { return } + let previousCount = contactState.cachedResolvedEndpoints.count var filteredEntries: [StoredPaymentEntry] = [] - var didRemoveEndpoint = false - for entry in contactState.remoteEndpoints { + for entry in contactState.cachedResolvedEndpoints { guard entry.methodId == PublicPaykitService.MethodId.bitcoinLightningBolt11.rawValue, let endpoint = PublicPaykitService.parseEndpoint(methodId: entry.methodId, endpointData: entry.endpointData), let paymentHash = await paymentHash(forBolt11: endpoint.value), @@ -435,13 +155,11 @@ extension PrivatePaykitService { filteredEntries.append(entry) continue } - - didRemoveEndpoint = true } - guard didRemoveEndpoint else { return } + guard filteredEntries.count != previousCount else { return } - contactState.remoteEndpoints = filteredEntries + contactState.cachedResolvedEndpoints = filteredEntries state.contacts[normalizedKey] = contactState persistState(markWalletBackup: true) } @@ -452,8 +170,8 @@ extension PrivatePaykitService { !addresses.isEmpty else { return } - let previousCount = contactState.remoteEndpoints.count - contactState.remoteEndpoints = contactState.remoteEndpoints.filter { entry in + let previousCount = contactState.cachedResolvedEndpoints.count + contactState.cachedResolvedEndpoints = contactState.cachedResolvedEndpoints.filter { entry in guard PublicPaykitService.MethodId.onchainPreferenceOrder.contains(where: { $0.rawValue == entry.methodId }), let endpoint = PublicPaykitService.parseEndpoint(methodId: entry.methodId, endpointData: entry.endpointData) else { @@ -463,9 +181,17 @@ extension PrivatePaykitService { return !addresses.contains(endpoint.value) } - guard contactState.remoteEndpoints.count != previousCount else { return } + guard contactState.cachedResolvedEndpoints.count != previousCount else { return } state.contacts[normalizedKey] = contactState persistState(markWalletBackup: true) } + + private func resolvedEndpoints(from resolution: ContactPaymentResolution, source: PaymentEndpointSource) -> [PublicPaykitService.Endpoint] { + resolution.payableEndpoints.compactMap { + guard $0.source == source else { return nil } + + return PublicPaykitService.parseEndpoint(identifier: $0.identifier, payload: $0.target.payload) + } + } } diff --git a/Bitkit/Services/PrivatePaykitService+State.swift b/Bitkit/Services/PrivatePaykitService+State.swift index 73d83a61b..9203472d5 100644 --- a/Bitkit/Services/PrivatePaykitService+State.swift +++ b/Bitkit/Services/PrivatePaykitService+State.swift @@ -1,345 +1,40 @@ import Foundation -// MARK: - Active Paykit Handles +// MARK: - State extension PrivatePaykitService { - func markProfileRecoveryPendingIfNeeded() { - guard !state.contacts.isEmpty else { return } - Self.setProfileRecoveryPending(true) - } - - func closeAndClear(markProfileRecoveryPending: Bool = false) async { - if markProfileRecoveryPending { - markProfileRecoveryPendingIfNeeded() - } - resetInFlightWork() - await closeActivePaykitHandles() - activeHandlesByContact.removeAll() - knownSavedContactKeys.removeAll() + func closeAndClear() async { + pendingMessageDrainRetryTask?.cancel() + pendingMessageDrainRetryTask = nil state = PrivatePaykitState(contacts: [:]) - try? Keychain.delete(key: .privatePaykitSecretState) - UserDefaults.standard.removeObject(forKey: Self.cacheStateKey) - markWalletBackupDataChanged() - } - - func persistLinkSnapshot(linkId: String, publicKey: String, generation: UInt64, linkWasReplaced: Bool = false) async throws { - let snapshotHex = try await PubkyService.serializeEncryptedLink(linkId: linkId) - try ensureCurrentGeneration(generation) - guard activeHandlesByContact[publicKey]?.linkId == linkId else { - throw PrivatePaykitError.staleLinkState - } - let completedAttemptId = state.contacts[publicKey]?.mainRecoveryAttemptId ?? state.contacts[publicKey]?.responderRecoveryAttemptId - state.contacts[publicKey, default: ContactState()].linkSnapshotHex = snapshotHex - state.contacts[publicKey]?.handshakeSnapshotHex = nil - state.contacts[publicKey]?.recoveryStartedAt = nil - state.contacts[publicKey]?.mainRecoveryAttemptId = nil - state.contacts[publicKey]?.responderRecoveryAttemptId = nil - if linkWasReplaced || state.contacts[publicKey]?.linkCompletedAt == nil { - state.contacts[publicKey]?.linkCompletedAt = UInt64(Date().timeIntervalSince1970) - } - if linkWasReplaced { - state.contacts[publicKey]?.lastLocalPayloadHash = nil - } - if let completedAttemptId { - state.contacts[publicKey]?.lastCompletedRecoveryAttemptId = completedAttemptId - state.contacts[publicKey]?.awaitingRecoveredRemoteEndpoints = true - } + knownSavedContactKeys.removeAll() + await PaykitSdkService.shared.clearState() persistState(markWalletBackup: true) + Self.setContactSharingCleanupPending(false) + Self.clearDeletedContactCleanupPending() } func clearContactState(publicKey: String) async { - let ownPublicKey = await (PubkyService.currentPublicKey()).flatMap(PubkyPublicKeyFormat.normalized) - if let ownPublicKey { - await clearRecoveryMarker(from: ownPublicKey, to: publicKey) - } - - if let linkId = activeHandlesByContact[publicKey]?.linkId { - try? await PubkyService.closeEncryptedLink(linkId: linkId) - } - if let handshakeId = activeHandlesByContact[publicKey]?.handshakeId { - try? await PubkyService.dropEncryptedLinkHandshake(handshakeId: handshakeId) - } - - activeHandlesByContact[publicKey] = nil - state.contacts[publicKey] = nil + guard let normalizedKey = PubkyPublicKeyFormat.normalized(publicKey) else { return } + state.contacts[normalizedKey] = nil + await PrivatePaykitAddressReservationStore.shared.clearContactAssignment(publicKey: normalizedKey) persistState(markWalletBackup: true) } - func closeActivePaykitHandles() async { - for handles in activeHandlesByContact.values { - if let linkId = handles.linkId { - try? await PubkyService.closeEncryptedLink(linkId: linkId) - } - if let handshakeId = handles.handshakeId { - try? await PubkyService.dropEncryptedLinkHandshake(handshakeId: handshakeId) - } - } - } - - func recordLinkSuccess(publicKey: String) { - guard state.contacts[publicKey]?.linkFailureCount != 0 else { return } - state.contacts[publicKey]?.linkFailureCount = 0 - persistState() - } - - func recordLinkFailure(publicKey: String, error: Error, generation: UInt64) async { - guard stateGeneration == generation, !Task.isCancelled else { - return - } - - guard shouldCountAsStaleLinkFailure(error) else { - return - } - - let failureCount = (state.contacts[publicKey]?.linkFailureCount ?? 0) + 1 - state.contacts[publicKey, default: ContactState()].linkFailureCount = failureCount - - guard failureCount >= Self.staleLinkFailureThreshold else { - persistState() - return - } - - if let linkId = activeHandlesByContact[publicKey]?.linkId { - try? await PubkyService.closeEncryptedLink(linkId: linkId) - } - guard stateGeneration == generation, !Task.isCancelled else { - return - } - - stateGeneration &+= 1 - linkEstablishmentTasks.removeValue(forKey: publicKey)?.task.cancel() - if var handles = activeHandlesByContact[publicKey] { - handles.linkId = nil - handles.handshakeId = nil - activeHandlesByContact[publicKey] = handles - } - state.contacts[publicKey]?.linkSnapshotHex = nil - state.contacts[publicKey]?.handshakeSnapshotHex = nil - state.contacts[publicKey]?.lastLocalPayloadHash = nil - state.contacts[publicKey]?.remoteEndpoints = [] - state.contacts[publicKey]?.linkFailureCount = 0 - state.contacts[publicKey]?.recoveryStartedAt = UInt64(Date().timeIntervalSince1970) - state.contacts[publicKey]?.mainRecoveryAttemptId = nil - state.contacts[publicKey]?.responderRecoveryAttemptId = nil - state.contacts[publicKey]?.awaitingRecoveredRemoteEndpoints = false - persistState(markWalletBackup: true) - } - - func resetInFlightWork() { - stateGeneration &+= 1 - for inFlight in linkEstablishmentTasks.values { - inFlight.task.cancel() - } - linkEstablishmentTasks.removeAll() - for inFlight in publicationTasks.values { - inFlight.task.cancel() - } - publicationTasks.removeAll() - for inFlight in pendingPublicationRetryTasks.values { - inFlight.cancel() - } - pendingPublicationRetryTasks.removeAll() - } - - func invalidateLinkEstablishmentWork() { - stateGeneration &+= 1 - for inFlight in linkEstablishmentTasks.values { - inFlight.task.cancel() - } - linkEstablishmentTasks.removeAll() - } - - func invalidateLinkEstablishment(for publicKey: String) { - stateGeneration &+= 1 - if let inFlight = linkEstablishmentTasks.removeValue(forKey: publicKey) { - inFlight.task.cancel() - } - cancelPendingPublicationRetry(for: publicKey) - } - - func ensureCurrentGeneration(_ generation: UInt64) throws { - try Task.checkCancellation() - guard stateGeneration == generation else { - throw PrivatePaykitError.privateUnavailable - } - } - func persistState(markWalletBackup: Bool = false) { do { - let secretState = state.secretState - if secretState.contacts.isEmpty { - try? Keychain.delete(key: .privatePaykitSecretState) - } else { - let data = try JSONEncoder().encode(secretState) - try Keychain.upsert(key: .privatePaykitSecretState, data: data) - } - - let cacheState = state.cacheState - if cacheState.contacts.isEmpty { - UserDefaults.standard.removeObject(forKey: Self.cacheStateKey) - } else { - let data = try JSONEncoder().encode(cacheState) - UserDefaults.standard.set(data, forKey: Self.cacheStateKey) - } - - if markWalletBackup { - markWalletBackupDataChanged() - } - } catch { - Logger.error("Failed to persist private Paykit state: \(error)", context: "PrivatePaykit") - } - } - - func markWalletBackupDataChanged() { - Self.walletBackupDataChangedSubject.send() - } - - @discardableResult - func purgePrivatePaymentOutbox(for publicKey: String, reason: String) async -> Bool { - let otherContactCount = state.contacts.keys.filter { $0 != publicKey }.count - guard otherContactCount == 0 else { - Logger.warn( - "Skipping broad private Paykit transport cleanup during \(reason) because \(otherContactCount) other private contact(s) have state; continuing recovery without purge", - context: "PrivatePaykit" - ) - return true - } - - return await purgePrivatePaymentStorage(reason: reason) - } - - @discardableResult - func purgePrivatePaymentOutboxForProfileRecovery(reason: String) async -> Bool { - await purgePrivatePaymentStorage(reason: reason) - } - - @discardableResult - private func purgePrivatePaymentStorage(reason: String) async -> Bool { - guard let sessionSecret = try? Keychain.loadString(key: .paykitSession), - !sessionSecret.isEmpty - else { return false } - - do { - if try await deletePrivatePaymentStorageRoot(sessionSecret: sessionSecret, reason: reason) { - return true - } - - let result = try await purgePrivatePaymentStorageTree( - sessionSecret: sessionSecret, - dirPath: Self.privateStorageRootPath, - depth: 0, - deletedSoFar: 0 - ) - if result.deletedCount > 0 { - Logger.info("Cleared \(result.deletedCount) stale private Paykit transport messages during \(reason)", context: "PrivatePaykit") - } - if result.didHitLimit { - Logger.warn("Stopped private Paykit transport cleanup after reaching the safety limit", context: "PrivatePaykit") - } - return !result.didHitLimit && !result.didFail - } catch { - if isMissingPrivateStorageError(error) { - return true - } - Logger.warn("Failed to clear private Paykit transport messages during \(reason): \(error)", context: "PrivatePaykit") - return false - } - } - - func deletePrivatePaymentStorageRoot(sessionSecret: String, reason: String) async throws -> Bool { - do { - try await PubkyService.sessionDelete(sessionSecret: sessionSecret, path: filePath(Self.privateStorageRootPath)) - Logger.info("Cleared stale private Paykit transport directory during \(reason)", context: "PrivatePaykit") - return true + let data = try JSONEncoder().encode(state) + UserDefaults.standard.set(data, forKey: Self.cacheStateKey) } catch { - return false - } - } - - func purgePrivatePaymentStorageTree(sessionSecret: String, dirPath: String, depth: Int, - deletedSoFar: Int) async throws -> PrivateStoragePurgeResult - { - guard deletedSoFar < Self.privateStoragePurgeMaxEntries else { - return PrivateStoragePurgeResult(deletedCount: 0, didHitLimit: true, didFail: false) + Logger.error("Failed to persist private Paykit cache state: \(error)", context: "PrivatePaykit") } - guard depth < Self.privateStoragePurgeMaxDepth else { - return PrivateStoragePurgeResult(deletedCount: 0, didHitLimit: true, didFail: false) - } - - let entries = try await PubkyService.sessionList(sessionSecret: sessionSecret, dirPath: directoryPath(dirPath)) - var deletedCount = 0 - var didHitLimit = false - var didFail = false - - for entry in entries { - guard deletedSoFar + deletedCount < Self.privateStoragePurgeMaxEntries else { - didHitLimit = true - break - } - guard let path = privateStoragePath(from: entry) else { continue } - do { - try await PubkyService.sessionDelete(sessionSecret: sessionSecret, path: filePath(path)) - deletedCount += 1 - } catch { - if depth == 0, !path.hasSuffix("/") { - do { - let childResult = try await purgePrivatePaymentStorageTree( - sessionSecret: sessionSecret, - dirPath: directoryPath(path), - depth: depth + 1, - deletedSoFar: deletedSoFar + deletedCount - ) - deletedCount += childResult.deletedCount - didHitLimit = didHitLimit || childResult.didHitLimit - didFail = didFail || childResult.didFail - continue - } catch { - if isMissingPrivateStorageError(error) { - continue - } - Logger.warn("Failed to list private Paykit transport directory at \(path): \(error)", context: "PrivatePaykit") - didFail = true - } - } else if isMissingPrivateStorageError(error) { - continue - } - - Logger.warn("Failed to delete stale private Paykit transport entry at \(path): \(error)", context: "PrivatePaykit") - didFail = true - } + if markWalletBackup { + markWalletBackupDataChanged() } - - return PrivateStoragePurgeResult(deletedCount: deletedCount, didHitLimit: didHitLimit, didFail: didFail) } - func privateStoragePath(from entry: String) -> String? { - let path: String = if let url = URL(string: entry), url.scheme == "pubky" { - url.path - } else { - entry - } - - guard path.hasPrefix(Self.privateStorageRootPath) else { return nil } - return path - } - - func directoryPath(_ path: String) -> String { - path.hasSuffix("/") ? path : "\(path)/" - } - - func filePath(_ path: String) -> String { - path.hasSuffix("/") ? String(path.dropLast()) : path - } - - func isMissingPrivateStorageError(_ error: Error) -> Bool { - let reason: String = if let appError = error as? AppError { - [appError.message, appError.debugMessage].compactMap { $0 }.joined(separator: " ") - } else { - error.localizedDescription - } - - let lowercasedReason = reason.lowercased() - return lowercasedReason.contains("404") && lowercasedReason.contains("not found") + func markWalletBackupDataChanged() { + Self.walletBackupDataChangedSubject.send() } } diff --git a/Bitkit/Services/PrivatePaykitService.swift b/Bitkit/Services/PrivatePaykitService.swift index 832b91a14..73cedb571 100644 --- a/Bitkit/Services/PrivatePaykitService.swift +++ b/Bitkit/Services/PrivatePaykitService.swift @@ -1,6 +1,37 @@ import Combine import Foundation +actor PrivatePaykitPublicationLock { + private var isLocked = false + private var waiters: [CheckedContinuation] = [] + + func withLock(_ operation: () async throws -> T) async throws -> T { + await lock() + defer { unlock() } + return try await operation() + } + + private func lock() async { + if !isLocked { + isLocked = true + return + } + + await withCheckedContinuation { continuation in + waiters.append(continuation) + } + } + + private func unlock() { + guard !waiters.isEmpty else { + isLocked = false + return + } + + waiters.removeFirst().resume() + } +} + // MARK: - Core Actor actor PrivatePaykitService { @@ -12,59 +43,59 @@ actor PrivatePaykitService { walletBackupDataChangedSubject.eraseToAnyPublisher() } - static let maxNoisePayloadBytes = 1000 static let invoiceRefreshBufferSeconds: TimeInterval = 30 * 60 static let maxReceivedInvoicePaymentHashesPerContact = 100 - static let staleLinkFailureThreshold = 3 static let publishingEnabledKey = "sharesPrivatePaykitEndpoints" static let cleanupPendingKey = "paykitContactSharingCleanupPending" - static let profileRecoveryPendingKey = "privatePaykitProfileRecoveryPending" + static let deletedContactCleanupKeysKey = "privatePaykitDeletedContactCleanupKeys" static let cacheStateKey = "privatePaykitCacheState" - static let privateEndpointRemovalPayload = #"{"value":""}"# - static let recoveryMarkerStageInit = "init" - static let recoveryMarkerStageResponse = "response" - static let recoveryMarkerStageFinal = "final" - static let pendingPublicationRetryDelay: UInt64 = 5_000_000_000 - static let pendingPublicationRetryAttempts = 60 - static let privatePaymentRecoveryRetryDelay: UInt64 = 2_000_000_000 - static let privatePaymentRecoveryRetryAttempts = 12 - static let freshLinkInitialPublishDelaySeconds: UInt64 = 8 - static let privateStorageRootPath = "/pub/paykit/v0/private/" - static let privateStoragePurgeMaxEntries = 500 - static let privateStoragePurgeMaxDepth = 3 + // Private links can finish after a contact is added on the other device; keep draining long enough for staggered mutual adds. + static let privateMessageDrainRetryDelays: [UInt64] = [ + 1_000_000_000, + 3_000_000_000, + 8_000_000_000, + 20_000_000_000, + 45_000_000_000, + 90_000_000_000, + ] var state: PrivatePaykitState - var activeHandlesByContact: [String: ContactPaykitHandles] = [:] - var linkEstablishmentTasks: [String: LinkEstablishmentTask] = [:] - var publicationTasks: [String: PublicationTask] = [:] - var pendingPublicationRetryTasks: [String: Task] = [:] var knownSavedContactKeys: Set = [] - var stateGeneration: UInt64 = 0 + var pendingMessageDrainRetryTask: Task? + let publicationLock = PrivatePaykitPublicationLock() init() { - let secretState = (try? Keychain.load(key: .privatePaykitSecretState)) - .flatMap { try? JSONDecoder().decode(PrivatePaykitSecretState.self, from: $0) } ?? PrivatePaykitSecretState(contacts: [:]) - let cacheState = UserDefaults.standard.data(forKey: Self.cacheStateKey) - .flatMap { try? JSONDecoder().decode(PrivatePaykitCacheState.self, from: $0) } ?? PrivatePaykitCacheState(contacts: [:]) + state = UserDefaults.standard.data(forKey: Self.cacheStateKey) + .flatMap { try? JSONDecoder().decode(PrivatePaykitState.self, from: $0) } ?? PrivatePaykitState(contacts: [:]) + } - state = PrivatePaykitState(secretState: secretState, cacheState: cacheState) + static func setContactSharingCleanupPending(_ isPending: Bool) { + UserDefaults.standard.set(isPending, forKey: cleanupPendingKey) } - static func shouldInitiate(ownPublicKey: String, remotePublicKey: String) -> Bool { - let own = PubkyPublicKeyFormat.normalized(ownPublicKey) ?? ownPublicKey - let remote = PubkyPublicKeyFormat.normalized(remotePublicKey) ?? remotePublicKey - return own > remote + static func pendingDeletedContactCleanupKeys() -> Set { + Set(UserDefaults.standard.stringArray(forKey: deletedContactCleanupKeysKey) ?? []) } - static func setContactSharingCleanupPending(_ isPending: Bool) { - UserDefaults.standard.set(isPending, forKey: cleanupPendingKey) + static func markDeletedContactCleanupPending(_ publicKeys: [String]) { + let normalizedKeys = publicKeys.compactMap(PubkyPublicKeyFormat.normalized) + guard !normalizedKeys.isEmpty else { return } + + let keys = pendingDeletedContactCleanupKeys().union(normalizedKeys) + UserDefaults.standard.set(Array(keys).sorted(), forKey: deletedContactCleanupKeysKey) } - static func setProfileRecoveryPending(_ isPending: Bool) { - UserDefaults.standard.set(isPending, forKey: profileRecoveryPendingKey) + static func clearDeletedContactCleanupPending(_ publicKeys: [String]) { + var keys = pendingDeletedContactCleanupKeys() + keys.subtract(publicKeys.compactMap(PubkyPublicKeyFormat.normalized)) + if keys.isEmpty { + UserDefaults.standard.removeObject(forKey: deletedContactCleanupKeysKey) + } else { + UserDefaults.standard.set(Array(keys).sorted(), forKey: deletedContactCleanupKeysKey) + } } - static var isProfileRecoveryPending: Bool { - UserDefaults.standard.bool(forKey: profileRecoveryPendingKey) + static func clearDeletedContactCleanupPending() { + UserDefaults.standard.removeObject(forKey: deletedContactCleanupKeysKey) } } diff --git a/Bitkit/Services/PubkyService.swift b/Bitkit/Services/PubkyService.swift index bfc207909..6fcf59318 100644 --- a/Bitkit/Services/PubkyService.swift +++ b/Bitkit/Services/PubkyService.swift @@ -1,4 +1,5 @@ import BitkitCore +import Combine import Foundation import Paykit @@ -25,76 +26,56 @@ enum PubkyServiceError: LocalizedError { } } -/// Service layer wrapping BitkitCore (auth) and PaykitFFI (profile/contacts/payments). +/// Service layer wrapping BitkitCore key derivation and Paykit SDK workflows. enum PubkyService { static func initialize() async throws { - try await ServiceQueue.background(.core) { - try await paykitInitialize() - } + try await PaykitSdkService.shared.initialize() } // MARK: - Session Management /// Import a session secret into paykit and return the public key. static func importSession(secret: String) async throws -> String { - try await ServiceQueue.background(.core) { - try await paykitImportSession(sessionSecret: secret) - } - } - - static func exportSession() async throws -> String { - try await ServiceQueue.background(.core) { - try await paykitExportSession() - } + let result = try await PaykitSdkService.shared.importSession(secret: secret) + return result.publicKey } - static func isAuthenticated() async -> Bool { - await (try? ServiceQueue.background(.core) { - await paykitIsAuthenticated() - }) ?? false + static func importExternalSession(secret: String) async throws -> String { + let result = try await PaykitSdkService.shared.importSession(secret: secret, includeLocalSecret: false) + return result.publicKey } static func currentPublicKey() async -> String? { - try? await ServiceQueue.background(.core) { - await paykitGetCurrentPublicKey() - } + try? await PaykitSdkService.shared.currentPublicKey() } - // MARK: - Auth Flow (BitkitCore) + // MARK: - Auth Flow /// Step 1: Generate the pubkyauth:// URL to open in Pubky Ring. static func startAuth() async throws -> String { - try await ServiceQueue.background(.core) { - try await startPubkyAuth(caps: Env.pubkyCapabilities) - } + try await PaykitSdkService.shared.startAuth() } /// Step 2: Long-poll until Ring approves. Returns the raw session secret. static func completeAuth() async throws -> String { - try await ServiceQueue.background(.core) { - try await completePubkyAuth() - } + try await PaykitSdkService.shared.completeAuth() } /// Cancel an in-progress auth relay poll started by `startAuth`. static func cancelAuth() async throws { - try await ServiceQueue.background(.core) { - try await cancelPubkyAuth() - } + await PaykitSdkService.shared.cancelAuth() } // MARK: - Auth Approval (Bitkit as authenticator) /// Parse a pubkyauth:// URL to extract details for UI display. - static func parseAuthUrl(_ authUrl: String) throws -> BitkitCore.PubkyAuthDetails { - try parsePubkyAuthUrl(authUrl: authUrl) + static func parseAuthUrl(_ authUrl: String) throws -> Paykit.PubkyAuthDetails { + try Paykit.parsePubkyAuthUrl(authUrl: authUrl) } /// Approve a pubkyauth:// request using the local secret key. static func approveAuth(authUrl: String, secretKeyHex: String) async throws { - try await ServiceQueue.background(.core) { - try await approvePubkyAuth(authUrl: authUrl, secretKeyHex: secretKeyHex) - } + try await PaykitSdkService.shared.approveAuth(authUrl: authUrl, secretKeyHex: secretKeyHex) } // MARK: - Key Derivation @@ -106,210 +87,682 @@ enum PubkyService { /// Derive an Ed25519 secret key from a BIP39 seed. Returns hex-encoded 32-byte key. static func derivePubkySecretKey(seed: Data) throws -> String { - try BitkitCore.derivePubkySecretKey(seed: seed) + try PaykitSdkService.secretKeyHex(from: Paykit.derivePubkySecretKey(seed: seed, runtimeLabel: PaykitSdkService.pubkyDerivationRuntimeLabel)) } /// Derive the z32-encoded public key from a hex-encoded secret key. static func pubkyPublicKeyFromSecret(secretKeyHex: String) throws -> String { - try BitkitCore.pubkyPublicKeyFromSecret(secretKeyHex: secretKeyHex) + try Paykit.pubkyPublicKeyFromSecret(localSecretKey: PaykitSdkService.localSecretKey(fromHex: secretKeyHex)) } // MARK: - Homeserver Auth /// Sign up on a homeserver. Returns session secret for persistence. static func signUp(secretKeyHex: String, homeserverZ32: String, signupCode: String? = nil) async throws -> String { - try await ServiceQueue.background(.core) { - try await pubkySignUp(secretKeyHex: secretKeyHex, homeserverPublicKeyZ32: homeserverZ32, signupCode: signupCode) - } + let result = try await PaykitSdkService.shared.signUp( + secretKeyHex: secretKeyHex, + homeserverPublicKey: homeserverZ32, + signupCode: signupCode + ) + return result.sessionAccess.exportSessionSecret() } /// Sign in with an existing secret key. Returns new session secret. static func signIn(secretKeyHex: String) async throws -> String { - try await ServiceQueue.background(.core) { - try await pubkySignIn(secretKeyHex: secretKeyHex) + let result = try await PaykitSdkService.shared.signIn(secretKeyHex: secretKeyHex) + return result.sessionAccess.exportSessionSecret() + } + + // MARK: - File Fetching + + /// Fetch raw bytes from a `pubky://` URI via PKDNS resolution. + static func fetchFile(uri: String) async throws -> Data { + try await PaykitSdkService.shared.fetchFile(uri: uri) + } + + // MARK: - Profile + + static func publishPaykitProfile(_ profile: Paykit.PaykitProfile) async throws { + _ = try await PaykitSdkService.shared.publishPaykitProfile(profile) + } + + static func uploadProfileAvatar(bytes: Data, contentType: String) async throws -> String { + try await PaykitSdkService.shared.uploadProfileAvatar(bytes: bytes, contentType: contentType) + } + + static func deletePaykitProfile() async throws { + try await PaykitSdkService.shared.deletePaykitProfile() + } + + // MARK: - Contacts + + static func getContacts(publicKey: String) async throws -> [String] { + try await PaykitSdkService.shared.fetchPubkyFollows(publicKey: publicKey) + } + + static func contactRecords() async throws -> [Paykit.ContactRecord] { + try await PaykitSdkService.shared.contactRecords() + } + + static func saveContact(publicKey: String, label: String?) async throws -> Paykit.ContactRecord { + try await PaykitSdkService.shared.saveContact(publicKey: publicKey, label: label) + } + + static func removeContact(publicKey: String) async throws -> Paykit.ContactRecord? { + try await PaykitSdkService.shared.removeContact(publicKey: publicKey) + } + + static func resolveContactProfile(publicKey: String, allowPubkyProfileFallback: Bool) async throws -> Paykit.ContactProfileResolution? { + try await PaykitSdkService.shared.resolveContactProfile(publicKey: publicKey, allowPubkyProfileFallback: allowPubkyProfileFallback) + } + + // MARK: - Sign Out + + static func signOut() async throws { + try await PaykitSdkService.shared.signOut() + } + + static func forceSignOut() async { + await PaykitSdkService.shared.forceSignOut() + } + + static func clearSessionAccess() async { + await PaykitSdkService.shared.clearSessionAccess() + } +} + +// MARK: - Paykit SDK Runtime + +actor PaykitSdkService { + static let shared = PaykitSdkService() + nonisolated static let pubkyDerivationRuntimeLabel = "bitkit" + static let walletBackupDataChangedSubject = PassthroughSubject() + + nonisolated static var walletBackupDataChangedPublisher: AnyPublisher { + walletBackupDataChangedSubject.eraseToAnyPublisher() + } + + private let stateStore = PaykitSdkStateBlobStore() + private let sessionProvider = PaykitSdkSessionProvider() + private let paymentAdapter = PaykitSdkPaymentAdapter() + private let operationLock = PaykitSdkOperationLock() + private var sdk: PaykitSdk? + private var activeAuthRequest: Paykit.PubkyAuthRequest? + + func initialize() async throws { + try await operationLock.withLock { + _ = try await handle().initialize() } } - // MARK: - Authenticated Storage + func currentPublicKey() async throws -> String? { + try await operationLock.withLock { + if let status = try await handle().identityStatus(), let publicKey = status.publicKey { + return publicKey + } + + guard let publicKey = try await handle().initialize().identity.publicKey else { + return nil + } - /// Write content to a path on the user's homeserver. - static func sessionPut(sessionSecret: String, path: String, content: Data) async throws { - try await ServiceQueue.background(.core) { - try await pubkySessionPut(sessionSecret: sessionSecret, path: path, content: content) + return publicKey } } - /// Delete a resource at path on the user's homeserver. - static func sessionDelete(sessionSecret: String, path: String) async throws { - try await ServiceQueue.background(.core) { - try await pubkySessionDelete(sessionSecret: sessionSecret, path: path) + func identityStatus() async throws -> IdentityStatus? { + try await operationLock.withLock { + try await handle().identityStatus() } } - /// List resources in a directory on the user's homeserver. - static func sessionList(sessionSecret: String, dirPath: String) async throws -> [String] { - try await ServiceQueue.background(.core) { - try await pubkySessionList(sessionSecret: sessionSecret, dirPath: dirPath) + func importSession(secret: String, includeLocalSecret: Bool = true) async throws -> PubkySessionBootstrapResult { + try await operationLock.withLock { + let previousPublicKey = await currentSdkStatePublicKey() + let localSecret = includeLocalSecret ? try sessionProvider.loadLocalSecretKey() : nil + let result = try await bootstrap().importSession( + sessionSecret: secret, + localSecretKey: localSecret, + requiredCapabilities: Self.requiredCapabilities() + ) + try await activateBootstrapResult(result, previousPublicKey: previousPublicKey, shouldStoreLocalSecret: includeLocalSecret) + markWalletBackupDataChanged() + return result } } - /// Sign in with secret key and write content in one shot. - static func putWithSecretKey(secretKeyHex: String, path: String, content: Data) async throws { - try await ServiceQueue.background(.core) { - try await pubkyPutWithSecretKey(secretKeyHex: secretKeyHex, path: path, content: content) + func signUp(secretKeyHex: String, homeserverPublicKey: String, signupCode: String?) async throws -> PubkySessionBootstrapResult { + try await operationLock.withLock { + let previousPublicKey = await currentSdkStatePublicKey() + let result = try await bootstrap().signUp( + localSecretKey: Self.localSecretKey(fromHex: secretKeyHex), + homeserverPublicKey: homeserverPublicKey, + signupCode: signupCode + ) + try await activateBootstrapResult(result, previousPublicKey: previousPublicKey, shouldStoreLocalSecret: true) + markWalletBackupDataChanged() + return result } } - // MARK: - File Fetching + func signIn(secretKeyHex: String) async throws -> PubkySessionBootstrapResult { + try await operationLock.withLock { + let previousPublicKey = await currentSdkStatePublicKey() + let result = try await bootstrap().signIn(localSecretKey: Self.localSecretKey(fromHex: secretKeyHex)) + try await activateBootstrapResult(result, previousPublicKey: previousPublicKey, shouldStoreLocalSecret: true) + markWalletBackupDataChanged() + return result + } + } - /// Fetch raw bytes from a `pubky://` URI via PKDNS resolution. - static func fetchFile(uri: String) async throws -> Data { - try await ServiceQueue.background(.core) { - try await fetchPubkyFile(uri: uri) + func startAuth() async throws -> String { + try await operationLock.withLock { + let request = try bootstrap().startSignInAuth(capabilities: Self.requiredCapabilities()) + activeAuthRequest = request + return try await request.authorizationUrl() } } - /// Fetch a public resource from a `pubky://` URI and return as a UTF-8 string. - static func fetchFileString(uri: String) async throws -> String { - try await ServiceQueue.background(.core) { - try await fetchPubkyFileString(uri: uri) + func completeAuth() async throws -> String { + try await operationLock.withLock { + guard let request = activeAuthRequest else { + throw PubkyServiceError.invalidAuthUrl + } + + defer { + activeAuthRequest = nil + } + + let previousPublicKey = await currentSdkStatePublicKey() + let result = try await request.complete( + localSecretKey: nil, + requiredCapabilities: Self.requiredCapabilities() + ) + try await activateBootstrapResult(result, previousPublicKey: previousPublicKey, shouldStoreLocalSecret: false) + markWalletBackupDataChanged() + return result.sessionAccess.exportSessionSecret() } } - // MARK: - Profile + func cancelAuth() { + activeAuthRequest = nil + } - static func getProfile(publicKey: String) async throws -> BitkitCore.PubkyProfile { - try await ServiceQueue.background(.core) { - try await fetchPubkyProfile(publicKey: publicKey) + func approveAuth(authUrl: String, secretKeyHex: String) async throws { + try await operationLock.withLock { + try await bootstrap().approveAuth( + authUrl: authUrl, + expectedCapabilities: Self.requiredCapabilities(), + localSecretKey: Self.localSecretKey(fromHex: secretKeyHex) + ) } } - // MARK: - Contacts + func fetchFile(uri: String) async throws -> Data { + try await operationLock.withLock { + guard let data = try await handle().fetchPubkyFile(uri: uri) else { + throw PubkyServiceError.profileNotFound + } + return data + } + } - static func getContacts(publicKey: String) async throws -> [String] { - try await ServiceQueue.background(.core) { - try await fetchPubkyContacts(publicKey: publicKey) + func publishPaykitProfile(_ profile: Paykit.PaykitProfile) async throws -> Paykit.PaykitProfileRecord { + try await withStateRevisionTracking { sdk in + try await sdk.publishPaykitProfile(profile: profile) } } - // MARK: - Payments + func uploadProfileAvatar(bytes: Data, contentType: String) async throws -> String { + let record = try await withStateRevisionTracking { sdk in + try await sdk.uploadProfileAvatar(bytes: bytes, contentType: contentType) + } + return record.uri + } - static func getPaymentList(publicKey: String) async throws -> [FfiPaymentEntry] { - try await ServiceQueue.background(.core) { - try await paykitGetPaymentList(publicKey: publicKey) + func deletePaykitProfile() async throws { + try await withStateRevisionTracking { sdk in + try await sdk.deletePaykitProfile() } } - static func getPaymentEndpoint(publicKey: String, methodId: String) async throws -> String? { - try await ServiceQueue.background(.core) { - try await paykitGetPaymentEndpoint(publicKey: publicKey, methodId: methodId) + func fetchPubkyFollows(publicKey: String) async throws -> [String] { + try await operationLock.withLock { + try await handle().fetchPubkyFollows(publicKey: publicKey) } } - static func setPaymentEndpoint(methodId: String, endpointData: String) async throws { - try await ServiceQueue.background(.core) { - try await paykitSetPaymentEndpoint(methodId: methodId, endpointData: endpointData) + func contactRecords() async throws -> [Paykit.ContactRecord] { + try await operationLock.withLock { + try await handle().contactRecords() } } - static func removePaymentEndpoint(methodId: String) async throws { - try await ServiceQueue.background(.core) { - try await paykitRemovePaymentEndpoint(methodId: methodId) + func saveContact(publicKey: String, label: String?) async throws -> Paykit.ContactRecord { + try await withStateRevisionTracking { sdk in + try await sdk.saveContact(update: Paykit.ContactUpdate(publicKey: publicKey, label: label)) } } - // MARK: - Private Payments + func removeContact(publicKey: String) async throws -> Paykit.ContactRecord? { + try await withStateRevisionTracking { sdk in + try await sdk.removeContact(publicKey: publicKey) + } + } - static func initiateEncryptedLink(secretKeyHex: String, receiverPublicKey: String) async throws -> String { - try await ServiceQueue.background(.core, wrapErrors: false) { - try await paykitInitiateEncryptedLink(secretKeyHex: secretKeyHex, receiverPublicKey: receiverPublicKey) + func resolveContactProfile(publicKey: String, allowPubkyProfileFallback: Bool) async throws -> Paykit.ContactProfileResolution? { + try await operationLock.withLock { + try await handle().resolveContactProfile(publicKey: publicKey, allowPubkyProfileFallback: allowPubkyProfileFallback) } } - static func acceptEncryptedLink(secretKeyHex: String, senderPublicKey: String) async throws -> String { - try await ServiceQueue.background(.core, wrapErrors: false) { - try await paykitAcceptEncryptedLink(secretKeyHex: secretKeyHex, senderPublicKey: senderPublicKey) + func syncPublicEndpoints(_ endpoints: [PublicPaykitService.Endpoint]) async throws -> EndpointSyncReport { + try await withStateRevisionTracking { sdk in + try await sdk.syncPublicEndpointsWithReceivingDetails(receivingDetails: endpoints.map(\.paykitReceivingDetail)) } } - static func advanceHandshake(handshakeId: String) async throws -> FfiHandshakeProgress { - try await ServiceQueue.background(.core, wrapErrors: false) { - try await paykitAdvanceHandshake(handshakeId: handshakeId) + func syncPrivatePaymentListsWithReservations( + _ updates: [PrivatePaymentListReservationUpdateInput], + clearUnlistedLinkedPeers: Bool + ) async throws -> PrivatePaymentListDeliveryReport { + return try await withStateRevisionTracking { sdk in + try await sdk.syncPrivatePaymentListsWithReservationsAndProcessOutbound( + updates: updates, + clearUnlistedLinkedPeers: clearUnlistedLinkedPeers + ) } } - static func restoreEncryptedLink(secretKeyHex: String, snapshotHex: String) async throws -> String { - try await ServiceQueue.background(.core, wrapErrors: false) { - try await paykitRestoreEncryptedLink(secretKeyHex: secretKeyHex, snapshotHex: snapshotHex) + func ensureLinkWithPeer(_ counterparty: String, maxAdvanceSteps: UInt32 = 8) async throws -> LinkedPeerHandshakeReport { + try await withStateRevisionTracking { sdk in + try await sdk.ensureLinkWithPeer(counterparty: counterparty, maxAdvanceSteps: maxAdvanceSteps) } } - static func encryptedLinkSnapshotRecipient(snapshotHex: String) async throws -> String { - try await ServiceQueue.background(.core, wrapErrors: false) { - try paykitEncryptedLinkSnapshotRecipient(snapshotHex: snapshotHex) + func clearPrivatePaymentList(to counterparty: String) async throws -> PrivatePaymentListDeliveryReport { + try await withStateRevisionTracking { sdk in + try await sdk.clearPrivatePaymentListAndProcessOutbound(counterparty: counterparty) } } - static func restoreEncryptedLinkHandshake(secretKeyHex: String, snapshotHex: String) async throws -> String { - try await ServiceQueue.background(.core, wrapErrors: false) { - try await paykitRestoreEncryptedLinkHandshake(secretKeyHex: secretKeyHex, snapshotHex: snapshotHex) + func receivePrivateMessagesFromLinkedPeers() async throws { + try await withStateRevisionTracking { sdk in + _ = try await sdk.receivePrivateMessagesFromLinkedPeers() } } - static func encryptedLinkHandshakeSnapshotRecipient(snapshotHex: String) async throws -> String { - try await ServiceQueue.background(.core, wrapErrors: false) { - try paykitEncryptedLinkHandshakeSnapshotRecipient(snapshotHex: snapshotHex) + func processPendingPrivateMessages() async throws { + try await withStateRevisionTracking { sdk in + _ = try await sdk.processPendingPrivateMessages() } } - static func serializeEncryptedLink(linkId: String) async throws -> String { - try await ServiceQueue.background(.core, wrapErrors: false) { - try await paykitSerializeEncryptedLink(linkId: linkId) + func prepareAndResolveContactPayment(counterparty: String, includePublicEndpoints: Bool) async throws -> PreparedContactPayment { + try await withStateRevisionTracking { sdk in + try await sdk.prepareAndResolveContactPayment( + counterparty: counterparty, + amount: nil, + includePublicEndpoints: includePublicEndpoints, + maxAdvanceSteps: 8 + ) } } - static func serializeEncryptedLinkHandshake(handshakeId: String) async throws -> String { - try await ServiceQueue.background(.core, wrapErrors: false) { - try await paykitSerializeEncryptedLinkHandshake(handshakeId: handshakeId) + func resolvePublicContactPayment(counterparty: String) async throws -> ContactPaymentResolution { + try await operationLock.withLock { + try await handle().resolvePublicContactPayment(counterparty: counterparty, amount: nil) } } - static func closeEncryptedLink(linkId: String) async throws { - try await ServiceQueue.background(.core, wrapErrors: false) { - try await paykitCloseEncryptedLink(linkId: linkId) + func exportBackupState() async throws -> String { + try await operationLock.withLock { + try await handle().exportBackupString() } } - static func dropEncryptedLinkHandshake(handshakeId: String) async throws { - try await ServiceQueue.background(.core, wrapErrors: false) { - try await paykitDropEncryptedLinkHandshake(handshakeId: handshakeId) + func restoreBackupState(_ backup: String) async throws { + try await withStateRevisionTracking { sdk in + _ = try await sdk.restoreBackupString(backup: backup) } + resetRuntime() } - static func setPrivatePayments(linkId: String, entries: [FfiPaymentEntry]) async throws { - try await ServiceQueue.background(.core, wrapErrors: false) { - let payload = FfiPrivatePaymentsPayload(reference: paykitGeneratePaymentReference(), entries: entries) - try await paykitSetPrivatePayments(linkId: linkId, payload: payload) + func signOut() async throws { + try await withStateRevisionTracking { sdk in + _ = try await sdk.signOut() } + resetRuntime() } - static func getPrivatePayments(linkId: String) async throws -> FfiPrivatePaymentsPayload? { - try await ServiceQueue.background(.core, wrapErrors: false) { - try await paykitGetPrivatePayments(linkId: linkId) + func forceSignOut() async { + await operationLock.withLock { + sessionProvider.clearLiveSessionAccess() + try? Keychain.delete(key: .paykitSession) + try? Keychain.delete(key: .pubkySecretKey) + clearStateLocked() } } - // MARK: - Sign Out + func clearSessionAccess() async { + await operationLock.withLock { + sessionProvider.clearLiveSessionAccess() + try? Keychain.delete(key: .paykitSession) + try? Keychain.delete(key: .pubkySecretKey) + activeAuthRequest = nil + resetRuntime() + markWalletBackupDataChanged() + } + } - static func signOut() async throws { - try await ServiceQueue.background(.core) { - try await paykitSignOut() + func clearState() async { + await operationLock.withLock { + clearStateLocked() } } - static func forceSignOut() async { - _ = try? await ServiceQueue.background(.core) { - await paykitForceSignOut() + private func clearStateLocked() { + try? Keychain.delete(key: .paykitSdkState) + activeAuthRequest = nil + resetRuntime() + markWalletBackupDataChanged() + } + + nonisolated static func localSecretKey(fromHex secretKeyHex: String) throws -> PubkyLocalSecretKey { + let hex = secretKeyHex.trimmingCharacters(in: .whitespacesAndNewlines) + guard hex.count.isMultiple(of: 2) else { + throw PaykitError.Identity(code: "invalid_secret_key", context: "Secret key hex has odd length") + } + let bytes = hex.hexaData + guard bytes.count == hex.count / 2 else { + throw PaykitError.Identity(code: "invalid_secret_key", context: "Secret key hex is invalid") + } + return PubkyLocalSecretKey(bytes: bytes) + } + + nonisolated static func secretKeyHex(from secretKey: PubkyLocalSecretKey) -> String { + secretKey.exportBytes().hex + } + + nonisolated static func requiredCapabilities() throws -> String { + try Paykit.requiredSessionCapabilities(config: config()) + } + + private func handle() throws -> PaykitSdk { + if let sdk { + return sdk + } + + let created = try PaykitSdk.withPaymentAdapter( + stateStore: stateStore, + sessionProvider: sessionProvider, + paymentAdapter: paymentAdapter, + config: Self.config() + ) + sdk = created + return created + } + + private func withStateRevisionTracking(_ operation: (PaykitSdk) async throws -> T) async throws -> T { + try await operationLock.withLock { + let sdk = try handle() + let previousRevision = try? sdk.stateRevision() + do { + let result = try await operation(sdk) + markWalletBackupDataChangedIfNeeded(from: previousRevision, sdk: sdk) + return result + } catch { + markWalletBackupDataChangedIfNeeded(from: previousRevision, sdk: sdk) + throw error + } + } + } + + private func markWalletBackupDataChangedIfNeeded(from previousRevision: String?, sdk: PaykitSdk) { + guard let nextRevision = try? sdk.stateRevision(), previousRevision != nextRevision else { + return + } + markWalletBackupDataChanged() + } + + private func markWalletBackupDataChanged() { + Self.walletBackupDataChangedSubject.send() + } + + private func resetRuntime() { + sdk = nil + } + + private func persistSessionAccess(_ access: PubkySessionAccess, shouldStoreLocalSecret: Bool) throws { + guard let sessionData = access.exportSessionSecret().data(using: .utf8) else { + throw KeychainError.failedToSave + } + try Keychain.upsert(key: .paykitSession, data: sessionData) + + guard shouldStoreLocalSecret, let localSecret = access.exportLocalSecretKey() else { + try? Keychain.delete(key: .pubkySecretKey) + return + } + + guard let secretData = Self.secretKeyHex(from: localSecret).data(using: .utf8) else { + throw KeychainError.failedToSave + } + try Keychain.upsert(key: .pubkySecretKey, data: secretData) + } + + private func activateBootstrapResult( + _ result: PubkySessionBootstrapResult, + previousPublicKey: String?, + shouldStoreLocalSecret: Bool + ) async throws { + try persistSessionAccess(result.sessionAccess, shouldStoreLocalSecret: shouldStoreLocalSecret) + sessionProvider.setLiveSessionAccess(result.sessionAccess) + if !Self.publicKeysMatch(previousPublicKey, result.publicKey) { + try? Keychain.delete(key: .paykitSdkState) + } + resetRuntime() + _ = try await handle().initialize() + } + + private func currentSdkStatePublicKey() async -> String? { + do { + return try await handle().identityStatus()?.publicKey + } catch { + try? Keychain.delete(key: .paykitSdkState) + resetRuntime() + return nil + } + } + + private nonisolated static func publicKeysMatch(_ lhs: String?, _ rhs: String) -> Bool { + guard let lhs, + let normalizedLhs = try? Paykit.normalizePubkyPublicKey(value: lhs), + let normalizedRhs = try? Paykit.normalizePubkyPublicKey(value: rhs) + else { + return false + } + return normalizedLhs == normalizedRhs + } + + private func bootstrap() throws -> PubkySessionBootstrap { + try PubkySessionBootstrap() + } + + private nonisolated static func config() -> PaykitSdkConfig { + var config = Paykit.defaultConfig() + config.profileNamespace = switch Env.network { + case .bitcoin: "bitkit.to" + default: "staging.bitkit.to" } + config.endpointManagementScope = .managedOnly + config.encryptedLinkRecoveryMarkers = .enabled + config.publicContactSharing = .localOnly + return config + } +} + +private final class PaykitSdkOperationLock: @unchecked Sendable { + private let lock = NSLock() + private var isLocked = false + private var waiters: [CheckedContinuation] = [] + + func withLock(_ operation: () async throws -> T) async rethrows -> T { + await acquire() + defer { release() } + return try await operation() + } + + private func acquire() async { + await withCheckedContinuation { continuation in + lock.lock() + if isLocked { + waiters.append(continuation) + lock.unlock() + } else { + isLocked = true + lock.unlock() + continuation.resume() + } + } + } + + private func release() { + let nextWaiter: CheckedContinuation? + lock.lock() + if waiters.isEmpty { + isLocked = false + nextWaiter = nil + } else { + nextWaiter = waiters.removeFirst() + } + lock.unlock() + nextWaiter?.resume() + } +} + +extension PublicPaykitService.Endpoint { + var paykitReceivingDetail: ReceivingDetail { + ReceivingDetail( + identifier: methodId.rawValue, + payload: PaymentPayload(text: rawPayload) + ) + } +} + +private final class PaykitSdkStateBlobStore: SdkStateBlobStore, @unchecked Sendable { + private let lock = NSLock() + + init() {} + + func loadStateBlob() throws -> SdkStateBlobSnapshot? { + lock.lock() + defer { lock.unlock() } + + guard let data = try Keychain.load(key: .paykitSdkState) else { + return nil + } + + return try decodeSdkStateBlobSnapshot(bytes: data) + } + + func saveStateBlobAtomically(blob: SdkStateBlob, expectedRevision: String?) throws -> String { + lock.lock() + defer { lock.unlock() } + + let currentRevision = try Keychain.load(key: .paykitSdkState) + .map { try decodeSdkStateBlobSnapshot(bytes: $0).revision } + guard currentRevision == expectedRevision else { + throw PaykitError.Storage(code: "revision_conflict", context: "SDK state revision changed") + } + + let nextRevision = UUID().uuidString + let snapshot = SdkStateBlobSnapshot(blob: blob, revision: nextRevision) + let encoded = try encodeSdkStateBlobSnapshot(snapshot: snapshot) + try Keychain.upsert(key: .paykitSdkState, data: encoded) + return nextRevision + } +} + +private final class PaykitSdkSessionProvider: SdkPubkySessionProvider, @unchecked Sendable { + private let lock = NSLock() + private var liveSessionAccess: PubkySessionAccess? + + func setLiveSessionAccess(_ access: PubkySessionAccess) { + lock.lock() + liveSessionAccess = access + lock.unlock() + } + + func clearLiveSessionAccess() { + lock.lock() + liveSessionAccess = nil + lock.unlock() + } + + func loadSessionAccess() throws -> PubkySessionAccess? { + guard let sessionSecret = try Keychain.loadString(key: .paykitSession), !sessionSecret.isEmpty else { + return nil + } + + lock.lock() + let liveAccess = liveSessionAccess + lock.unlock() + + if liveAccess?.exportSessionSecret() == sessionSecret { + return liveAccess + } + + return try PubkySessionAccess( + sessionSecret: sessionSecret, + localSecretKey: loadLocalSecretKey() + ) + } + + func publicStorageAvailable() throws -> Bool { + true + } + + func clearSessionAccess() throws { + clearLiveSessionAccess() + try? Keychain.delete(key: .paykitSession) + try? Keychain.delete(key: .pubkySecretKey) + } + + func loadLocalSecretKey() throws -> PubkyLocalSecretKey? { + guard let secretKeyHex = try Keychain.loadString(key: .pubkySecretKey), !secretKeyHex.isEmpty else { + return nil + } + + return try PaykitSdkService.localSecretKey(fromHex: secretKeyHex) + } +} + +final class PaykitSdkPaymentAdapter: SdkPaymentAdapter, @unchecked Sendable { + func currentReceivingDetails(scope: ReceivingDetailScope) throws -> [ReceivingDetail] { + [] + } + + func reserveReceivingDetails(counterparty: String) throws -> ReceivingDetailReservationResponse { + ReceivingDetailReservationResponse(kind: .useCurrentReceivingDetails, reservations: []) + } + + func cancelReceivingDetailReservation(cancellation _: PaymentEndpointReservationCancellation) throws { + // Keeping unused reserved addresses/invoices out of reusable receive pools is safer than reusing leaked details. + } + + func selectPaymentEndpointIds(request: PaymentEndpointSelectionRequest) throws -> [String] { + let parsed = request.candidates.compactMap { candidate -> (id: String, endpoint: PublicPaykitService.Endpoint)? in + guard let endpoint = PublicPaykitService.parseEndpoint(candidate: candidate) else { + return nil + } + return (candidate.candidateId, endpoint) + } + + return PublicPaykitService.MethodId.payablePreferenceOrder.flatMap { methodId in + parsed.compactMap { $0.endpoint.methodId == methodId ? $0.id : nil } + } + } + + func buildPaymentTarget(endpoint: PaymentEndpointCandidate) throws -> PaymentTarget { + PaymentTarget(payload: endpoint.payload) } } diff --git a/Bitkit/Services/PublicPaykitService.swift b/Bitkit/Services/PublicPaykitService.swift index 97d94e998..aa429e228 100644 --- a/Bitkit/Services/PublicPaykitService.swift +++ b/Bitkit/Services/PublicPaykitService.swift @@ -1,12 +1,14 @@ import BitkitCore import Foundation import LDKNode +import Paykit enum PublicPaykitError: LocalizedError { case noSupportedEndpoint case walletNotReady case invalidPayload case routeHintsUnavailable + case publicationFailed var errorDescription: String? { switch self { @@ -18,6 +20,8 @@ enum PublicPaykitError: LocalizedError { return "The public payment endpoint payload is invalid." case .routeHintsUnavailable: return "A reachable Lightning payment endpoint is not available yet." + case .publicationFailed: + return "Bitkit could not publish Paykit payment endpoints." } } } @@ -75,6 +79,15 @@ enum PublicPaykitService { static let publishingEnabledKey = "sharesPublicPaykitEndpoints" static let lightningPaymentOptionEnabledKey = "paykitPaymentOptionLightningEnabled" static let onchainPaymentOptionEnabledKey = "paykitPaymentOptionOnchainEnabled" + static let cleanupPendingKey = "publicPaykitCleanupPending" + + static func setCleanupPending(_ isPending: Bool) { + UserDefaults.standard.set(isPending, forKey: cleanupPendingKey) + } + + static var isCleanupPending: Bool { + UserDefaults.standard.bool(forKey: cleanupPendingKey) + } enum MethodId: String, Hashable, CaseIterable { case bitcoinLightningBolt11 = "btc-lightning-bolt11" @@ -180,18 +193,13 @@ enum PublicPaykitService { } } - struct EndpointSyncPlan: Equatable { - let endpointsToSet: [Endpoint] - let methodIdsToRemove: [MethodId] - } - static func fetchPublicEndpoints(publicKey: String) async throws -> [Endpoint] { let normalizedKey = PubkyPublicKeyFormat.normalized(publicKey) ?? publicKey - let paymentEntries = try await PubkyService.getPaymentList(publicKey: normalizedKey) + let resolution = try await PaykitSdkService.shared.resolvePublicContactPayment(counterparty: normalizedKey) var endpointsByMethodId: [MethodId: Endpoint] = [:] - for entry in paymentEntries { - guard let endpoint = parseEndpoint(methodId: entry.methodId, endpointData: entry.endpointData) else { + for resolvedEndpoint in resolution.payableEndpoints { + guard let endpoint = parseEndpoint(identifier: resolvedEndpoint.identifier, payload: resolvedEndpoint.target.payload) else { continue } @@ -219,6 +227,14 @@ enum PublicPaykitService { ) } + static func parseEndpoint(identifier: String, payload: PaymentPayload) -> Endpoint? { + parseEndpoint(methodId: identifier, endpointData: payload.exportText()) + } + + static func parseEndpoint(candidate: PaymentEndpointCandidate) -> Endpoint? { + parseEndpoint(identifier: candidate.identifier, payload: candidate.payload) + } + static func serializePayload(value: String) throws -> String { let trimmedValue = value.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmedValue.isEmpty else { @@ -251,13 +267,7 @@ enum PublicPaykitService { } static func removePublishedEndpoints() async throws { - try await endpointLock.withLock { - let existingMethodIds = try await currentPublishedMethodIds() - - for methodId in methodIdsToRemoveWhenUnpublishing(existingMethodIds: existingMethodIds) { - try await PubkyService.removePaymentEndpoint(methodId: methodId.rawValue) - } - } + try await applyPublishedEndpoints([]) } static func hasPayablePublicEndpoint(publicKey: String) async throws -> Bool { @@ -326,10 +336,6 @@ enum PublicPaykitService { return MethodId.onchainMethodId(network: network, scriptType: scriptType) } - static func methodIdsToRemoveWhenUnpublishing(existingMethodIds: Set) -> [MethodId] { - MethodId.publishableMethodIds.filter { existingMethodIds.contains($0) } - } - static func isLightningPaymentOptionEnabled(defaults: UserDefaults = .standard) -> Bool { defaults.object(forKey: lightningPaymentOptionEnabledKey) as? Bool ?? true } @@ -346,14 +352,6 @@ enum PublicPaykitService { return invoice.routeHints().contains { !$0.isEmpty } } - static func publishedEndpointSyncPlan(existingEndpoints: [MethodId: String], desiredEndpoints: [Endpoint]) -> EndpointSyncPlan { - let desiredMethodIds = Set(desiredEndpoints.map(\.methodId)) - return EndpointSyncPlan( - endpointsToSet: desiredEndpoints.filter { existingEndpoints[$0.methodId] != $0.rawPayload }, - methodIdsToRemove: MethodId.publishableMethodIds.filter { existingEndpoints[$0] != nil && !desiredMethodIds.contains($0) } - ) - } - private struct ParsedPayload { let value: String let min: String? @@ -383,45 +381,13 @@ enum PublicPaykitService { private static func applyPublishedEndpoints(_ desiredEndpoints: [Endpoint]) async throws { try await endpointLock.withLock { - let existingEndpoints = try await currentPublishedEndpoints() - let plan = publishedEndpointSyncPlan(existingEndpoints: existingEndpoints, desiredEndpoints: desiredEndpoints) - - for endpoint in plan.endpointsToSet { - try await PubkyService.setPaymentEndpoint( - methodId: endpoint.methodId.rawValue, - endpointData: endpoint.rawPayload - ) - } - - for methodId in plan.methodIdsToRemove { - try await PubkyService.removePaymentEndpoint(methodId: methodId.rawValue) + let report = try await PaykitSdkService.shared.syncPublicEndpoints(desiredEndpoints) + guard report.failed.isEmpty else { + throw PublicPaykitError.publicationFailed } } } - private static func currentPublishedMethodIds() async throws -> Set { - let endpoints = try await currentPublishedEndpoints() - return Set(endpoints.keys) - } - - private static func currentPublishedEndpoints() async throws -> [MethodId: String] { - guard let publicKey = await PubkyService.currentPublicKey() else { - throw PubkyServiceError.sessionNotActive - } - - let paymentEntries = try await PubkyService.getPaymentList(publicKey: publicKey) - var endpoints: [MethodId: String] = [:] - for entry in paymentEntries { - guard let methodId = MethodId(rawValue: entry.methodId) else { - continue - } - - endpoints[methodId] = entry.endpointData - } - - return endpoints - } - @MainActor private static func buildWalletEndpoints(wallet: WalletViewModel, refreshIfNeeded: Bool, requireEndpoint: Bool) async throws -> [Endpoint] { let includeOnchain = isOnchainPaymentOptionEnabled() diff --git a/Bitkit/Utilities/Keychain.swift b/Bitkit/Utilities/Keychain.swift index 25e2e1877..a05778260 100644 --- a/Bitkit/Utilities/Keychain.swift +++ b/Bitkit/Utilities/Keychain.swift @@ -7,7 +7,7 @@ enum KeychainEntryType { case pushNotificationPrivateKey // For secp256k1 shared secret when decrypting push payload case securityPin case paykitSession - case privatePaykitSecretState + case paykitSdkState case pubkySecretKey var storageKey: String { @@ -17,7 +17,7 @@ enum KeychainEntryType { case .pushNotificationPrivateKey: "push_notification_private_key" case .securityPin: "security_pin" case .paykitSession: "paykit_session" - case .privatePaykitSecretState: "private_paykit_secret_state" + case .paykitSdkState: "paykit_sdk_state" case .pubkySecretKey: "pubky_secret_key" } } diff --git a/Bitkit/ViewModels/SettingsViewModel.swift b/Bitkit/ViewModels/SettingsViewModel.swift index 98ec1d817..a366785a4 100644 --- a/Bitkit/ViewModels/SettingsViewModel.swift +++ b/Bitkit/ViewModels/SettingsViewModel.swift @@ -207,6 +207,7 @@ class SettingsViewModel: NSObject, ObservableObject { UserDefaults.standard.set(false, forKey: PaykitFeatureFlags.uiEnabledKey) UserDefaults.standard.set(false, forKey: PrivatePaykitService.publishingEnabledKey) UserDefaults.standard.set(false, forKey: PublicPaykitService.publishingEnabledKey) + UserDefaults.standard.set(false, forKey: PublicPaykitService.cleanupPendingKey) UserDefaults.standard.set(false, forKey: "hasConfirmedPublicPaykitEndpoints") UserDefaults.standard.set(true, forKey: PublicPaykitService.lightningPaymentOptionEnabledKey) UserDefaults.standard.set(true, forKey: PublicPaykitService.onchainPaymentOptionEnabledKey) diff --git a/Bitkit/ViewModels/WalletViewModel.swift b/Bitkit/ViewModels/WalletViewModel.swift index 6646fc71a..25ad5a4c5 100644 --- a/Bitkit/ViewModels/WalletViewModel.swift +++ b/Bitkit/ViewModels/WalletViewModel.swift @@ -1063,10 +1063,7 @@ class WalletViewModel: ObservableObject { guard case .routeHintsUnavailable = error else { throw error } - Logger.warn( - "Public Paykit Lightning invoice has no route hints yet; publishing without Lightning for now", - context: "WalletViewModel" - ) + Logger.warn("Public Paykit Lightning invoice has no route hints; publishing on-chain endpoint only", context: "WalletViewModel") } } } else if includeLightning { diff --git a/Bitkit/Views/Profile/EditProfileView.swift b/Bitkit/Views/Profile/EditProfileView.swift index 1f09f68df..75888d544 100644 --- a/Bitkit/Views/Profile/EditProfileView.swift +++ b/Bitkit/Views/Profile/EditProfileView.swift @@ -175,15 +175,18 @@ struct EditProfileView: View { } private func performDeleteProfile() async throws { - await PrivatePaykitService.shared.markProfileRecoveryPendingIfNeeded() try await contactsManager.deleteAllContacts() try await pubkyProfile.deleteProfile() navigation.path = [app.hasSeenProfileIntro ? .pubkyChoice : .profileIntro] } private func disconnectAfterFailedDelete() async { - await pubkyProfile.signOut() - navigation.path = [app.hasSeenProfileIntro ? .pubkyChoice : .profileIntro] + do { + try await pubkyProfile.signOut() + navigation.path = [app.hasSeenProfileIntro ? .pubkyChoice : .profileIntro] + } catch { + app.toast(type: .error, title: t("profile__sign_out_title"), description: error.localizedDescription) + } } // MARK: - Save Profile diff --git a/Bitkit/Views/Profile/PayContactsView.swift b/Bitkit/Views/Profile/PayContactsView.swift index 683d91ae0..c48afd02b 100644 --- a/Bitkit/Views/Profile/PayContactsView.swift +++ b/Bitkit/Views/Profile/PayContactsView.swift @@ -94,8 +94,10 @@ struct PayContactsView: View { hasConfirmedPublicPaykitEndpoints = true do { try await PublicPaykitService.syncPublishedEndpoints(wallet: wallet, publish: false) + PublicPaykitService.setCleanupPending(false) } catch { cleanupError = error + PublicPaykitService.setCleanupPending(true) Logger.warn("Failed to remove public Paykit endpoints while disabling contact payments: \(error)", context: "PayContactsView") } do { diff --git a/Bitkit/Views/Profile/ProfileView.swift b/Bitkit/Views/Profile/ProfileView.swift index 3e4f86872..bcb3695a5 100644 --- a/Bitkit/Views/Profile/ProfileView.swift +++ b/Bitkit/Views/Profile/ProfileView.swift @@ -197,7 +197,11 @@ struct ProfileView: View { private func performSignOut() async { isSigningOut = true - await pubkyProfile.signOut() + do { + try await pubkyProfile.signOut() + } catch { + app.toast(type: .error, title: t("profile__sign_out_title"), description: error.localizedDescription) + } isSigningOut = false } diff --git a/Bitkit/Views/Settings/DevSettings/LegacyRnRecoveryScreen.swift b/Bitkit/Views/Settings/DevSettings/LegacyRnRecoveryScreen.swift index 278b0f518..8b8b0cac7 100644 --- a/Bitkit/Views/Settings/DevSettings/LegacyRnRecoveryScreen.swift +++ b/Bitkit/Views/Settings/DevSettings/LegacyRnRecoveryScreen.swift @@ -329,7 +329,7 @@ struct LegacyRnRecoveryScreen: View { throw AppError(message: "Destination address unavailable", debugMessage: nil) } - let feeRate = (try? await CoreService.shared.blocktank.fees(refresh: false)) + let feeRate = await (try? CoreService.shared.blocktank.fees(refresh: false)) .map { TransactionSpeed.normal.getFeeRate(from: $0) } sweepPreview = try await CoreService.shared.utility.prepareLegacyRnNativeSegwitRecoverySweep( diff --git a/Bitkit/Views/Settings/DevSettingsView.swift b/Bitkit/Views/Settings/DevSettingsView.swift index 9c6542d61..5cfc5676b 100644 --- a/Bitkit/Views/Settings/DevSettingsView.swift +++ b/Bitkit/Views/Settings/DevSettingsView.swift @@ -194,7 +194,7 @@ struct DevSettingsView: View { } } } message: { - Text("Paykit features are still experimental and may not work reliably until supporting homeserver changes are deployed.") + Text("Paykit features are experimental and may not work reliably.") } } diff --git a/Bitkit/Views/Settings/General/PaymentPreferenceView.swift b/Bitkit/Views/Settings/General/PaymentPreferenceView.swift index e4426b659..d0cd3adc5 100644 --- a/Bitkit/Views/Settings/General/PaymentPreferenceView.swift +++ b/Bitkit/Views/Settings/General/PaymentPreferenceView.swift @@ -161,9 +161,7 @@ struct PaymentPreferenceView: View { return } - if !previousCleanupPending { - PrivatePaykitService.setContactSharingCleanupPending(false) - } + PrivatePaykitService.setContactSharingCleanupPending(false) hasConfirmedPublicPaykitEndpoints = true } else { do { diff --git a/BitkitTests/PrivatePaykitServiceTests.swift b/BitkitTests/PrivatePaykitServiceTests.swift index 13b4b4106..50e0eab81 100644 --- a/BitkitTests/PrivatePaykitServiceTests.swift +++ b/BitkitTests/PrivatePaykitServiceTests.swift @@ -1,153 +1,91 @@ @testable import Bitkit -import Combine -import Paykit import XCTest final class PrivatePaykitServiceTests: XCTestCase { - func testRoleSelectionUsesLexicographicPubkyKeys() { - let lower = "pubky1111111111111111111111111111111111111111111111111111" - let higher = "pubkyzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz" - - XCTAssertTrue(PrivatePaykitService.shouldInitiate(ownPublicKey: higher, remotePublicKey: lower)) - XCTAssertFalse(PrivatePaykitService.shouldInitiate(ownPublicKey: lower, remotePublicKey: higher)) - } - - func testPrivatePayloadLimitAcceptsV1Envelope() throws { - let invoicePayload = try PublicPaykitService.serializePayload(value: "lnbc1privateinvoice") - let addressPayload = try PublicPaykitService.serializePayload(value: "bcrt1qprivateaddress") - + func testDuplicatePaymentErrorClassificationUsesWrappedAppErrorReason() { XCTAssertTrue( - PrivatePaykitService.isNoisePayloadWithinLimit([ - PublicPaykitService.MethodId.bitcoinLightningBolt11.rawValue: invoicePayload, - PublicPaykitService.MethodId.regtestOnchainP2wpkh.rawValue: addressPayload, - ]) + PrivatePaykitService.isDuplicatePaymentError( + AppError(message: "Lightning payment failed", debugMessage: "Duplicate payment") + ) ) - } - func testPrivatePayloadLimitRejectsOversizedEnvelope() { XCTAssertFalse( - PrivatePaykitService.isNoisePayloadWithinLimit([ - PublicPaykitService.MethodId.bitcoinLightningBolt11.rawValue: String(repeating: "x", count: 1200), - ]) - ) - } - - func testPrivatePayloadLimitRejectsMapThatOnlyFitsWithoutEnvelope() throws { - let methodId = PublicPaykitService.MethodId.bitcoinLightningBolt11.rawValue - - for valueLength in 1 ... 1000 { - let paymentMap = [methodId: String(repeating: "x", count: valueLength)] - let rawMapSize = try JSONSerialization.data(withJSONObject: paymentMap).count - - if rawMapSize <= 1000, !PrivatePaykitService.isNoisePayloadWithinLimit(paymentMap) { - return - } - } - - XCTFail("Expected to find a payload where the raw entries map fits but the rc8 envelope exceeds the Noise limit") - } - - func testPrivateRemovalTombstoneMapFitsNoisePayloadLimitAndIsNotPayable() { - let tombstonePayload = #"{"value":""}"# - let tombstoneMap = Dictionary(uniqueKeysWithValues: PublicPaykitService.MethodId.publishableMethodIds.map { - ($0.rawValue, tombstonePayload) - }) - - XCTAssertTrue(PrivatePaykitService.isNoisePayloadWithinLimit(tombstoneMap)) - XCTAssertNil( - PublicPaykitService.parseEndpoint( - methodId: PublicPaykitService.MethodId.bitcoinLightningBolt11.rawValue, - endpointData: tombstonePayload + PrivatePaykitService.isDuplicatePaymentError( + AppError(message: "Lightning payment failed", debugMessage: "Route not found") ) ) } - func testRecoveryMarkerPathIsStableAndDirectional() throws { - let alice = "pubky1111111111111111111111111111111111111111111111111111" - let bob = "pubkyzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz" + func testReceivedPrivateInvoiceHashKeepsContactAttribution() async { + let service = PrivatePaykitService() + let publicKey = "pubkycontact" - let aliceToBob = try XCTUnwrap(PrivatePaykitService.recoveryMarkerPath(from: alice, to: bob)) - let aliceToBobAgain = try XCTUnwrap(PrivatePaykitService.recoveryMarkerPath(from: alice, to: bob)) - let bobToAlice = try XCTUnwrap(PrivatePaykitService.recoveryMarkerPath(from: bob, to: alice)) + await service.rememberReceivedInvoicePaymentHash("payment-hash", publicKey: publicKey) - XCTAssertEqual(aliceToBob, aliceToBobAgain) - XCTAssertNotEqual(aliceToBob, bobToAlice) - XCTAssertTrue(aliceToBob.hasPrefix("/pub/paykit/v0/private-recovery/")) - XCTAssertTrue(aliceToBob.hasSuffix(".json")) + let matchedPublicKey = await service.contactPublicKey(forPrivateInvoicePaymentHash: "payment-hash") + XCTAssertEqual(matchedPublicKey, publicKey) } - func testNewerRecoveryMarkerReplacesRecentlyCompletedLink() async { + func testPrivateReservationAttributionMatchesSdkPublicationMetadata() async throws { let service = PrivatePaykitService() - let publicKey = "pubkycytinw71a3ge1esmzj5e53hsr3jtj6t4pogpgr6k75w9mzmyokzo" - await service.restoreBackup([ - publicKey: PrivatePaykitContactLinkBackupV1( - publicKey: publicKey, - linkSnapshotHex: nil, - handshakeSnapshotHex: nil, - remoteEndpoints: [:], - linkCompletedAt: 100, - handshakeUpdatedAt: nil, - recoveryStartedAt: nil, - mainRecoveryAttemptId: nil, - responderRecoveryAttemptId: nil + let publicKey = "pubkycontact" + await service.setTestLocalInvoice( + PrivatePaykitService.StoredInvoice( + bolt11: "lnbc1private", + paymentHash: "payment-hash", + expiresAt: 123 ), - ]) - - let marker = PrivatePaykitService.RecoveryMarker(version: 1, path: "", stage: "init", attemptId: "attempt", createdAt: 101) + publicKey: publicKey + ) - let shouldReplace = await service.shouldReplaceUsableLink(with: marker, publicKey: publicKey) + let endpoint = PublicPaykitService.Endpoint( + methodId: .bitcoinLightningBolt11, + value: "lnbc1private", + min: nil, + max: nil, + rawPayload: #"{"value":"lnbc1private"}"# + ) - XCTAssertTrue(shouldReplace) - } + let reservations = await service.reservations(from: [endpoint], publicKey: publicKey) + XCTAssertEqual(reservations.count, 1) + let reservation = try XCTUnwrap(reservations.first) + let attribution = reservation.attribution - func testStaleLinkFailureClassificationUsesTypedPaykitErrors() async { - let service = PrivatePaykitService() - let noiseFailure = await service.shouldCountAsStaleLinkFailure(PaykitFfiError.Transport(reason: "bad mac while decrypting payload")) - let linkHandleFailure = await service.shouldCountAsStaleLinkFailure(PaykitFfiError.Validation(reason: "Unknown encrypted-link handle: 123")) - let counterFailure = await service.shouldCountAsStaleLinkFailure(PaykitFfiError.Transport(reason: "counter mismatch")) - let networkFailure = await service.shouldCountAsStaleLinkFailure(PaykitFfiError.Transport(reason: "connection timed out")) - let sessionFailure = await service.shouldCountAsStaleLinkFailure(PaykitFfiError.Session(reason: "No active session")) - - XCTAssertTrue(noiseFailure) - XCTAssertTrue(linkHandleFailure) - XCTAssertFalse(counterFailure) - XCTAssertFalse(networkFailure) - XCTAssertFalse(sessionFailure) + XCTAssertEqual(attribution["type"], "private_paykit") + XCTAssertEqual(attribution["counterparty"], publicKey) + XCTAssertEqual(attribution["payment_hash"], "payment-hash") } - func testHandshakeTransportNotReadyIsPendingNotStaleState() async { + func testPrivateReservationIdChangesWhenEndpointPayloadChanges() async throws { let service = PrivatePaykitService() - let error = PaykitFfiError - .Transport(reason: "failed to transition to transport mode: IsHandshake: pubky-noise transition_transport failed: IsHandshake") - let isPending = await service.isEncryptedHandshakePendingError(error) - let isStaleState = await service.isEncryptedHandshakeStateFailure(error) - - XCTAssertTrue(isPending) - XCTAssertFalse(isStaleState) - } - - func testDuplicatePaymentErrorClassificationUsesWrappedAppErrorReason() { - XCTAssertTrue( - PrivatePaykitService.isDuplicatePaymentError( - AppError(message: "Lightning payment failed", debugMessage: "Duplicate payment") - ) + let publicKey = "pubkycontact" + let firstEndpoint = PublicPaykitService.Endpoint( + methodId: .regtestOnchainP2wpkh, + value: "bcrt1qfirst", + min: nil, + max: nil, + rawPayload: #"{"value":"bcrt1qfirst"}"# ) - - XCTAssertFalse( - PrivatePaykitService.isDuplicatePaymentError( - AppError(message: "Lightning payment failed", debugMessage: "Route not found") - ) + let secondEndpoint = PublicPaykitService.Endpoint( + methodId: .regtestOnchainP2wpkh, + value: "bcrt1qsecond", + min: nil, + max: nil, + rawPayload: #"{"value":"bcrt1qsecond"}"# ) - } - func testReceivedPrivateInvoiceHashKeepsContactAttribution() async { - let service = PrivatePaykitService() - let publicKey = "pubkycontact" + let firstReservations = await service.reservations(from: [firstEndpoint], publicKey: publicKey) + let repeatedReservations = await service.reservations(from: [firstEndpoint], publicKey: publicKey) + let secondReservations = await service.reservations(from: [secondEndpoint], publicKey: publicKey) - await service.rememberReceivedInvoicePaymentHash("payment-hash", publicKey: publicKey) + let firstReservation = try XCTUnwrap(firstReservations.first) + let repeatedReservation = try XCTUnwrap(repeatedReservations.first) + let secondReservation = try XCTUnwrap(secondReservations.first) - let matchedPublicKey = await service.contactPublicKey(forPrivateInvoicePaymentHash: "payment-hash") - XCTAssertEqual(matchedPublicKey, publicKey) + XCTAssertEqual(firstReservation.reservationId, repeatedReservation.reservationId) + XCTAssertNotEqual(firstReservation.reservationId, secondReservation.reservationId) + XCTAssertTrue(firstReservation.reservationId.hasPrefix("\(publicKey):\(firstEndpoint.methodId.rawValue):")) + XCTAssertLessThanOrEqual(firstReservation.reservationId.count, 128) } func testWalletBackupDecodesExistingPayloadWithoutPrivatePaykitFields() throws { @@ -156,16 +94,16 @@ final class PrivatePaykitServiceTests: XCTestCase { XCTAssertTrue(payload.transfers.isEmpty) XCTAssertNil(payload.privatePaykitHighestReservedReceiveIndexByAddressType) - XCTAssertNil(payload.privatePaykitContactLinks) + XCTAssertNil(payload.paykitSdkBackupState) } - func testWalletBackupRoundTripsPrivateReservationCeiling() throws { + func testWalletBackupRoundTripsPrivateReservationCeilingAndSdkState() throws { let backup = WalletBackupV1( version: 1, createdAt: 123, transfers: [], privatePaykitHighestReservedReceiveIndexByAddressType: ["nativeSegwit": 5], - privatePaykitContactLinks: nil + paykitSdkBackupState: "AQID" ) let data = try JSONEncoder().encode(backup) @@ -175,7 +113,7 @@ final class PrivatePaykitServiceTests: XCTestCase { XCTAssertEqual(decoded.createdAt, backup.createdAt) XCTAssertTrue(decoded.transfers.isEmpty) XCTAssertEqual(decoded.privatePaykitHighestReservedReceiveIndexByAddressType, backup.privatePaykitHighestReservedReceiveIndexByAddressType) - XCTAssertNil(decoded.privatePaykitContactLinks) + XCTAssertEqual(decoded.paykitSdkBackupState, backup.paykitSdkBackupState) } func testReservationStoreBacksUpRestoredCeiling() async throws { @@ -191,343 +129,41 @@ final class PrivatePaykitServiceTests: XCTestCase { XCTAssertNil(snapshot?["taproot"]) } - func testWalletBackupRoundTripsPrivateContactLinks() throws { - let backup = WalletBackupV1( - version: 1, - createdAt: 123, - transfers: [], - privatePaykitHighestReservedReceiveIndexByAddressType: nil, - privatePaykitContactLinks: [ - "pubkycontact": PrivatePaykitContactLinkBackupV1( - publicKey: "pubkycontact", - linkSnapshotHex: "abcd", - handshakeSnapshotHex: nil, - remoteEndpoints: [ - PublicPaykitService.MethodId.bitcoinLightningBolt11.rawValue: #"{"value":"lnbc1cached"}"#, - PublicPaykitService.MethodId.regtestOnchainP2wpkh.rawValue: #"{"value":"bcrt1qcached"}"#, - ], - linkCompletedAt: 456, - handshakeUpdatedAt: 123, - recoveryStartedAt: 789, - mainRecoveryAttemptId: "main-attempt", - responderRecoveryAttemptId: "responder-attempt", - awaitingRecoveredRemoteEndpoints: true - ), - ] - ) - - let data = try JSONEncoder().encode(backup) - let decoded = try JSONDecoder().decode(WalletBackupV1.self, from: data) - - XCTAssertEqual(decoded.version, backup.version) - XCTAssertEqual(decoded.createdAt, backup.createdAt) - XCTAssertTrue(decoded.transfers.isEmpty) - XCTAssertNil(decoded.privatePaykitHighestReservedReceiveIndexByAddressType) - XCTAssertEqual(decoded.privatePaykitContactLinks, backup.privatePaykitContactLinks) - XCTAssertEqual(decoded.privatePaykitContactLinks?["pubkycontact"]?.awaitingRecoveredRemoteEndpoints, true) - } - - func testPrivatePaykitStateStoresOnlySnapshotsInKeychainState() throws { + func testPrivatePaykitStateStoresOnlyAppOwnedAttributionState() throws { let publicKey = "pubkycontact" var contactState = PrivatePaykitService.ContactState() - contactState.linkSnapshotHex = "secret-link" - contactState.handshakeSnapshotHex = "secret-handshake" - contactState.remoteEndpoints = [ + contactState.cachedResolvedEndpoints = [ PrivatePaykitService.StoredPaymentEntry( methodId: PublicPaykitService.MethodId.bitcoinLightningBolt11.rawValue, endpointData: #"{"value":"lnbc1cached"}"# ), ] contactState.localInvoice = PrivatePaykitService.StoredInvoice(bolt11: "lnbc1local", paymentHash: "hash", expiresAt: 123) - contactState.lastLocalPayloadHash = "payload-hash" + contactState.receivedInvoicePaymentHashes = ["received-hash"] + contactState.hasPublishedPrivatePaymentList = true let state = PrivatePaykitService.PrivatePaykitState(contacts: [publicKey: contactState]) - let secretData = try JSONEncoder().encode(state.secretState) - let cacheData = try JSONEncoder().encode(state.cacheState) - let secretJson = try XCTUnwrap(String(data: secretData, encoding: .utf8)) - let cacheJson = try XCTUnwrap(String(data: cacheData, encoding: .utf8)) - - XCTAssertTrue(secretJson.contains("secret-link")) - XCTAssertTrue(secretJson.contains("secret-handshake")) - XCTAssertFalse(secretJson.contains("lnbc1cached")) - XCTAssertFalse(secretJson.contains("lnbc1local")) - XCTAssertFalse(secretJson.contains("payload-hash")) - - XCTAssertTrue(cacheJson.contains("lnbc1cached")) - XCTAssertTrue(cacheJson.contains("lnbc1local")) - XCTAssertTrue(cacheJson.contains("payload-hash")) - XCTAssertFalse(cacheJson.contains("secret-link")) - XCTAssertFalse(cacheJson.contains("secret-handshake")) - } - - func testCloseAndClearCanMarkProfileRecoveryPendingWhenPrivateContactStateExists() async { - let service = PrivatePaykitService() - let publicKey = "pubkycytinw71a3ge1esmzj5e53hsr3jtj6t4pogpgr6k75w9mzmyokzo" - await service.restoreBackup([ - publicKey: PrivatePaykitContactLinkBackupV1( - publicKey: publicKey, - linkSnapshotHex: nil, - handshakeSnapshotHex: nil, - remoteEndpoints: [:], - linkCompletedAt: 123, - handshakeUpdatedAt: nil, - recoveryStartedAt: nil, - mainRecoveryAttemptId: nil, - responderRecoveryAttemptId: nil - ), - ]) - - PrivatePaykitService.setProfileRecoveryPending(false) - await service.closeAndClear(markProfileRecoveryPending: true) - defer { PrivatePaykitService.setProfileRecoveryPending(false) } - - XCTAssertTrue(PrivatePaykitService.isProfileRecoveryPending) - } - - func testMarkProfileRecoveryPendingUsesPrivateContactStateWhenContactCleanupDefers() async { - let service = PrivatePaykitService() - let publicKey = "pubkycytinw71a3ge1esmzj5e53hsr3jtj6t4pogpgr6k75w9mzmyokzo" - await service.restoreBackup([ - publicKey: PrivatePaykitContactLinkBackupV1( - publicKey: publicKey, - linkSnapshotHex: nil, - handshakeSnapshotHex: nil, - remoteEndpoints: [:], - linkCompletedAt: 123, - handshakeUpdatedAt: nil, - recoveryStartedAt: nil, - mainRecoveryAttemptId: nil, - responderRecoveryAttemptId: nil - ), - ]) - - PrivatePaykitService.setProfileRecoveryPending(false) - PrivatePaykitService.setContactSharingCleanupPending(false) - await service.markProfileRecoveryPendingIfNeeded() - await service.pruneUnsavedContactState(savedPublicKeys: []) - defer { - PrivatePaykitService.setProfileRecoveryPending(false) - PrivatePaykitService.setContactSharingCleanupPending(false) - } - - XCTAssertTrue(PrivatePaykitService.isProfileRecoveryPending) - XCTAssertTrue(UserDefaults.standard.bool(forKey: PrivatePaykitService.cleanupPendingKey)) - let snapshot = await service.backupSnapshot()?[publicKey] - XCTAssertEqual(snapshot?.linkCompletedAt, 123) - } - - func testProfileRecoveryPurgeFailureKeepsMarkerPending() async { - let service = PrivatePaykitService() - - PrivatePaykitService.setProfileRecoveryPending(false) - let error = await service.handleProfileRecoveryPurgeFailure(requireImmediatePublication: false) - defer { PrivatePaykitService.setProfileRecoveryPending(false) } - - XCTAssertNil(error) - XCTAssertTrue(PrivatePaykitService.isProfileRecoveryPending) - } - - func testProfileRecoveryPurgeFailureFailsImmediateMode() async { - let service = PrivatePaykitService() - - PrivatePaykitService.setProfileRecoveryPending(false) - let error = await service.handleProfileRecoveryPurgeFailure(requireImmediatePublication: true) - defer { PrivatePaykitService.setProfileRecoveryPending(false) } - - guard case .privateUnavailable = error as? PrivatePaykitError else { - return XCTFail("Expected privateUnavailable") - } - XCTAssertTrue(PrivatePaykitService.isProfileRecoveryPending) - } - - func testProfileRecoveryStateClearsOldEndpointMetadata() async { - let service = PrivatePaykitService() - let publicKey = "pubkycytinw71a3ge1esmzj5e53hsr3jtj6t4pogpgr6k75w9mzmyokzo" - let remoteEndpoints = [ - PublicPaykitService.MethodId.regtestOnchainP2wpkh.rawValue: #"{"value":"bcrt1qcached"}"#, - ] - await service.restoreBackup([ - publicKey: PrivatePaykitContactLinkBackupV1( - publicKey: publicKey, - linkSnapshotHex: nil, - handshakeSnapshotHex: nil, - remoteEndpoints: remoteEndpoints, - linkCompletedAt: 123, - handshakeUpdatedAt: 100, - recoveryStartedAt: nil, - mainRecoveryAttemptId: nil, - responderRecoveryAttemptId: nil - ), - ]) - - await service.markContactForProfileRecovery(publicKey, startedAt: 456) - let snapshot = await service.backupSnapshot()?[publicKey] - - XCTAssertEqual(snapshot?.recoveryStartedAt, 456) - XCTAssertNil(snapshot?.linkSnapshotHex) - XCTAssertNil(snapshot?.handshakeSnapshotHex) - XCTAssertEqual(snapshot?.remoteEndpoints, [:]) - XCTAssertNil(snapshot?.linkCompletedAt) - XCTAssertNil(snapshot?.handshakeUpdatedAt) - } - - func testPrivatePaymentDefersPublicFallbackAfterRecoveryLinkCompletesWithoutEndpoints() async { - let service = PrivatePaykitService() - var contactState = PrivatePaykitService.ContactState() - contactState.linkCompletedAt = 123 - contactState.lastCompletedRecoveryAttemptId = "attempt" - contactState.awaitingRecoveredRemoteEndpoints = true - - let shouldDefer = await service.shouldDeferPublicFallbackForPrivateRecovery(contactState: contactState) - - XCTAssertTrue(shouldDefer) - } - - func testPrivatePaymentDoesNotDeferPublicFallbackForConsumedRecoveredEndpoints() async { - let service = PrivatePaykitService() - var contactState = PrivatePaykitService.ContactState() - contactState.linkCompletedAt = 123 - contactState.lastCompletedRecoveryAttemptId = "attempt" - - let shouldDefer = await service.shouldDeferPublicFallbackForPrivateRecovery(contactState: contactState) - - XCTAssertFalse(shouldDefer) - } - - func testPrivatePaymentDoesNotDeferPublicFallbackForPendingNonRecoveryHandshake() async { - let service = PrivatePaykitService() - var contactState = PrivatePaykitService.ContactState() - contactState.handshakeSnapshotHex = "pending-handshake" - - let shouldDefer = await service.shouldDeferPublicFallbackForPrivateRecovery(contactState: contactState) - - XCTAssertFalse(shouldDefer) - } - - func testPrivatePaymentKeepsAwaitingRecoveredEndpointsUntilRemoteEntriesArrive() async { - let service = PrivatePaykitService() - let publicKey = "pubkycytinw71a3ge1esmzj5e53hsr3jtj6t4pogpgr6k75w9mzmyokzo" - await service.restoreBackup([ - publicKey: PrivatePaykitContactLinkBackupV1( - publicKey: publicKey, - linkSnapshotHex: nil, - handshakeSnapshotHex: nil, - remoteEndpoints: [:], - linkCompletedAt: 123, - handshakeUpdatedAt: nil, - recoveryStartedAt: nil, - mainRecoveryAttemptId: nil, - responderRecoveryAttemptId: nil, - awaitingRecoveredRemoteEndpoints: true - ), - ]) - - let snapshot = await service.backupSnapshot()?[publicKey] - let shouldDefer = await service.shouldDeferPublicFallbackForPrivateRecovery(publicKey: publicKey) - - XCTAssertTrue(shouldDefer) - XCTAssertEqual(snapshot?.awaitingRecoveredRemoteEndpoints, true) - } - - func testPrivatePaymentKeepsAwaitingRecoveredEndpointsForTombstones() async { - let service = PrivatePaykitService() - let publicKey = "pubkycytinw71a3ge1esmzj5e53hsr3jtj6t4pogpgr6k75w9mzmyokzo" - await service.restoreBackup([ - publicKey: PrivatePaykitContactLinkBackupV1( - publicKey: publicKey, - linkSnapshotHex: nil, - handshakeSnapshotHex: nil, - remoteEndpoints: [:], - linkCompletedAt: 123, - handshakeUpdatedAt: nil, - recoveryStartedAt: nil, - mainRecoveryAttemptId: nil, - responderRecoveryAttemptId: nil, - awaitingRecoveredRemoteEndpoints: true - ), - ]) - - await service.cacheRemoteEndpoints( - [ - FfiPaymentEntry( - methodId: PublicPaykitService.MethodId.bitcoinLightningBolt11.rawValue, - endpointData: PrivatePaykitService.privateEndpointRemovalPayload - ), - FfiPaymentEntry( - methodId: PublicPaykitService.MethodId.regtestOnchainP2wpkh.rawValue, - endpointData: PrivatePaykitService.privateEndpointRemovalPayload - ), - ], - publicKey: publicKey - ) - - let result = await service.cachedPrivatePaymentResult(publicKey: publicKey) - let snapshot = await service.backupSnapshot()?[publicKey] - let shouldDefer = await service.shouldDeferPublicFallbackForPrivateRecovery(publicKey: publicKey) - - guard case .notOpened = result else { - return XCTFail("Expected tombstones to be non-payable") - } - XCTAssertTrue(shouldDefer) - XCTAssertEqual(snapshot?.awaitingRecoveredRemoteEndpoints, true) - } - - func testPrivatePaymentClearingAwaitingRecoveredEndpointsMarksWalletBackupChanged() async { - let service = PrivatePaykitService() - let publicKey = "pubkycytinw71a3ge1esmzj5e53hsr3jtj6t4pogpgr6k75w9mzmyokzo" - await service.restoreBackup([ - publicKey: PrivatePaykitContactLinkBackupV1( - publicKey: publicKey, - linkSnapshotHex: nil, - handshakeSnapshotHex: nil, - remoteEndpoints: [:], - linkCompletedAt: 123, - handshakeUpdatedAt: nil, - recoveryStartedAt: nil, - mainRecoveryAttemptId: nil, - responderRecoveryAttemptId: nil, - awaitingRecoveredRemoteEndpoints: true - ), - ]) - - let backupChanged = expectation(description: "private Paykit recovery marker clear marks wallet backup data changed") - let cancellable = PrivatePaykitService.walletBackupDataChangedPublisher.sink { - backupChanged.fulfill() - } - - await service.clearAwaitingRecoveredRemoteEndpoints(publicKey: publicKey) - await fulfillment(of: [backupChanged], timeout: 1) - - let snapshot = await service.backupSnapshot()?[publicKey] - XCTAssertNil(snapshot?.awaitingRecoveredRemoteEndpoints) - _ = cancellable + let data = try JSONEncoder().encode(state) + let json = try XCTUnwrap(String(data: data, encoding: .utf8)) + + XCTAssertTrue(json.contains("lnbc1cached")) + XCTAssertTrue(json.contains("lnbc1local")) + XCTAssertTrue(json.contains("received-hash")) + let decoded = try JSONDecoder().decode(PrivatePaykitService.PrivatePaykitState.self, from: data) + let decodedContact = try XCTUnwrap(decoded.contacts[publicKey]) + XCTAssertEqual(decodedContact.cachedResolvedEndpoints.first?.methodId, PublicPaykitService.MethodId.bitcoinLightningBolt11.rawValue) + XCTAssertEqual(decodedContact.cachedResolvedEndpoints.first?.endpointData, #"{"value":"lnbc1cached"}"#) + XCTAssertEqual(decodedContact.localInvoice?.bolt11, "lnbc1local") + XCTAssertEqual(decodedContact.localInvoice?.paymentHash, "hash") + XCTAssertEqual(decodedContact.localInvoice?.expiresAt, 123) + XCTAssertEqual(decodedContact.receivedInvoicePaymentHashes, ["received-hash"]) + XCTAssertTrue(decodedContact.hasPublishedPrivatePaymentList) } +} - func testPrivatePaymentDoesNotRetryGenericPrivateUnavailableBeforePublicFallback() async throws { - let service = PrivatePaykitService() - let publicKey = "pubkycytinw71a3ge1esmzj5e53hsr3jtj6t4pogpgr6k75w9mzmyokzo" - let result: Result = .failure(PrivatePaykitError.privateUnavailable) - - let shouldRetry = try await service.shouldRetryPrivatePaymentBeforePublicFallback( - publicKey: publicKey, - result: result, - shouldDeferPublicFallback: false - ) - - XCTAssertFalse(shouldRetry) - } - - func testPrivatePaymentRetriesPrivateUnavailableDuringRecovery() async throws { - let service = PrivatePaykitService() - let publicKey = "pubkycytinw71a3ge1esmzj5e53hsr3jtj6t4pogpgr6k75w9mzmyokzo" - let result: Result = .failure(PrivatePaykitError.privateUnavailable) - - let shouldRetry = try await service.shouldRetryPrivatePaymentBeforePublicFallback( - publicKey: publicKey, - result: result, - shouldDeferPublicFallback: true - ) - - XCTAssertTrue(shouldRetry) +private extension PrivatePaykitService { + func setTestLocalInvoice(_ invoice: StoredInvoice, publicKey: String) { + state.contacts[publicKey] = ContactState() + state.contacts[publicKey]?.localInvoice = invoice } } diff --git a/BitkitTests/PubkyProfileManagerTests.swift b/BitkitTests/PubkyProfileManagerTests.swift index 9e3595751..d096447f9 100644 --- a/BitkitTests/PubkyProfileManagerTests.swift +++ b/BitkitTests/PubkyProfileManagerTests.swift @@ -185,46 +185,6 @@ final class PubkyProfileManagerTests: XCTestCase { XCTAssertNil(resolved) } - // MARK: - Remote Profile Resolution - - func testResolveRemoteProfilePrefersBitkitProfile() async throws { - let bitkitProfile = makeProfile(publicKey: "pubky_test", name: "Bitkit") - let pubkyFallback = makeProfile(publicKey: "pubky_test", name: "Pubky") - - let resolved = try await PubkyProfileManager.resolveRemoteProfile( - publicKey: "pubky_test", - fetchBitkitProfile: { _ in bitkitProfile }, - fetchPubkyProfile: { _ in - XCTFail("Expected bitkit profile to win before pubky fallback") - return pubkyFallback - } - ) - - XCTAssertEqual(resolved.name, "Bitkit") - } - - func testResolveRemoteProfileFallsBackToPubkyProfile() async throws { - let fallbackProfile = makeProfile(publicKey: "pubky_test", name: "Pubky") - - let resolved = try await PubkyProfileManager.resolveRemoteProfile( - publicKey: "pubky_test", - fetchBitkitProfile: { _ in nil }, - fetchPubkyProfile: { _ in fallbackProfile } - ) - - XCTAssertEqual(resolved.name, "Pubky") - } - - func testResolveRemoteProfileThrowsWhenNoRemoteProfileExists() async { - await XCTAssertThrowsErrorAsync { - try await PubkyProfileManager.resolveRemoteProfile( - publicKey: "pubky_missing", - fetchBitkitProfile: { _ in nil }, - fetchPubkyProfile: { _ in throw PubkyServiceError.profileNotFound } - ) - } - } - func testIsMissingBitkitProfileStorageErrorRecognizes404DeleteFailure() { let error = AppError( message: "App Error", @@ -257,8 +217,6 @@ final class PubkyProfileManagerTests: XCTestCase { message: "App Error", debugMessage: #"BitkitCore.PubkyError.AuthFailed(reason: "Request failed: HTTP transport error: error sending request for url (https://example.com/session)")"# ) - var persistedSession: String? - let refreshed = await PubkyProfileManager.refreshSessionIfPossible( after: error, loadKeychainString: { key in @@ -273,11 +231,13 @@ final class PubkyProfileManagerTests: XCTestCase { XCTAssertEqual(secretKey, "local-secret") return "fresh-session" }, - persistSessionSecret: { persistedSession = $0 } + publicKeyFromSecretKey: { secretKey in + XCTAssertEqual(secretKey, "local-secret") + return "pubky_fresh" + } ) XCTAssertTrue(refreshed) - XCTAssertEqual(persistedSession, "fresh-session") } func testRefreshSessionIfPossibleReturnsFalseWithoutLocalSecret() async { @@ -293,8 +253,9 @@ final class PubkyProfileManagerTests: XCTestCase { XCTFail("Expected refresh to stop when no local secret key exists") return "fresh-session" }, - persistSessionSecret: { _ in - XCTFail("No refreshed session should be persisted") + publicKeyFromSecretKey: { _ in + XCTFail("No public key should be derived without a local secret") + return "pubky_unused" } ) @@ -333,8 +294,6 @@ final class PubkyProfileManagerTests: XCTestCase { } func testResolveSessionInitializationRestoresSavedSessionWithoutReSigningIn() async { - var persistedSession: String? - let result = await PubkyProfileManager.resolveSessionInitialization( savedSessionSecret: "saved-session", storedSecretKeyHex: "local-secret", @@ -346,38 +305,40 @@ final class PubkyProfileManagerTests: XCTestCase { XCTFail("Expected saved session import to succeed without re-sign-in") return "new-session" }, - persistSessionSecret: { persistedSession = $0 }, + publicKeyFromSecretKey: { _ in + XCTFail("Public key should not be derived after successful saved-session import") + return "pubky_unused" + }, deleteSessionSecret: { XCTFail("Session should not be deleted after successful import") } ) XCTAssertEqual(result, .restored(publicKey: "pubky_saved")) - XCTAssertNil(persistedSession) } func testResolveSessionInitializationSignsInWhenOnlySecretKeyExists() async { - var persistedSession: String? - let result = await PubkyProfileManager.resolveSessionInitialization( savedSessionSecret: nil, storedSecretKeyHex: "local-secret", importSession: { secret in - XCTAssertEqual(secret, "new-session") - return "pubky_test" + XCTFail("Re-signed local sessions should not be re-imported") + return "pubky_unused" }, signInWithSecretKey: { secretKey in XCTAssertEqual(secretKey, "local-secret") return "new-session" }, - persistSessionSecret: { persistedSession = $0 }, + publicKeyFromSecretKey: { secretKey in + XCTAssertEqual(secretKey, "local-secret") + return "pubky_test" + }, deleteSessionSecret: { XCTFail("Session should not be deleted after successful re-sign-in") } ) XCTAssertEqual(result, .restored(publicKey: "pubky_test")) - XCTAssertEqual(persistedSession, "new-session") } func testResolveSessionInitializationDeletesSavedSessionWhenReSignInFails() async { @@ -392,8 +353,9 @@ final class PubkyProfileManagerTests: XCTestCase { signInWithSecretKey: { _ in throw PubkyServiceError.authFailed("sign in failed") }, - persistSessionSecret: { _ in - XCTFail("No session should be persisted when re-sign-in fails") + publicKeyFromSecretKey: { _ in + XCTFail("No public key should be derived when re-sign-in fails") + return "pubky_unused" }, deleteSessionSecret: { deletedSavedSession = true } @@ -415,8 +377,9 @@ final class PubkyProfileManagerTests: XCTestCase { XCTFail("No sign-in should occur without credentials") return "unused-session" }, - persistSessionSecret: { _ in - XCTFail("No session should be persisted without credentials") + publicKeyFromSecretKey: { _ in + XCTFail("No public key should be derived without credentials") + return "pubky_unused" }, deleteSessionSecret: { XCTFail("No saved session exists to delete") } @@ -430,25 +393,32 @@ final class PubkyProfileManagerTests: XCTestCase { paykitSession: "stale-session", pubkySecretKey: "local-secret" ) - var didForceSignOut = false + var didClearSessionAccess = false try await PubkyProfileManager.restoreSessionBackupState( PubkySessionBackupV1(kind: .externalSession, sessionSecret: "external-session"), loadKeychainString: { key in store[key.storageKey] }, - persistKeychainString: { key, value in - store[key.storageKey] = value - }, deleteKeychainValue: { key in store.removeValue(forKey: key.storageKey) }, - forceSignOut: { - didForceSignOut = true + clearSessionAccess: { + didClearSessionAccess = true + }, + signInWithSecretKey: { _ in + XCTFail("External session restore should not sign in with a local secret") + return "unused-session" + }, + importExternalSession: { session in + XCTAssertEqual(session, "external-session") + store[KeychainEntryType.paykitSession.storageKey] = session + store.removeValue(forKey: KeychainEntryType.pubkySecretKey.storageKey) + return "pubky_external" } ) - XCTAssertTrue(didForceSignOut) + XCTAssertTrue(didClearSessionAccess) XCTAssertEqual(store[KeychainEntryType.paykitSession.storageKey], "external-session") XCTAssertNil(store[KeychainEntryType.pubkySecretKey.storageKey]) } @@ -464,13 +434,18 @@ final class PubkyProfileManagerTests: XCTestCase { loadKeychainString: { key in store[key.storageKey] }, - persistKeychainString: { key, value in - store[key.storageKey] = value - }, deleteKeychainValue: { key in store.removeValue(forKey: key.storageKey) }, - forceSignOut: {} + clearSessionAccess: {}, + signInWithSecretKey: { _ in + XCTFail("Missing pubky state should not sign in") + return "unused-session" + }, + importExternalSession: { _ in + XCTFail("Missing pubky state should not import a session") + return "pubky_unused" + } ) XCTAssertNil(store[KeychainEntryType.paykitSession.storageKey]) @@ -488,16 +463,19 @@ final class PubkyProfileManagerTests: XCTestCase { loadKeychainString: { key in store[key.storageKey] }, - persistKeychainString: { key, value in - store[key.storageKey] = value - }, deleteKeychainValue: { key in store.removeValue(forKey: key.storageKey) }, - forceSignOut: {} + clearSessionAccess: {}, + signInWithSecretKey: { secretKey in + XCTAssertFalse(secretKey.isEmpty) + store[KeychainEntryType.pubkySecretKey.storageKey] = secretKey + store[KeychainEntryType.paykitSession.storageKey] = "fresh-session" + return "fresh-session" + } ) - XCTAssertNil(store[KeychainEntryType.paykitSession.storageKey]) + XCTAssertEqual(store[KeychainEntryType.paykitSession.storageKey], "fresh-session") XCTAssertFalse(store[KeychainEntryType.pubkySecretKey.storageKey, default: ""].isEmpty) } @@ -509,7 +487,8 @@ final class PubkyProfileManagerTests: XCTestCase { createdAt: 123, tagMetadata: [], cache: makeAppCacheData(), - pubkySession: PubkySessionBackupV1(kind: .externalSession, sessionSecret: "session-secret") + pubkySession: PubkySessionBackupV1(kind: .externalSession, sessionSecret: "session-secret"), + pubkyContactProfileOverrides: nil ) let encoded = try JSONEncoder().encode(payload) @@ -527,7 +506,8 @@ final class PubkyProfileManagerTests: XCTestCase { createdAt: 123, tagMetadata: [], cache: makeAppCacheData(), - pubkySession: nil + pubkySession: nil, + pubkyContactProfileOverrides: nil ) let encoded = try JSONEncoder().encode(payload) diff --git a/BitkitTests/PublicPaykitServiceTests.swift b/BitkitTests/PublicPaykitServiceTests.swift index 06005e685..c128ca53d 100644 --- a/BitkitTests/PublicPaykitServiceTests.swift +++ b/BitkitTests/PublicPaykitServiceTests.swift @@ -4,6 +4,16 @@ import LDKNode import XCTest final class PublicPaykitServiceTests: XCTestCase { + override func setUp() { + super.setUp() + clearPaykitDefaults() + } + + override func tearDown() { + clearPaykitDefaults() + super.tearDown() + } + func testParseEndpointReadsSpecPayloadObject() { let endpoint = PublicPaykitService.parseEndpoint( methodId: "btc-lightning-bolt11", @@ -156,77 +166,16 @@ final class PublicPaykitServiceTests: XCTestCase { XCTAssertTrue(payable.isEmpty) } - func testMethodIdsToRemoveWhenUnpublishingOnlyIncludesBitkitManagedEndpoints() { - let methodIds = PublicPaykitService.methodIdsToRemoveWhenUnpublishing(existingMethodIds: [ - .bitcoinLightningBolt11, - .bitcoinLightningLnurl, - .bitcoinOnchainP2tr, - ]) - - XCTAssertEqual(methodIds, [.bitcoinLightningBolt11, .bitcoinOnchainP2tr]) - } + func testBuildAvailabilityMarksPublicCleanupPendingForPublishedPublicState() throws { + try withIsolatedDefaults { defaults in + defaults.set(true, forKey: "hasConfirmedPublicPaykitEndpoints") - func testPublishedEndpointSyncPlanRemovesStalePublishedMethods() { - let desired = [ - endpoint(.bitcoinLightningBolt11, value: "lnbc1invoice"), - endpoint(.bitcoinOnchainP2tr, value: "bc1ptaproot"), - ] - - let plan = PublicPaykitService.publishedEndpointSyncPlan( - existingEndpoints: [ - .bitcoinLightningBolt11: #"{"value":"oldinvoice"}"#, - .bitcoinOnchainP2wpkh: #"{"value":"bc1qsegwit"}"#, - .bitcoinOnchainP2sh: #"{"value":"3nested"}"#, - ], - desiredEndpoints: desired - ) - - XCTAssertEqual(plan.endpointsToSet, desired) - XCTAssertEqual(plan.methodIdsToRemove, [.bitcoinOnchainP2wpkh, .bitcoinOnchainP2sh]) - } - - func testPublishedEndpointSyncPlanSkipsUnchangedPublishedPayloads() { - let bolt11 = endpoint(.bitcoinLightningBolt11, value: "lnbc1invoice") - let taproot = endpoint(.bitcoinOnchainP2tr, value: "bc1ptaproot") - - let plan = PublicPaykitService.publishedEndpointSyncPlan( - existingEndpoints: [ - .bitcoinLightningBolt11: bolt11.rawPayload, - .bitcoinOnchainP2tr: #"{"value":"oldtaproot"}"#, - ], - desiredEndpoints: [bolt11, taproot] - ) + PaykitFeatureFlags.enforceBuildAvailability(defaults: defaults, isUIEnabled: false) - XCTAssertEqual(plan.endpointsToSet, [taproot]) - XCTAssertTrue(plan.methodIdsToRemove.isEmpty) - } - - func testPublishedEndpointSyncPlanPreservesExternallyOwnedLnurlEndpoint() { - let bolt11 = endpoint(.bitcoinLightningBolt11, value: "lnbc1invoice") - - let plan = PublicPaykitService.publishedEndpointSyncPlan( - existingEndpoints: [ - .bitcoinLightningLnurl: #"{"value":"lnurl1external"}"#, - ], - desiredEndpoints: [bolt11] - ) - - XCTAssertEqual(plan.endpointsToSet, [bolt11]) - XCTAssertTrue(plan.methodIdsToRemove.isEmpty) - } - - func testPublishedEndpointSyncPlanRemovesAllManagedEndpointsWhenDesiredSetIsEmpty() { - let plan = PublicPaykitService.publishedEndpointSyncPlan( - existingEndpoints: [ - .bitcoinLightningBolt11: #"{"value":"lnbc1old"}"#, - .bitcoinLightningLnurl: #"{"value":"lnurl1external"}"#, - .bitcoinOnchainP2wpkh: #"{"value":"bc1qold"}"#, - ], - desiredEndpoints: [] - ) - - XCTAssertTrue(plan.endpointsToSet.isEmpty) - XCTAssertEqual(plan.methodIdsToRemove, [.bitcoinLightningBolt11, .bitcoinOnchainP2wpkh]) + XCTAssertTrue(defaults.bool(forKey: PublicPaykitService.cleanupPendingKey)) + XCTAssertFalse(defaults.bool(forKey: "hasConfirmedPublicPaykitEndpoints")) + XCTAssertFalse(defaults.bool(forKey: PublicPaykitService.publishingEnabledKey)) + } } private func endpoint(_ methodId: PublicPaykitService.MethodId, value: String) -> PublicPaykitService.Endpoint { @@ -238,4 +187,22 @@ final class PublicPaykitServiceTests: XCTestCase { rawPayload: #"{"value":"\#(value)"}"# ) } + + private func clearPaykitDefaults() { + UserDefaults.standard.removeObject(forKey: PaykitFeatureFlags.uiEnabledKey) + UserDefaults.standard.removeObject(forKey: PublicPaykitService.publishingEnabledKey) + UserDefaults.standard.removeObject(forKey: PublicPaykitService.cleanupPendingKey) + UserDefaults.standard.removeObject(forKey: "hasConfirmedPublicPaykitEndpoints") + UserDefaults.standard.removeObject(forKey: "publicPaykitBolt11") + UserDefaults.standard.removeObject(forKey: "publicPaykitBolt11PaymentHash") + UserDefaults.standard.removeObject(forKey: "publicPaykitBolt11ExpiresAt") + } + + private func withIsolatedDefaults(_ body: (UserDefaults) throws -> Void) throws { + let suiteName = "PublicPaykitServiceTests.\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) + defaults.removePersistentDomain(forName: suiteName) + defer { defaults.removePersistentDomain(forName: suiteName) } + try body(defaults) + } } From 9a1aa9fa04c3a416db7a133afdcb29fa0e6e75c6 Mon Sep 17 00:00:00 2001 From: benk10 Date: Wed, 24 Jun 2026 13:28:56 +0300 Subject: [PATCH 2/4] fix: harden paykit edge cases --- Bitkit/Managers/ContactsManager.swift | 11 +++++ Bitkit/Managers/PubkyProfileManager.swift | 20 +++++++-- Bitkit/Models/PubkyAuthRequest.swift | 18 +++----- .../PrivatePaykitService+Backup.swift | 2 + .../PrivatePaykitService+Payments.swift | 2 +- Bitkit/Services/PubkyService.swift | 17 +++++++- Bitkit/Views/Profile/EditProfileView.swift | 2 +- Bitkit/Views/Profile/PayContactsView.swift | 43 +++++++++++++++---- Bitkit/Views/Settings/DevSettingsView.swift | 6 ++- 9 files changed, 93 insertions(+), 28 deletions(-) diff --git a/Bitkit/Managers/ContactsManager.swift b/Bitkit/Managers/ContactsManager.swift index 8db850b6b..9bcee6cd7 100644 --- a/Bitkit/Managers/ContactsManager.swift +++ b/Bitkit/Managers/ContactsManager.swift @@ -436,6 +436,17 @@ class ContactsManager: ObservableObject { Logger.info("Deleted all contacts", context: "ContactsManager") } + func deleteAllContactsBestEffort() async { + do { + try await deleteAllContacts() + } catch { + Logger.warn("Continuing after contact cleanup failed: \(error)", context: "ContactsManager") + Self.clearContactProfileOverrides() + await PrivatePaykitService.shared.pruneUnsavedContactState(savedPublicKeys: []) + contacts.removeAll() + } + } + // MARK: - Remote Contact Discovery @discardableResult diff --git a/Bitkit/Managers/PubkyProfileManager.swift b/Bitkit/Managers/PubkyProfileManager.swift index 32fb2287a..4b3632620 100644 --- a/Bitkit/Managers/PubkyProfileManager.swift +++ b/Bitkit/Managers/PubkyProfileManager.swift @@ -347,6 +347,7 @@ class PubkyProfileManager: ObservableObject { } func deleteProfile() async throws { + try await Self.removePrivatePaykitEndpoints(context: "PubkyProfileManager.deleteProfile") do { try await Task.detached { try await PubkyService.deletePaykitProfile() @@ -359,7 +360,7 @@ class PubkyProfileManager: ObservableObject { Logger.info("Bitkit profile storage already missing, continuing sign out", context: "PubkyProfileManager") } - try await signOut() + try await signOut(cleanPrivatePaykitEndpoints: false) } private func writeProfile( @@ -670,20 +671,33 @@ class PubkyProfileManager: ObservableObject { static func removePrivatePaykitEndpoints(context: String) async throws { do { try await PrivatePaykitService.shared.removePublishedEndpoints() + PrivatePaykitService.setContactSharingCleanupPending(false) } catch { + PrivatePaykitService.setContactSharingCleanupPending(true) Logger.warn("Failed to remove private Paykit endpoints before clearing session: \(error)", context: context) throw error } } static func removePrivatePaykitEndpointsBestEffort(context: String) async { - try? await removePrivatePaykitEndpoints(context: context) + do { + try await removePrivatePaykitEndpoints(context: context) + PrivatePaykitService.setContactSharingCleanupPending(false) + } catch { + PrivatePaykitService.setContactSharingCleanupPending(true) + } } func signOut() async throws { + try await signOut(cleanPrivatePaykitEndpoints: true) + } + + private func signOut(cleanPrivatePaykitEndpoints: Bool) async throws { try await Task.detached { + if cleanPrivatePaykitEndpoints { + try await Self.removePrivatePaykitEndpoints(context: "PubkyProfileManager.signOut") + } await Self.removePublicPaykitEndpointsBestEffort(context: "PubkyProfileManager.signOut") - try await Self.removePrivatePaykitEndpoints(context: "PubkyProfileManager.signOut") do { try await PubkyService.signOut() } catch { diff --git a/Bitkit/Models/PubkyAuthRequest.swift b/Bitkit/Models/PubkyAuthRequest.swift index fc661db37..56db8bfd7 100644 --- a/Bitkit/Models/PubkyAuthRequest.swift +++ b/Bitkit/Models/PubkyAuthRequest.swift @@ -1,5 +1,5 @@ -import BitkitCore import Foundation +import Paykit // MARK: - PubkyAuth Permission @@ -19,28 +19,24 @@ struct PubkyAuthPermission { struct PubkyAuthRequest { let rawUrl: String - let kind: PubkyAuthKind + let kind: Paykit.PubkyAuthRequestKind let relay: String let permissions: [PubkyAuthPermission] let serviceNames: [String] - /// Parse a `pubkyauth://` URL into a display-ready request. - /// Uses BitkitCore FFI `parsePubkyAuthUrl` for URL parsing, then extracts permissions from caps. static func parse(url: String) throws -> PubkyAuthRequest { - let details = try parsePubkyAuthUrl(authUrl: url) - let permissions = parseCapabilities(details.capabilities) + let details = try Paykit.parsePubkyAuthUrl(authUrl: url) + let permissions = parseCapabilities(details.capabilities ?? "") let serviceNames = permissions.compactMap { extractServiceName($0.path) } return PubkyAuthRequest( rawUrl: url, kind: details.kind, - relay: details.relay, + relay: details.relayUrl ?? "", permissions: permissions, serviceNames: serviceNames ) } - /// Parse a capabilities string like `/pub/pubky.app/:rw,/pub/paykit/v0/:rw` - /// into individual permission entries. static func parseCapabilities(_ caps: String) -> [PubkyAuthPermission] { caps .split(separator: ",") @@ -48,8 +44,6 @@ struct PubkyAuthRequest { let trimmed = segment.trimmingCharacters(in: .whitespaces) guard !trimmed.isEmpty else { return nil } - // Find the last `:` that separates path from access flags - // e.g., "/pub/pubky.app/:rw" → path="/pub/pubky.app/", access="rw" guard let lastColon = trimmed.lastIndex(of: ":") else { return nil } let path = String(trimmed[trimmed.startIndex ..< lastColon]) @@ -61,8 +55,6 @@ struct PubkyAuthRequest { } } - /// Extract a human-readable service name from a permission path. - /// e.g., "/pub/pubky.app/" → "pubky.app", "/pub/paykit/v0/" → "paykit" static func extractServiceName(_ path: String) -> String? { let components = path .trimmingCharacters(in: CharacterSet(charactersIn: "/")) diff --git a/Bitkit/Services/PrivatePaykitService+Backup.swift b/Bitkit/Services/PrivatePaykitService+Backup.swift index 4dedbfe49..5f2d3f376 100644 --- a/Bitkit/Services/PrivatePaykitService+Backup.swift +++ b/Bitkit/Services/PrivatePaykitService+Backup.swift @@ -20,6 +20,8 @@ extension PrivatePaykitService { } else { await PaykitSdkService.shared.clearState() } + Self.setContactSharingCleanupPending(false) + Self.clearDeletedContactCleanupPending() persistState(markWalletBackup: true) } } diff --git a/Bitkit/Services/PrivatePaykitService+Payments.swift b/Bitkit/Services/PrivatePaykitService+Payments.swift index 5804ab666..610ac0be3 100644 --- a/Bitkit/Services/PrivatePaykitService+Payments.swift +++ b/Bitkit/Services/PrivatePaykitService+Payments.swift @@ -81,7 +81,7 @@ extension PrivatePaykitService { "Failed to resolve Paykit contact payment for \(PubkyPublicKeyFormat.redacted(publicKey)): \(error)", context: "PrivatePaykit" ) - return .noEndpoint + return try await PublicPaykitService.beginPayment(to: publicKey) } } diff --git a/Bitkit/Services/PubkyService.swift b/Bitkit/Services/PubkyService.swift index 6fcf59318..621c1723a 100644 --- a/Bitkit/Services/PubkyService.swift +++ b/Bitkit/Services/PubkyService.swift @@ -188,6 +188,7 @@ actor PaykitSdkService { private let operationLock = PaykitSdkOperationLock() private var sdk: PaykitSdk? private var activeAuthRequest: Paykit.PubkyAuthRequest? + private var activeAuthRequestID: UUID? func initialize() async throws { try await operationLock.withLock { @@ -257,7 +258,9 @@ actor PaykitSdkService { func startAuth() async throws -> String { try await operationLock.withLock { let request = try bootstrap().startSignInAuth(capabilities: Self.requiredCapabilities()) + let requestID = UUID() activeAuthRequest = request + activeAuthRequestID = requestID return try await request.authorizationUrl() } } @@ -267,9 +270,15 @@ actor PaykitSdkService { guard let request = activeAuthRequest else { throw PubkyServiceError.invalidAuthUrl } + guard let requestID = activeAuthRequestID else { + throw PubkyServiceError.invalidAuthUrl + } defer { - activeAuthRequest = nil + if activeAuthRequestID == requestID { + activeAuthRequest = nil + activeAuthRequestID = nil + } } let previousPublicKey = await currentSdkStatePublicKey() @@ -277,6 +286,9 @@ actor PaykitSdkService { localSecretKey: nil, requiredCapabilities: Self.requiredCapabilities() ) + guard activeAuthRequestID == requestID, activeAuthRequest != nil else { + throw CancellationError() + } try await activateBootstrapResult(result, previousPublicKey: previousPublicKey, shouldStoreLocalSecret: false) markWalletBackupDataChanged() return result.sessionAccess.exportSessionSecret() @@ -285,6 +297,7 @@ actor PaykitSdkService { func cancelAuth() { activeAuthRequest = nil + activeAuthRequestID = nil } func approveAuth(authUrl: String, secretKeyHex: String) async throws { @@ -449,6 +462,7 @@ actor PaykitSdkService { try? Keychain.delete(key: .paykitSession) try? Keychain.delete(key: .pubkySecretKey) activeAuthRequest = nil + activeAuthRequestID = nil resetRuntime() markWalletBackupDataChanged() } @@ -463,6 +477,7 @@ actor PaykitSdkService { private func clearStateLocked() { try? Keychain.delete(key: .paykitSdkState) activeAuthRequest = nil + activeAuthRequestID = nil resetRuntime() markWalletBackupDataChanged() } diff --git a/Bitkit/Views/Profile/EditProfileView.swift b/Bitkit/Views/Profile/EditProfileView.swift index 75888d544..bf8fac0b1 100644 --- a/Bitkit/Views/Profile/EditProfileView.swift +++ b/Bitkit/Views/Profile/EditProfileView.swift @@ -175,7 +175,7 @@ struct EditProfileView: View { } private func performDeleteProfile() async throws { - try await contactsManager.deleteAllContacts() + await contactsManager.deleteAllContactsBestEffort() try await pubkyProfile.deleteProfile() navigation.path = [app.hasSeenProfileIntro ? .pubkyChoice : .profileIntro] } diff --git a/Bitkit/Views/Profile/PayContactsView.swift b/Bitkit/Views/Profile/PayContactsView.swift index c48afd02b..26dd868fd 100644 --- a/Bitkit/Views/Profile/PayContactsView.swift +++ b/Bitkit/Views/Profile/PayContactsView.swift @@ -88,7 +88,10 @@ struct PayContactsView: View { ) } } else { - var cleanupError: Error? + let previousSharesPrivate = sharesPrivatePaykitEndpoints + let previousSharesPublic = sharesPublicPaykitEndpoints + var publicCleanupError: Error? + var privateCleanupError: Error? sharesPrivatePaykitEndpoints = false sharesPublicPaykitEndpoints = false hasConfirmedPublicPaykitEndpoints = true @@ -96,23 +99,47 @@ struct PayContactsView: View { try await PublicPaykitService.syncPublishedEndpoints(wallet: wallet, publish: false) PublicPaykitService.setCleanupPending(false) } catch { - cleanupError = error + publicCleanupError = error PublicPaykitService.setCleanupPending(true) Logger.warn("Failed to remove public Paykit endpoints while disabling contact payments: \(error)", context: "PayContactsView") } do { try await PrivatePaykitService.shared.removePublishedEndpoints() } catch { - if cleanupError == nil { - cleanupError = error - } + privateCleanupError = error Logger.warn("Failed to remove private Paykit endpoints while disabling contact payments: \(error)", context: "PayContactsView") } - if let cleanupError { - PrivatePaykitService.setContactSharingCleanupPending(true) - throw cleanupError + + if publicCleanupError != nil { + sharesPublicPaykitEndpoints = previousSharesPublic } + + if let privateCleanupError { + var restoredPrivateSharing = false + if previousSharesPrivate { + sharesPrivatePaykitEndpoints = true + if let restoreError = await PrivatePaykitService.shared.prepareSavedContacts( + contactsManager.contacts.map(\.publicKey), + wallet: wallet, + requireImmediatePublication: true + ) { + sharesPrivatePaykitEndpoints = false + Logger.warn( + "Failed to restore private Paykit endpoints after cleanup failure: \(restoreError)", + context: "PayContactsView" + ) + } else { + restoredPrivateSharing = true + } + } + PrivatePaykitService.setContactSharingCleanupPending(!restoredPrivateSharing) + throw publicCleanupError ?? privateCleanupError + } + PrivatePaykitService.setContactSharingCleanupPending(false) + if let publicCleanupError { + throw publicCleanupError + } } navigation.path = [.profile] } catch { diff --git a/Bitkit/Views/Settings/DevSettingsView.swift b/Bitkit/Views/Settings/DevSettingsView.swift index 5cfc5676b..7d6a221d0 100644 --- a/Bitkit/Views/Settings/DevSettingsView.swift +++ b/Bitkit/Views/Settings/DevSettingsView.swift @@ -211,22 +211,25 @@ struct DevSettingsView: View { var cleanupError: Error? do { try await PublicPaykitService.syncPublishedEndpoints(wallet: wallet, publish: false) + PublicPaykitService.setCleanupPending(false) } catch { cleanupError = error + PublicPaykitService.setCleanupPending(true) Logger.warn("Failed to remove public Paykit endpoints after disabling Paykit UI: \(error)", context: "DevSettingsView") } do { try await PrivatePaykitService.shared.removePublishedEndpoints() + PrivatePaykitService.setContactSharingCleanupPending(false) } catch { if cleanupError == nil { cleanupError = error } + PrivatePaykitService.setContactSharingCleanupPending(true) Logger.warn("Failed to remove private Paykit endpoints after disabling Paykit UI: \(error)", context: "DevSettingsView") } if let cleanupError { - PrivatePaykitService.setContactSharingCleanupPending(true) app.toast( type: .error, title: "Paykit UI disabled", @@ -236,6 +239,7 @@ struct DevSettingsView: View { return } + PublicPaykitService.setCleanupPending(false) PrivatePaykitService.setContactSharingCleanupPending(false) app.toast(type: .success, title: "Paykit UI disabled", accessibilityIdentifier: "PaykitUiDisabledToast") } From fbaa3104796345aee54269d77fe59ed68108f2d8 Mon Sep 17 00:00:00 2001 From: benk10 Date: Wed, 24 Jun 2026 14:00:13 +0300 Subject: [PATCH 3/4] chore: polish paykit cleanup --- Bitkit/Managers/PubkyProfileManager.swift | 3 +-- .../Services/PrivatePaykitService+Contacts.swift | 2 +- Bitkit/Services/PrivatePaykitService+State.swift | 4 ---- Bitkit/Services/PrivatePaykitService.swift | 16 ++++++++++++---- Bitkit/Services/PubkyService.swift | 4 ++-- Bitkit/ViewModels/WalletViewModel.swift | 16 ---------------- BitkitTests/PubkyProfileManagerTests.swift | 8 ++++---- BitkitTests/PublicPaykitServiceTests.swift | 2 +- 8 files changed, 21 insertions(+), 34 deletions(-) diff --git a/Bitkit/Managers/PubkyProfileManager.swift b/Bitkit/Managers/PubkyProfileManager.swift index 4b3632620..495db2eac 100644 --- a/Bitkit/Managers/PubkyProfileManager.swift +++ b/Bitkit/Managers/PubkyProfileManager.swift @@ -1,5 +1,4 @@ import Foundation -import Paykit import SwiftUI enum PubkyAuthState: Equatable { @@ -825,7 +824,7 @@ class PubkyProfileManager: ObservableObject { switch backup?.kind { case .none: - // Missing pubky backup state clears restored pubky credentials, including legacy backups without this field. + // Backups without pubky state do not carry recoverable pubky credentials. try? deleteKeychainValue(.paykitSession) try? deleteKeychainValue(.pubkySecretKey) case .localSeed: diff --git a/Bitkit/Services/PrivatePaykitService+Contacts.swift b/Bitkit/Services/PrivatePaykitService+Contacts.swift index ec5b69c00..19f5c4be4 100644 --- a/Bitkit/Services/PrivatePaykitService+Contacts.swift +++ b/Bitkit/Services/PrivatePaykitService+Contacts.swift @@ -209,7 +209,7 @@ extension PrivatePaykitService { requireImmediatePublication: Bool ) async -> Error? { do { - return try await publicationLock.withLock { + return try await withPublicationLock { await syncLocalEndpointPublicationLocked( for: publicKeys, wallet: wallet, diff --git a/Bitkit/Services/PrivatePaykitService+State.swift b/Bitkit/Services/PrivatePaykitService+State.swift index 9203472d5..448950a84 100644 --- a/Bitkit/Services/PrivatePaykitService+State.swift +++ b/Bitkit/Services/PrivatePaykitService+State.swift @@ -33,8 +33,4 @@ extension PrivatePaykitService { markWalletBackupDataChanged() } } - - func markWalletBackupDataChanged() { - Self.walletBackupDataChangedSubject.send() - } } diff --git a/Bitkit/Services/PrivatePaykitService.swift b/Bitkit/Services/PrivatePaykitService.swift index 73cedb571..e7801bfa5 100644 --- a/Bitkit/Services/PrivatePaykitService.swift +++ b/Bitkit/Services/PrivatePaykitService.swift @@ -1,7 +1,7 @@ import Combine import Foundation -actor PrivatePaykitPublicationLock { +private actor PrivatePaykitPublicationLock { private var isLocked = false private var waiters: [CheckedContinuation] = [] @@ -37,7 +37,7 @@ actor PrivatePaykitPublicationLock { actor PrivatePaykitService { static let shared = PrivatePaykitService() - static let walletBackupDataChangedSubject = PassthroughSubject() + private static let walletBackupDataChangedSubject = PassthroughSubject() nonisolated static var walletBackupDataChangedPublisher: AnyPublisher { walletBackupDataChangedSubject.eraseToAnyPublisher() @@ -49,7 +49,7 @@ actor PrivatePaykitService { static let cleanupPendingKey = "paykitContactSharingCleanupPending" static let deletedContactCleanupKeysKey = "privatePaykitDeletedContactCleanupKeys" static let cacheStateKey = "privatePaykitCacheState" - // Private links can finish after a contact is added on the other device; keep draining long enough for staggered mutual adds. + /// Private links can finish after a contact is added on the other device; keep draining long enough for staggered mutual adds. static let privateMessageDrainRetryDelays: [UInt64] = [ 1_000_000_000, 3_000_000_000, @@ -62,13 +62,21 @@ actor PrivatePaykitService { var state: PrivatePaykitState var knownSavedContactKeys: Set = [] var pendingMessageDrainRetryTask: Task? - let publicationLock = PrivatePaykitPublicationLock() + private let publicationLock = PrivatePaykitPublicationLock() init() { state = UserDefaults.standard.data(forKey: Self.cacheStateKey) .flatMap { try? JSONDecoder().decode(PrivatePaykitState.self, from: $0) } ?? PrivatePaykitState(contacts: [:]) } + func withPublicationLock(_ operation: () async throws -> T) async throws -> T { + try await publicationLock.withLock(operation) + } + + func markWalletBackupDataChanged() { + Self.walletBackupDataChangedSubject.send() + } + static func setContactSharingCleanupPending(_ isPending: Bool) { UserDefaults.standard.set(isPending, forKey: cleanupPendingKey) } diff --git a/Bitkit/Services/PubkyService.swift b/Bitkit/Services/PubkyService.swift index 621c1723a..06084babe 100644 --- a/Bitkit/Services/PubkyService.swift +++ b/Bitkit/Services/PubkyService.swift @@ -176,7 +176,7 @@ enum PubkyService { actor PaykitSdkService { static let shared = PaykitSdkService() nonisolated static let pubkyDerivationRuntimeLabel = "bitkit" - static let walletBackupDataChangedSubject = PassthroughSubject() + private static let walletBackupDataChangedSubject = PassthroughSubject() nonisolated static var walletBackupDataChangedPublisher: AnyPublisher { walletBackupDataChangedSubject.eraseToAnyPublisher() @@ -751,7 +751,7 @@ private final class PaykitSdkSessionProvider: SdkPubkySessionProvider, @unchecke } } -final class PaykitSdkPaymentAdapter: SdkPaymentAdapter, @unchecked Sendable { +private final class PaykitSdkPaymentAdapter: SdkPaymentAdapter, @unchecked Sendable { func currentReceivingDetails(scope: ReceivingDetailScope) throws -> [ReceivingDetail] { [] } diff --git a/Bitkit/ViewModels/WalletViewModel.swift b/Bitkit/ViewModels/WalletViewModel.swift index 25ad5a4c5..1513284c1 100644 --- a/Bitkit/ViewModels/WalletViewModel.swift +++ b/Bitkit/ViewModels/WalletViewModel.swift @@ -165,22 +165,6 @@ class WalletViewModel: ObservableObject { MigrationsService.shared.pendingChannelMigration = nil } - // // If no local migration data, try fetching from RN remote backup (one-time) - // if channelMigration == nil { - // let (remoteMigration, allRetrieved) = await fetchOrphanedChannelMonitorsIfNeeded(walletIndex: walletIndex) - // if let remoteMigration { - // channelMigration = ChannelDataMigration( - // // don't overwrite channel manager, we only need the monitors for the sweep - // channelManager: nil, - // channelMonitors: remoteMigration.channelMonitors.map { [UInt8]($0) } - // ) - // MigrationsService.shared.pendingChannelMigration = nil - // } - // if allRetrieved { - // MigrationsService.shared.isChannelRecoveryChecked = true - // } - // } - await runLegacyNetworkGraphCleanupIfNeeded() try await lightningService.setup( diff --git a/BitkitTests/PubkyProfileManagerTests.swift b/BitkitTests/PubkyProfileManagerTests.swift index d096447f9..d91df975e 100644 --- a/BitkitTests/PubkyProfileManagerTests.swift +++ b/BitkitTests/PubkyProfileManagerTests.swift @@ -321,7 +321,7 @@ final class PubkyProfileManagerTests: XCTestCase { let result = await PubkyProfileManager.resolveSessionInitialization( savedSessionSecret: nil, storedSecretKeyHex: "local-secret", - importSession: { secret in + importSession: { _ in XCTFail("Re-signed local sessions should not be re-imported") return "pubky_unused" }, @@ -512,9 +512,9 @@ final class PubkyProfileManagerTests: XCTestCase { let encoded = try JSONEncoder().encode(payload) let json = try XCTUnwrap(JSONSerialization.jsonObject(with: encoded) as? [String: Any]) - let legacyJson = json.filter { $0.key != "pubkySession" } - let legacyData = try JSONSerialization.data(withJSONObject: legacyJson) - let decoded = try JSONDecoder().decode(MetadataBackupV1.self, from: legacyData) + let jsonWithoutPubkySession = json.filter { $0.key != "pubkySession" } + let dataWithoutPubkySession = try JSONSerialization.data(withJSONObject: jsonWithoutPubkySession) + let decoded = try JSONDecoder().decode(MetadataBackupV1.self, from: dataWithoutPubkySession) XCTAssertNil(decoded.pubkySession) XCTAssertEqual(decoded.cache.dismissedSuggestions, []) diff --git a/BitkitTests/PublicPaykitServiceTests.swift b/BitkitTests/PublicPaykitServiceTests.swift index c128ca53d..7fb6a263d 100644 --- a/BitkitTests/PublicPaykitServiceTests.swift +++ b/BitkitTests/PublicPaykitServiceTests.swift @@ -71,7 +71,7 @@ final class PublicPaykitServiceTests: XCTestCase { ) } - func testParseEndpointRejectsNonSpecLegacyLnurlMethodId() { + func testParseEndpointRejectsUnsupportedLnurlMethodId() { let endpoint = PublicPaykitService.parseEndpoint( methodId: "btc-lightning-lnurl-pay", endpointData: #"{"value":"lnurl1example"}"# From 703b5929082bace11f669d7633279008a356df71 Mon Sep 17 00:00:00 2001 From: benk10 Date: Wed, 24 Jun 2026 15:01:49 +0300 Subject: [PATCH 4/4] fix: harden paykit restore --- Bitkit/Managers/PubkyProfileManager.swift | 8 +++++ Bitkit/Services/BackupService.swift | 11 ++---- BitkitTests/PubkyProfileManagerTests.swift | 39 ++++++++++++++++++++++ 3 files changed, 50 insertions(+), 8 deletions(-) diff --git a/Bitkit/Managers/PubkyProfileManager.swift b/Bitkit/Managers/PubkyProfileManager.swift index 495db2eac..07a68070e 100644 --- a/Bitkit/Managers/PubkyProfileManager.swift +++ b/Bitkit/Managers/PubkyProfileManager.swift @@ -807,6 +807,12 @@ class PubkyProfileManager: ObservableObject { loadKeychainString: (KeychainEntryType) throws -> String? = { try Keychain.loadString(key: $0) }, + persistKeychainString: (KeychainEntryType, String) throws -> Void = { key, value in + guard let data = value.data(using: .utf8) else { + throw KeychainError.failedToSave + } + try Keychain.upsert(key: key, data: data) + }, deleteKeychainValue: (KeychainEntryType) throws -> Void = { try Keychain.delete(key: $0) }, @@ -829,6 +835,8 @@ class PubkyProfileManager: ObservableObject { try? deleteKeychainValue(.pubkySecretKey) case .localSeed: let secretKeyHex = try deriveLocalSecretKeyFromWalletSeed(loadKeychainString: loadKeychainString) + try persistKeychainString(.pubkySecretKey, secretKeyHex) + try? deleteKeychainValue(.paykitSession) _ = try await signInWithSecretKey(secretKeyHex) case .externalSession: guard let sessionSecret = backup?.sessionSecret, diff --git a/Bitkit/Services/BackupService.swift b/Bitkit/Services/BackupService.swift index 072190442..0a38bc7a6 100644 --- a/Bitkit/Services/BackupService.swift +++ b/Bitkit/Services/BackupService.swift @@ -247,16 +247,11 @@ class BackupService { } if didRestoreWalletBackup { - if pendingPaykitSdkBackupState != nil { + do { try await PrivatePaykitService.shared.restoreBackup(pendingPaykitSdkBackupState) await PrivatePaykitAddressReservationStore.shared.reconcileReservedIndexesWithLdk() - } else { - do { - try await PrivatePaykitService.shared.restoreBackup(nil) - await PrivatePaykitAddressReservationStore.shared.reconcileReservedIndexesWithLdk() - } catch { - Logger.warn("Failed to clear missing Paykit SDK backup state: \(error)", context: "BackupService") - } + } catch { + Logger.warn("Failed to restore Paykit SDK backup state: \(error)", context: "BackupService") } } diff --git a/BitkitTests/PubkyProfileManagerTests.swift b/BitkitTests/PubkyProfileManagerTests.swift index d91df975e..af51ff0d0 100644 --- a/BitkitTests/PubkyProfileManagerTests.swift +++ b/BitkitTests/PubkyProfileManagerTests.swift @@ -400,6 +400,9 @@ final class PubkyProfileManagerTests: XCTestCase { loadKeychainString: { key in store[key.storageKey] }, + persistKeychainString: { key, value in + store[key.storageKey] = value + }, deleteKeychainValue: { key in store.removeValue(forKey: key.storageKey) }, @@ -434,6 +437,9 @@ final class PubkyProfileManagerTests: XCTestCase { loadKeychainString: { key in store[key.storageKey] }, + persistKeychainString: { key, value in + store[key.storageKey] = value + }, deleteKeychainValue: { key in store.removeValue(forKey: key.storageKey) }, @@ -463,6 +469,9 @@ final class PubkyProfileManagerTests: XCTestCase { loadKeychainString: { key in store[key.storageKey] }, + persistKeychainString: { key, value in + store[key.storageKey] = value + }, deleteKeychainValue: { key in store.removeValue(forKey: key.storageKey) }, @@ -479,6 +488,36 @@ final class PubkyProfileManagerTests: XCTestCase { XCTAssertFalse(store[KeychainEntryType.pubkySecretKey.storageKey, default: ""].isEmpty) } + func testRestoreSessionBackupStateForLocalSeedKeepsSecretWhenSignInFails() async throws { + var store = makeKeychainStore( + mnemonic: "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about", + paykitSession: "stale-session" + ) + + do { + try await PubkyProfileManager.restoreSessionBackupState( + PubkySessionBackupV1(kind: .localSeed, sessionSecret: nil), + loadKeychainString: { key in + store[key.storageKey] + }, + persistKeychainString: { key, value in + store[key.storageKey] = value + }, + deleteKeychainValue: { key in + store.removeValue(forKey: key.storageKey) + }, + clearSessionAccess: {}, + signInWithSecretKey: { _ in + throw PubkyServiceError.authFailed("offline") + } + ) + XCTFail("Expected sign-in failure") + } catch { + XCTAssertNil(store[KeychainEntryType.paykitSession.storageKey]) + XCTAssertFalse(store[KeychainEntryType.pubkySecretKey.storageKey, default: ""].isEmpty) + } + } + // MARK: - Metadata backup payload func testMetadataBackupV1RoundTripsPubkySession() throws {