From 10661273eaf58dd910134bb2a8899fd5fff38304 Mon Sep 17 00:00:00 2001 From: benk10 Date: Fri, 10 Jul 2026 10:59:31 +0200 Subject: [PATCH 1/3] feat: support paykit receiver paths --- Bitkit.xcodeproj/project.pbxproj | 2 +- .../xcshareddata/swiftpm/Package.resolved | 4 +- Bitkit/AppScene.swift | 31 +- Bitkit/MainNavView.swift | 3 + Bitkit/Managers/ContactsManager.swift | 53 ++- Bitkit/Managers/PubkyProfileManager.swift | 21 +- Bitkit/Managers/ScannerManager.swift | 13 +- ...PrivatePaykitAddressReservationStore.swift | 95 ++--- .../PrivatePaykitService+Contacts.swift | 339 ++++++++++++++---- .../PrivatePaykitService+Endpoints.swift | 49 ++- .../PrivatePaykitService+Invoices.swift | 33 +- .../PrivatePaykitService+Models.swift | 10 +- .../PrivatePaykitService+Payments.swift | 8 +- Bitkit/Services/PrivatePaykitService.swift | 7 +- Bitkit/Services/PubkyService.swift | 185 +++++++++- Bitkit/Services/PublicPaykitService.swift | 38 +- Bitkit/Views/Contacts/AddContactView.swift | 3 + Bitkit/Views/Profile/PayContactsView.swift | 63 +++- Bitkit/Views/Scanner/ScannerScreen.swift | 4 +- Bitkit/Views/Scanner/ScannerSheet.swift | 4 +- .../General/PaymentPreferenceView.swift | 46 ++- .../Views/Wallets/Send/SendOptionsView.swift | 3 +- BitkitTests/ContactsManagerTests.swift | 2 +- BitkitTests/PrivatePaykitServiceTests.swift | 53 ++- changelog.d/next/620.added.md | 1 + 25 files changed, 859 insertions(+), 211 deletions(-) create mode 100644 changelog.d/next/620.added.md diff --git a/Bitkit.xcodeproj/project.pbxproj b/Bitkit.xcodeproj/project.pbxproj index ba5ba8862..df84f0190 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-rc31"; + version = "0.1.0-rc33"; }; }; 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 57e5bd90d..36edf0db5 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" : "941763505b794d3d473d03c8a4202166fc402642", - "version" : "0.1.0-rc31" + "revision" : "aa15cae9c18f7a8643162d127320aa006ffb1afd", + "version" : "0.1.0-rc33" } }, { diff --git a/Bitkit/AppScene.swift b/Bitkit/AppScene.swift index 5588eab02..99a0cba16 100644 --- a/Bitkit/AppScene.swift +++ b/Bitkit/AppScene.swift @@ -195,6 +195,7 @@ struct AppScene: View { if authState == .authenticated, let pk = pubkyProfile.publicKey { Task { try? await contactsManager.loadContacts(for: pk) + await refreshPrivateOnlyPaykitReceiverMarker() if !PaykitFeatureFlags.isUIEnabled, wallet.walletExists == true { await retryPendingPaykitEndpointRemoval() } @@ -632,6 +633,7 @@ struct AppScene: View { await retryPendingPaykitEndpointRemoval() } guard PaykitFeatureFlags.isUIEnabled else { return } + await refreshPrivateOnlyPaykitReceiverMarker() await PrivatePaykitAddressReservationStore.shared.reconcileReservedIndexesWithLdk() await PrivatePaykitService.shared.prepareSavedContacts( contactsManager.contacts.map(\.publicKey), @@ -671,6 +673,7 @@ struct AppScene: View { await retryPendingPaykitEndpointRemoval() await wallet.refreshPublicPaykitEndpointsOnForeground() if PaykitFeatureFlags.isUIEnabled { + await refreshPrivateOnlyPaykitReceiverMarker() await PrivatePaykitService.shared.refreshSavedContactEndpoints( for: contactsManager.contacts.map(\.publicKey), wallet: wallet @@ -681,17 +684,31 @@ struct AppScene: View { } } + private func refreshPrivateOnlyPaykitReceiverMarker() async { + let publicSharingEnabled = UserDefaults.standard.bool(forKey: PublicPaykitService.publishingEnabledKey) + let privateSharingEnabled = UserDefaults.standard.bool(forKey: PrivatePaykitService.publishingEnabledKey) + guard privateSharingEnabled, !publicSharingEnabled else { return } + guard await PubkyService.currentPublicKey() != nil else { return } + + do { + try await PublicPaykitService.syncLocalReceiverMarker() + } catch { + Logger.warn("Failed to refresh private Paykit receiver marker: \(error)", context: "AppScene") + } + } + private func retryPendingPaykitEndpointRemoval() async { if PublicPaykitService.isCleanupPending { - if UserDefaults.standard.bool(forKey: PublicPaykitService.publishingEnabledKey) { - PublicPaykitService.setCleanupPending(false) - } else { - do { + do { + if UserDefaults.standard.bool(forKey: PublicPaykitService.publishingEnabledKey) { + try await PublicPaykitService.syncCurrentPublishedEndpoints(wallet: wallet) + } else { try await PublicPaykitService.removePublishedEndpoints() - PublicPaykitService.setCleanupPending(false) - } catch { - Logger.warn("Failed to retry public Paykit endpoint cleanup: \(error)", context: "AppScene") + try await PublicPaykitService.syncLocalReceiverMarker(publicSharingEnabled: false) } + PublicPaykitService.setCleanupPending(false) + } catch { + Logger.warn("Failed to reconcile public Paykit state: \(error)", context: "AppScene") } } diff --git a/Bitkit/MainNavView.swift b/Bitkit/MainNavView.swift index 505d6613f..bee39ae5c 100644 --- a/Bitkit/MainNavView.swift +++ b/Bitkit/MainNavView.swift @@ -623,6 +623,9 @@ struct MainNavView: View { contacts: contactsManager.contacts ) { navigation.navigate(route) + if case let .contactDetail(publicKey) = route { + await contactsManager.refreshContactReceiverPaths(publicKey: publicKey, wallet: wallet) + } clipboardUri = nil return } diff --git a/Bitkit/Managers/ContactsManager.swift b/Bitkit/Managers/ContactsManager.swift index 855814549..5d41f9d7a 100644 --- a/Bitkit/Managers/ContactsManager.swift +++ b/Bitkit/Managers/ContactsManager.swift @@ -280,9 +280,8 @@ class ContactsManager: ObservableObject { try await resolveContactProfile(publicKey: prefixedKey, includePlaceholder: true) } - try await Task.detached { - _ = try await PubkyService.saveContact(publicKey: prefixedKey, label: profile.name) - }.value + let receiverPaths = try await Self.relevantReceiverPaths(for: prefixedKey) + _ = try await PubkyService.saveContact(publicKey: prefixedKey, label: profile.name, receiverPaths: receiverPaths) Logger.info("Added contact \(PubkyPublicKeyFormat.redacted(prefixedKey))", context: "ContactsManager") @@ -291,6 +290,25 @@ class ContactsManager: ObservableObject { contacts.sort { $0.displayName.localizedCaseInsensitiveCompare($1.displayName) == .orderedAscending } } + func refreshContactReceiverPaths(publicKey: String, wallet: WalletViewModel) async { + guard let prefixedKey = PubkyPublicKeyFormat.normalized(publicKey), + let contact = contacts.first(where: { PubkyPublicKeyFormat.matches($0.publicKey, prefixedKey) }) + else { return } + + do { + let receiverPaths = try await Self.relevantReceiverPaths(for: prefixedKey) + _ = try await PubkyService.saveContact(publicKey: prefixedKey, label: contact.profile.name, receiverPaths: receiverPaths) + await PrivatePaykitService.shared.refreshSavedContactEndpoints(for: [prefixedKey], wallet: wallet) + } catch is CancellationError { + return + } catch { + Logger.warn( + "Failed to refresh contact receiver paths for \(PubkyPublicKeyFormat.redacted(prefixedKey)): \(error)", + context: "ContactsManager" + ) + } + } + // MARK: - Import Contacts func importContacts(publicKeys: [String]) async throws { @@ -302,8 +320,11 @@ class ContactsManager: ObservableObject { group.addTask { [self] in do { let profile = try await resolveContactProfile(publicKey: key, includePlaceholder: true) - _ = try await PubkyService.saveContact(publicKey: key, label: profile.name) + let receiverPaths = try await Self.relevantReceiverPaths(for: key) + _ = try await PubkyService.saveContact(publicKey: key, label: profile.name, receiverPaths: receiverPaths) return .success(PubkyContact(publicKey: key, profile: profile)) + } catch is CancellationError { + return .failure(CancellationError()) } catch { Logger.warn("Failed to save imported contact '\(PubkyPublicKeyFormat.redacted(key))': \(error)", context: "ContactsManager") return .failure(error) @@ -328,6 +349,8 @@ class ContactsManager: ObservableObject { return (results, failures, firstError) } + try Task.checkCancellation() + if !prefixedKeys.isEmpty, loadedResult.contacts.isEmpty { throw loadedResult.firstError ?? PubkyServiceError.profileNotFound } @@ -379,12 +402,12 @@ class ContactsManager: ObservableObject { try await Task.detached { _ = try await PubkyService.removeContact(publicKey: prefixedKey) }.value + await PrivatePaykitService.shared.removeSavedContact(publicKey: prefixedKey) Self.removeContactProfileOverride(publicKey: prefixedKey) Logger.info("Removed contact \(PubkyPublicKeyFormat.redacted(prefixedKey))", context: "ContactsManager") contacts.removeAll { $0.publicKey == prefixedKey } - await PrivatePaykitService.shared.removeSavedContact(publicKey: prefixedKey) } func deleteAllContacts() async throws { @@ -423,13 +446,17 @@ class ContactsManager: ObservableObject { if let firstError { if !deletedKeys.isEmpty { - contacts.removeAll { deletedKeys.contains($0.publicKey) } await PrivatePaykitService.shared.removeSavedContacts(publicKeys: Array(deletedKeys)) + for publicKey in deletedKeys { + Self.removeContactProfileOverride(publicKey: publicKey) + } + contacts.removeAll { deletedKeys.contains($0.publicKey) } } throw firstError } // All remote deletes succeeded, so clear any local-only contacts too. + await PrivatePaykitService.shared.removeSavedContacts(publicKeys: Array(deletedKeys)) Self.clearContactProfileOverrides() await PrivatePaykitService.shared.pruneUnsavedContactState(savedPublicKeys: []) contacts.removeAll() @@ -593,6 +620,20 @@ class ContactsManager: ObservableObject { throw PubkyServiceError.profileNotFound } + private nonisolated static func relevantReceiverPaths(for publicKey: String) async throws -> [String] { + do { + return try await PubkyService.discoverRelevantReceiverPaths(publicKey: publicKey) + } catch is CancellationError { + throw CancellationError() + } catch { + Logger.warn( + "Failed to discover Paykit receivers for '\(PubkyPublicKeyFormat.redacted(publicKey))': \(error)", + context: "ContactsManager" + ) + return [PaykitReceiverPath.wallet] + } + } + private nonisolated static let contactProfileOverridesKey = "pubkyContactProfileOverrides" private nonisolated static func contact( diff --git a/Bitkit/Managers/PubkyProfileManager.swift b/Bitkit/Managers/PubkyProfileManager.swift index b27b96a72..3fe1435f8 100644 --- a/Bitkit/Managers/PubkyProfileManager.swift +++ b/Bitkit/Managers/PubkyProfileManager.swift @@ -346,7 +346,7 @@ class PubkyProfileManager: ObservableObject { } func deleteProfile() async throws { - await Self.removePrivatePaykitEndpointsBestEffort(context: "PubkyProfileManager.deleteProfile") + try await Self.removePrivatePaykitEndpoints(context: "PubkyProfileManager.deleteProfile") do { try await Task.detached { try await PubkyService.deletePaykitProfile() @@ -684,13 +684,26 @@ class PubkyProfileManager: ObservableObject { } static func removePublicPaykitEndpoints(context: String) async throws { + var firstError: Error? do { try await PublicPaykitService.removePublishedEndpoints() } catch PubkyServiceError.sessionNotActive { Logger.debug("Skipping public Paykit endpoint cleanup because no session is active", context: context) } catch { - Logger.warn("Failed to remove public Paykit endpoints before clearing session: \(error)", context: context) - throw error + firstError = error + } + + do { + try await PublicPaykitService.syncLocalReceiverMarker(publicSharingEnabled: false, privateSharingEnabled: false) + } catch PubkyServiceError.sessionNotActive { + Logger.debug("Skipping Paykit receiver marker cleanup because no session is active", context: context) + } catch { + firstError = firstError ?? error + } + + if let firstError { + Logger.warn("Failed to remove public Paykit state before clearing session: \(firstError)", context: context) + throw firstError } } @@ -730,7 +743,7 @@ class PubkyProfileManager: ObservableObject { private func signOut(cleanPrivatePaykitEndpoints: Bool) async throws { try await Task.detached { if cleanPrivatePaykitEndpoints { - await Self.removePrivatePaykitEndpointsBestEffort(context: "PubkyProfileManager.signOut") + try await Self.removePrivatePaykitEndpoints(context: "PubkyProfileManager.signOut") } await Self.removePublicPaykitEndpointsBestEffort(context: "PubkyProfileManager.signOut") do { diff --git a/Bitkit/Managers/ScannerManager.swift b/Bitkit/Managers/ScannerManager.swift index f6dc760af..9093948a5 100644 --- a/Bitkit/Managers/ScannerManager.swift +++ b/Bitkit/Managers/ScannerManager.swift @@ -18,6 +18,7 @@ class ScannerManager: ObservableObject { private var navigation: NavigationViewModel? private var pubkyProfile: PubkyProfileManager? private var sheets: SheetViewModel? + private var wallet: WalletViewModel? func configure( app: AppViewModel, @@ -26,7 +27,8 @@ class ScannerManager: ObservableObject { settings: SettingsViewModel? = nil, navigation: NavigationViewModel? = nil, pubkyProfile: PubkyProfileManager? = nil, - sheets: SheetViewModel? = nil + sheets: SheetViewModel? = nil, + wallet: WalletViewModel? = nil ) { self.app = app self.contactsManager = contactsManager @@ -35,6 +37,7 @@ class ScannerManager: ObservableObject { self.navigation = navigation self.pubkyProfile = pubkyProfile self.sheets = sheets + self.wallet = wallet } func handleScan(_ uri: String, context: ScannerContext) async { @@ -109,6 +112,14 @@ class ScannerManager: ObservableObject { sheets?.hideSheetIfActive(sheetId, reason: reason) } navigation.navigate(route) + if case let .contactDetail(publicKey) = route, + let contactsManager, + let wallet + { + Task { + await contactsManager.refreshContactReceiverPaths(publicKey: publicKey, wallet: wallet) + } + } return true } diff --git a/Bitkit/Services/PrivatePaykitAddressReservationStore.swift b/Bitkit/Services/PrivatePaykitAddressReservationStore.swift index b1762d52f..5eba5af32 100644 --- a/Bitkit/Services/PrivatePaykitAddressReservationStore.swift +++ b/Bitkit/Services/PrivatePaykitAddressReservationStore.swift @@ -71,7 +71,7 @@ actor PrivatePaykitAddressReservationStore { func hasContactAssignment(for publicKey: String) -> Bool { guard let normalizedKey = PubkyPublicKeyFormat.normalized(publicKey), - let assignment = ledger.contactAssignments[normalizedKey] + let assignment = ledger.contactAssignments.first(where: { Self.publicKey(fromAssignmentKey: $0.key) == normalizedKey })?.value else { return false } return LDKNode.AddressType.from(string: assignment.addressType) != nil @@ -84,7 +84,7 @@ actor PrivatePaykitAddressReservationStore { return publicKey } - for (publicKey, history) in ledger.contactAssignmentHistory { + for (assignmentKey, history) in ledger.contactAssignmentHistory { for assignment in history { guard let addressType = LDKNode.AddressType.from(string: assignment.addressType), addressType.matchesAddressFormat(address, network: Env.network), @@ -92,7 +92,7 @@ actor PrivatePaykitAddressReservationStore { reservedAddress == address else { continue } - return publicKey + return Self.publicKey(fromAssignmentKey: assignmentKey) } } @@ -102,24 +102,25 @@ actor PrivatePaykitAddressReservationStore { func currentContactPublicKey(forReservedAddress address: String) async -> String? { guard !address.isEmpty else { return nil } - for (publicKey, assignment) in ledger.contactAssignments { + for (assignmentKey, assignment) in ledger.contactAssignments { guard let addressType = LDKNode.AddressType.from(string: assignment.addressType), addressType.matchesAddressFormat(address, network: Env.network), let reservedAddress = try? await self.address(for: addressType, receiveIndex: assignment.receiveIndex), reservedAddress == address else { continue } - return publicKey + return Self.publicKey(fromAssignmentKey: assignmentKey) } return nil } - func currentOrRotatedAddress(for publicKey: String) async throws -> String { - if let existing = try await reservedAddressDetails(for: publicKey) { + func currentOrRotatedAddress(for publicKey: String, receiverPath: String) async throws -> String { + let assignmentKey = try Self.contactAssignmentKey(publicKey: publicKey, receiverPath: receiverPath) + if let existing = try await reservedAddressDetails(forAssignmentKey: assignmentKey) { guard isAddressTypeMonitored(existing.addressType) else { - clearCurrentContactAssignment(publicKey: publicKey) - return try await allocateAddress(for: publicKey) + clearCurrentContactAssignment(assignmentKey: assignmentKey) + return try await allocateAddress(forAssignmentKey: assignmentKey) } do { @@ -136,16 +137,11 @@ actor PrivatePaykitAddressReservationStore { } } - return try await allocateAddress(for: publicKey) - } - - func rotateAddress(for publicKey: String) async throws -> String { - return try await allocateAddress(for: publicKey) + return try await allocateAddress(forAssignmentKey: assignmentKey) } - private func reservedAddressDetails(for publicKey: String) async throws -> (address: String, addressType: LDKNode.AddressType)? { - guard let normalizedKey = PubkyPublicKeyFormat.normalized(publicKey), - let assignment = ledger.contactAssignments[normalizedKey], + private func reservedAddressDetails(forAssignmentKey assignmentKey: String) async throws -> (address: String, addressType: LDKNode.AddressType)? { + guard let assignment = ledger.contactAssignments[assignmentKey], let addressType = LDKNode.AddressType.from(string: assignment.addressType) else { return nil } @@ -165,17 +161,17 @@ actor PrivatePaykitAddressReservationStore { var seenAssignmentKeys = Set() var assignments: [(publicKey: String, assignment: StoredAssignment)] = [] - for (publicKey, assignment) in ledger.contactAssignments { + for (assignmentKey, assignment) in ledger.contactAssignments { let key = Self.assignmentKey(assignment) guard seenAssignmentKeys.insert(key).inserted else { continue } - assignments.append((publicKey: publicKey, assignment: assignment)) + assignments.append((publicKey: Self.publicKey(fromAssignmentKey: assignmentKey), assignment: assignment)) } - for (publicKey, history) in ledger.contactAssignmentHistory { + for (assignmentKey, history) in ledger.contactAssignmentHistory { for assignment in history { let key = Self.assignmentKey(assignment) guard seenAssignmentKeys.insert(key).inserted else { continue } - assignments.append((publicKey: publicKey, assignment: assignment)) + assignments.append((publicKey: Self.publicKey(fromAssignmentKey: assignmentKey), assignment: assignment)) } } @@ -187,24 +183,24 @@ actor PrivatePaykitAddressReservationStore { func contactsWithUsedReservedAddresses() async -> [String] { var publicKeys: [String] = [] - for (publicKey, assignment) in ledger.contactAssignments { + for (assignmentKey, assignment) in ledger.contactAssignments { guard let addressType = LDKNode.AddressType.from(string: assignment.addressType), let address = try? await address(for: addressType, receiveIndex: assignment.receiveIndex) else { continue } do { if try await CoreService.shared.utility.isAddressUsed(address: address) { - publicKeys.append(publicKey) + publicKeys.append(Self.publicKey(fromAssignmentKey: assignmentKey)) } } catch { Logger.warn( - "Unable to check private Paykit reserved address usage for \(PubkyPublicKeyFormat.redacted(publicKey)): \(error)", + "Unable to check private Paykit reserved address usage for \(PubkyPublicKeyFormat.redacted(Self.publicKey(fromAssignmentKey: assignmentKey))): \(error)", context: "PrivatePaykit" ) } } - return publicKeys + return Array(Set(publicKeys)).sorted() } // MARK: - Reusable Receive Protection @@ -311,8 +307,12 @@ actor PrivatePaykitAddressReservationStore { func clearContactAssignment(publicKey: String) { guard let normalizedKey = PubkyPublicKeyFormat.normalized(publicKey) else { return } - let removedCurrent = ledger.contactAssignments.removeValue(forKey: normalizedKey) != nil - let removedHistory = ledger.contactAssignmentHistory.removeValue(forKey: normalizedKey) != nil + let previousCount = ledger.contactAssignments.count + let previousHistoryCount = ledger.contactAssignmentHistory.count + ledger.contactAssignments = ledger.contactAssignments.filter { Self.publicKey(fromAssignmentKey: $0.key) != normalizedKey } + ledger.contactAssignmentHistory = ledger.contactAssignmentHistory.filter { Self.publicKey(fromAssignmentKey: $0.key) != normalizedKey } + let removedCurrent = ledger.contactAssignments.count != previousCount + let removedHistory = ledger.contactAssignmentHistory.count != previousHistoryCount guard removedCurrent || removedHistory else { return } persist() @@ -322,28 +322,23 @@ actor PrivatePaykitAddressReservationStore { let normalizedKeys = Set(publicKeys.compactMap(PubkyPublicKeyFormat.normalized)) let previousCount = ledger.contactAssignments.count let previousHistoryCount = ledger.contactAssignmentHistory.count - ledger.contactAssignments = ledger.contactAssignments.filter { normalizedKeys.contains($0.key) } - ledger.contactAssignmentHistory = ledger.contactAssignmentHistory.filter { normalizedKeys.contains($0.key) } + ledger.contactAssignments = ledger.contactAssignments.filter { normalizedKeys.contains(Self.publicKey(fromAssignmentKey: $0.key)) } + ledger.contactAssignmentHistory = ledger.contactAssignmentHistory + .filter { normalizedKeys.contains(Self.publicKey(fromAssignmentKey: $0.key)) } guard ledger.contactAssignments.count != previousCount || ledger.contactAssignmentHistory.count != previousHistoryCount else { return } persist() } - private func clearCurrentContactAssignment(publicKey: String) { - guard let normalizedKey = PubkyPublicKeyFormat.normalized(publicKey), - ledger.contactAssignments.removeValue(forKey: normalizedKey) != nil - else { return } + private func clearCurrentContactAssignment(assignmentKey: String) { + guard ledger.contactAssignments.removeValue(forKey: assignmentKey) != nil else { return } persist() } // MARK: - Private Address Allocation - private func allocateAddress(for publicKey: String) async throws -> String { - guard let normalizedKey = PubkyPublicKeyFormat.normalized(publicKey) else { - throw PrivatePaykitError.privateUnavailable - } - + private func allocateAddress(forAssignmentKey assignmentKey: String) async throws -> String { let addressType = LDKNode.AddressType.fromStorage(UserDefaults.standard.string(forKey: "selectedAddressType")) let addressTypeKey = addressType.stringValue @@ -365,8 +360,8 @@ actor PrivatePaykitAddressReservationStore { addressType: addressTypeKey, receiveIndex: addressInfo.index ) - ledger.contactAssignments[normalizedKey] = assignment - rememberContactAssignmentForAttribution(publicKey: normalizedKey, assignment: assignment) + ledger.contactAssignments[assignmentKey] = assignment + rememberContactAssignmentForAttribution(assignmentKey: assignmentKey, assignment: assignment) persist() markWalletBackupDataChanged() await ensureReusableOnchainAddress(afterReserving: addressInfo.address, addressType: addressType) @@ -374,12 +369,12 @@ actor PrivatePaykitAddressReservationStore { return addressInfo.address } - private func rememberContactAssignmentForAttribution(publicKey: String, assignment: StoredAssignment) { - var history = ledger.contactAssignmentHistory[publicKey] ?? [] + private func rememberContactAssignmentForAttribution(assignmentKey: String, assignment: StoredAssignment) { + var history = ledger.contactAssignmentHistory[assignmentKey] ?? [] guard !history.contains(assignment) else { return } history.append(assignment) - ledger.contactAssignmentHistory[publicKey] = history + ledger.contactAssignmentHistory[assignmentKey] = history } // MARK: - LDK Address Index APIs @@ -397,6 +392,20 @@ actor PrivatePaykitAddressReservationStore { "\(assignment.addressType):\(assignment.receiveIndex)" } + private static func contactAssignmentKey(publicKey: String, receiverPath: String) throws -> String { + guard let normalizedKey = PubkyPublicKeyFormat.normalized(publicKey) else { + throw PrivatePaykitError.privateUnavailable + } + guard receiverPath != PaykitReceiverPath.wallet else { + return normalizedKey + } + return "\(normalizedKey)#\(receiverPath)" + } + + private static func publicKey(fromAssignmentKey assignmentKey: String) -> String { + assignmentKey.components(separatedBy: "#").first ?? assignmentKey + } + // MARK: - Cached Receive Address private var reusableOnchainAddress: String { diff --git a/Bitkit/Services/PrivatePaykitService+Contacts.swift b/Bitkit/Services/PrivatePaykitService+Contacts.swift index b60e56c62..a122db217 100644 --- a/Bitkit/Services/PrivatePaykitService+Contacts.swift +++ b/Bitkit/Services/PrivatePaykitService+Contacts.swift @@ -12,6 +12,7 @@ extension PrivatePaykitService { ) async -> Error? { let publicKeys = rememberSavedContacts(publicKeys, replacing: true) guard await canPublishPrivateEndpoints(wallet: wallet) else { + await prepareRelevantPrivateLinksIfAvailable(publicKeys, reason: "prepare") return requireImmediatePublication && !publicKeys.isEmpty ? PrivatePaykitError.privateUnavailable : nil } @@ -56,6 +57,7 @@ extension PrivatePaykitService { reason: String = "refresh" ) async -> Error? { guard await canPublishPrivateEndpoints(wallet: wallet) else { + await prepareRelevantPrivateLinksIfAvailable(publicKeys, reason: reason) return requireImmediatePublication && !publicKeys.isEmpty ? PrivatePaykitError.privateUnavailable : nil } @@ -78,22 +80,47 @@ extension PrivatePaykitService { func removePublishedEndpoints(for publicKeys: [String]) async throws { var firstError: Error? for publicKey in normalizedSavedContactKeys(publicKeys) { + var didFail = false + let cleanupReceiverPaths: [String] do { - let report = try await PaykitSdkService.shared.clearPrivatePaymentList(to: publicKey) - if !report.failedToQueue.isEmpty || !report.failedToDeliver.isEmpty { - throw PrivatePaykitError.privateUnavailable + cleanupReceiverPaths = try await receiverPathsForCleanup(publicKey: publicKey) + } catch { + firstError = firstError ?? error + Self.markDeletedContactCleanupPending([publicKey]) + continue + } + + for receiverPath in cleanupReceiverPaths { + do { + let report = try await PaykitSdkService.shared.clearPrivatePaymentList(to: publicKey, receiverPath: receiverPath) + if !report.failedToQueue.isEmpty || !report.failedToDeliver.isEmpty { + throw PrivatePaykitError.privateUnavailable + } + let retryKey = PrivateMessageDrainRetryKey(publicKey: publicKey, receiverPath: receiverPath) + await drainPendingPrivateMessages(reason: "cleanup", advancing: [retryKey]) + let pending = try await PaykitSdkService.shared.pendingOutboundPrivateCounterparties() + if pending.contains(where: { + PubkyPublicKeyFormat.normalized($0.counterparty) == publicKey && $0.counterpartyReceiverPath == receiverPath + }) { + throw PrivatePaykitError.privateUnavailable + } + } catch { + didFail = true + firstError = firstError ?? error + Self.markDeletedContactCleanupPending([publicKey]) } + } + + if !didFail { if var contactState = state.contacts[publicKey] { contactState.cachedResolvedEndpoints = [] - contactState.localInvoice = nil - contactState.hasPublishedPrivatePaymentList = false + contactState.localInvoicesByReceiverPath = [:] + contactState.publishedPrivatePaymentReceiverPaths = [] state.contacts[publicKey] = contactState.hasCacheState ? contactState : nil } + Self.clearDeletedContactCleanupPending([publicKey]) persistState(markWalletBackup: true) - } catch { - firstError = firstError ?? error - Self.markDeletedContactCleanupPending([publicKey]) } } @@ -239,35 +266,90 @@ extension PrivatePaykitService { var firstError: Error? var updates = [PrivatePaymentListReservationUpdateInput]() + var linkRetryKeys = [PrivateMessageDrainRetryKey]() for publicKey in publicKeys { + let receiverPaths = await receiverPathsForSavedContact(publicKey: publicKey) + let receiverPathSelection: PrivateReceiverPathSelection do { - _ = try await PaykitSdkService.shared.ensureLinkWithPeer(publicKey) + receiverPathSelection = try await PaykitSdkService.shared.privateReceiverPathSelection( + publicKey: publicKey, + savedReceiverPaths: receiverPaths + ) } catch { + return error + } + let linkableReceiverPaths = receiverPathSelection.linkableReceiverPaths + let publicationReceiverPaths = receiverPathSelection.publishableReceiverPaths + if let error = receiverPathSelection.error { + firstError = firstError ?? error Logger.warn( - "Failed to prepare private Paykit link for \(PubkyPublicKeyFormat.redacted(publicKey)) during \(reason): \(error)", + "Failed to inspect private Paykit receiver markers for \(PubkyPublicKeyFormat.redacted(publicKey)) during \(reason): \(error)", context: "PrivatePaykit" ) } - + let cleanupReceiverPaths: [String] do { - let endpoints = try await buildLocalEndpoints( - for: publicKey, - wallet: wallet, - forceRefreshLightning: forceRefreshLightning + cleanupReceiverPaths = try await receiverPathsForPrivateEndpointCleanup( + publicKey: publicKey, + excluding: publicationReceiverPaths + receiverPathSelection.cleanupProtectedReceiverPaths ) - let reservations = reservations(from: endpoints, publicKey: publicKey) - updates.append(PrivatePaymentListReservationUpdateInput(counterparty: publicKey, reservations: reservations)) } catch { + cleanupReceiverPaths = [] + firstError = firstError ?? error Logger.warn( - "Failed to prepare private Paykit endpoints for \(PubkyPublicKeyFormat.redacted(publicKey)) during \(reason): \(error)", + "Failed to inspect private Paykit links for \(PubkyPublicKeyFormat.redacted(publicKey)) during \(reason): \(error)", context: "PrivatePaykit" ) - firstError = firstError ?? error + } + + for receiverPath in Set(linkableReceiverPaths).union(cleanupReceiverPaths) { + linkRetryKeys.append(PrivateMessageDrainRetryKey(publicKey: publicKey, receiverPath: receiverPath)) + do { + _ = try await PaykitSdkService.shared.ensureLinkWithPeer(publicKey, receiverPath: receiverPath) + } catch { + Logger.warn( + "Failed to prepare private Paykit link for \(PubkyPublicKeyFormat.redacted(publicKey)) during \(reason): \(error)", + context: "PrivatePaykit" + ) + } + } + + updates.append(contentsOf: cleanupReceiverPaths.map { receiverPath in + PrivatePaymentListReservationUpdateInput( + counterparty: publicKey, + counterpartyReceiverPath: receiverPath, + reservations: [] + ) + }) + + for receiverPath in publicationReceiverPaths { + do { + let endpoints = try await buildLocalEndpoints( + for: publicKey, + receiverPath: receiverPath, + wallet: wallet, + forceRefreshLightning: forceRefreshLightning + ) + let reservations = reservations(from: endpoints, publicKey: publicKey, receiverPath: receiverPath) + let update = PrivatePaymentListReservationUpdateInput( + counterparty: publicKey, + counterpartyReceiverPath: receiverPath, + reservations: reservations + ) + updates.append(update) + } catch { + Logger.warn( + "Failed to prepare private Paykit endpoints for \(PubkyPublicKeyFormat.redacted(publicKey)) during \(reason): \(error)", + context: "PrivatePaykit" + ) + firstError = firstError ?? error + } } } guard !updates.isEmpty else { - return requireImmediatePublication ? firstError ?? PrivatePaykitError.privateUnavailable : nil + await drainAndSchedulePrivateLinkRetries(reason: reason, retryKeys: linkRetryKeys) + return requireImmediatePublication ? firstError : nil } do { @@ -276,11 +358,8 @@ extension PrivatePaykitService { 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) - } + let retryKeys = linkRetryKeys + privatePaymentListDeliveryRetryKeys(from: report) + await drainAndSchedulePrivateLinkRetries(reason: reason, retryKeys: retryKeys) } catch { Logger.warn("Failed to sync private Paykit endpoint publications during \(reason): \(error)", context: "PrivatePaykit") firstError = firstError ?? error @@ -289,18 +368,78 @@ extension PrivatePaykitService { return requireImmediatePublication ? firstError : nil } - private func privatePaymentListDeliveryRetryKeys(from report: PrivatePaymentListDeliveryReport) -> [String] { - normalizedSavedContactKeys(report.queued.map(\.counterparty) + report.failedToDeliver.map(\.counterparty)) + private func prepareRelevantPrivateLinksIfAvailable(_ publicKeys: [String], reason: String) async { + guard await canUsePrivateLinks() else { return } + + var retryKeys = [PrivateMessageDrainRetryKey]() + for publicKey in normalizedSavedContactKeys(publicKeys) { + let receiverPaths = await receiverPathsForSavedContact(publicKey: publicKey) + do { + let selection = try await PaykitSdkService.shared.privateReceiverPathSelection( + publicKey: publicKey, + savedReceiverPaths: receiverPaths + ) + if let error = selection.error { + Logger.warn( + "Failed to inspect private Paykit receiver markers for \(PubkyPublicKeyFormat.redacted(publicKey)) during \(reason): \(error)", + context: "PrivatePaykit" + ) + } + retryKeys.append(contentsOf: selection.linkableReceiverPaths.map { + PrivateMessageDrainRetryKey(publicKey: publicKey, receiverPath: $0) + }) + } catch is CancellationError { + return + } catch { + Logger.warn( + "Failed to prepare private Paykit links for \(PubkyPublicKeyFormat.redacted(publicKey)) during \(reason): \(error)", + context: "PrivatePaykit" + ) + } + } + + await drainAndSchedulePrivateLinkRetries(reason: reason, retryKeys: retryKeys) + } + + private func canUsePrivateLinks() async -> Bool { + guard PaykitFeatureFlags.isUIEnabled, + let ownPublicKey = await PubkyService.currentPublicKey() + else { return false } + + return PubkyProfileManager.hasLocalSecretKey(for: ownPublicKey) } - private func drainPendingPrivateMessages(reason: String, advancingLinksFor publicKeys: [String] = []) async { + private func drainAndSchedulePrivateLinkRetries(reason: String, retryKeys: [PrivateMessageDrainRetryKey]) async { + let retryKeys = Array(Set(retryKeys)) + guard !retryKeys.isEmpty else { return } + + await drainPendingPrivateMessages(reason: reason, advancing: retryKeys) + let pendingRetryKeys = await pendingPrivateMessageDrainKeys(retryKeys) + if !pendingRetryKeys.isEmpty { + schedulePendingPrivateMessageDrainRetries(reason: reason, retryKeys: Array(pendingRetryKeys)) + } + } + + private func privatePaymentListDeliveryRetryKeys(from report: PrivatePaymentListDeliveryReport) -> [PrivateMessageDrainRetryKey] { + let changes = (report.queued + report.cleared).map { (counterparty: $0.counterparty, receiverPath: $0.counterpartyReceiverPath) } + + report.failedToDeliver.map { (counterparty: $0.counterparty, receiverPath: $0.counterpartyReceiverPath) } + var seen = Set() + return changes.compactMap { change in + guard let publicKey = PubkyPublicKeyFormat.normalized(change.counterparty) else { return nil } + let retryKey = PrivateMessageDrainRetryKey(publicKey: publicKey, receiverPath: change.receiverPath) + guard seen.insert(retryKey).inserted else { return nil } + return retryKey + } + } + + private func drainPendingPrivateMessages(reason: String, advancing retryKeys: [PrivateMessageDrainRetryKey]) async { do { - for publicKey in normalizedSavedContactKeys(publicKeys) { + for retryKey in Set(retryKeys) { do { - _ = try await PaykitSdkService.shared.ensureLinkWithPeer(publicKey) + _ = try await PaykitSdkService.shared.ensureLinkWithPeer(retryKey.publicKey, receiverPath: retryKey.receiverPath) } catch { Logger.warn( - "Failed to advance private Paykit link for \(PubkyPublicKeyFormat.redacted(publicKey)) during \(reason): \(error)", + "Failed to advance private Paykit link for \(PubkyPublicKeyFormat.redacted(retryKey.publicKey)) during \(reason): \(error)", context: "PrivatePaykit" ) } @@ -314,8 +453,8 @@ extension PrivatePaykitService { } } - private func schedulePendingPrivateMessageDrainRetries(reason: String, publicKeys: [String]) { - let retryKeys = Set(normalizedSavedContactKeys(publicKeys)) + private func schedulePendingPrivateMessageDrainRetries(reason: String, retryKeys: [PrivateMessageDrainRetryKey]) { + let retryKeys = Set(retryKeys) guard !retryKeys.isEmpty else { return } pendingMessageDrainRetryKeys.formUnion(retryKeys) @@ -341,13 +480,17 @@ extension PrivatePaykitService { } func schedulePrivatePaymentRecovery(for publicKey: String) { - schedulePendingPrivateMessageDrainRetries(reason: "payment recovery", publicKeys: [publicKey]) + guard let publicKey = PubkyPublicKeyFormat.normalized(publicKey) else { return } + schedulePendingPrivateMessageDrainRetries( + reason: "payment recovery", + retryKeys: [PrivateMessageDrainRetryKey(publicKey: publicKey, receiverPath: PaykitReceiverPath.wallet)] + ) } private func drainPendingPrivateMessageRetryKeys(reason: String) async { let retryKeys = Array(pendingMessageDrainRetryKeys) guard !retryKeys.isEmpty else { return } - await drainPendingPrivateMessages(reason: reason, advancingLinksFor: retryKeys) + await drainPendingPrivateMessages(reason: reason, advancing: retryKeys) await updatePendingMessageDrainRetryKeys(retryKeys) } @@ -361,47 +504,51 @@ extension PrivatePaykitService { pendingMessageDrainRetryKeys.removeAll() } - private func updatePendingMessageDrainRetryKeys(_ publicKeys: [String]) async { - let remainingKeys = await pendingPrivateMessageDrainKeys(publicKeys) - pendingMessageDrainRetryKeys.subtract(publicKeys) + private func updatePendingMessageDrainRetryKeys(_ retryKeys: [PrivateMessageDrainRetryKey]) async { + let remainingKeys = await pendingPrivateMessageDrainKeys(retryKeys) + pendingMessageDrainRetryKeys.subtract(retryKeys) pendingMessageDrainRetryKeys.formUnion(remainingKeys) } - private func pendingPrivateMessageDrainKeys(_ publicKeys: [String]) async -> Set { - let retryKeys = Set(normalizedSavedContactKeys(publicKeys)) + private func pendingPrivateMessageDrainKeys(_ retryKeys: [PrivateMessageDrainRetryKey]) async -> Set { + let retryKeys = Set(retryKeys) guard !retryKeys.isEmpty else { return [] } - let linkedPeers: [String: LinkedPeerState] + let linkedPeers: [PrivateMessageDrainRetryKey: LinkedPeerState] do { - linkedPeers = Dictionary( - uniqueKeysWithValues: try await PaykitSdkService.shared.linkedPeers().compactMap { peer in - guard let publicKey = PubkyPublicKeyFormat.normalized(peer.counterparty) else { return nil } - return (publicKey, peer.state) - } - ) + var peersByKey: [PrivateMessageDrainRetryKey: LinkedPeerState] = [:] + for peer in try await PaykitSdkService.shared.linkedPeers() { + guard let publicKey = PubkyPublicKeyFormat.normalized(peer.counterparty) else { continue } + peersByKey[PrivateMessageDrainRetryKey(publicKey: publicKey, receiverPath: peer.counterpartyReceiverPath)] = peer.state + } + linkedPeers = peersByKey } catch { Logger.warn("Failed to inspect private Paykit link state: \(error)", context: "PrivatePaykit") return retryKeys } - let pendingOutbound: Set + let pendingOutbound: Set do { - pendingOutbound = Set(normalizedSavedContactKeys(try await PaykitSdkService.shared.pendingOutboundPrivateCounterparties())) + let pendingReceivers = try await PaykitSdkService.shared.pendingOutboundPrivateCounterparties() + pendingOutbound = Set(pendingReceivers.compactMap { receiver in + guard let publicKey = PubkyPublicKeyFormat.normalized(receiver.counterparty) else { return nil } + return PrivateMessageDrainRetryKey(publicKey: publicKey, receiverPath: receiver.counterpartyReceiverPath) + }) } catch { Logger.warn("Failed to inspect pending private Paykit messages: \(error)", context: "PrivatePaykit") return retryKeys } - return Set(retryKeys.filter { publicKey in - switch linkedPeers[publicKey] { - case .linked: - pendingOutbound.contains(publicKey) - case .blocked, .unknown: - false - case nil: - pendingOutbound.contains(publicKey) - default: - true + return Set(retryKeys.filter { retryKey in + guard let state = linkedPeers[retryKey] else { + return pendingOutbound.contains(retryKey) + } + if state == .linked { + return pendingOutbound.contains(retryKey) + } else if state == .blocked || state == .unknown { + return false + } else { + return true } }) } @@ -412,21 +559,14 @@ extension PrivatePaykitService { for change in report.queued { guard let publicKey = PubkyPublicKeyFormat.normalized(change.counterparty) else { continue } - state.contacts[publicKey, default: ContactState()].hasPublishedPrivatePaymentList = true - Self.clearDeletedContactCleanupPending([publicKey]) + recordPublishedPrivatePaymentList(publicKey: publicKey, receiverPath: change.counterpartyReceiverPath) didChangeState = true } 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]) + clearPublishedPrivatePaymentList(publicKey: publicKey, receiverPath: change.counterpartyReceiverPath) + didChangeState = true } for change in report.failedToQueue { @@ -487,4 +627,65 @@ extension PrivatePaykitService { guard let normalizedKey = PubkyPublicKeyFormat.normalized(publicKey) else { return false } return knownSavedContactKeys.contains(normalizedKey) } + + private func receiverPathsForSavedContact(publicKey: String) async -> [String] { + guard let record = try? await PaykitSdkService.shared.contactRecord(publicKey: publicKey) else { + return [PaykitReceiverPath.wallet] + } + + let paths = record.receiverPaths.filter { PaykitReceiverPath.supported.contains($0) } + return paths.isEmpty ? [PaykitReceiverPath.wallet] : paths + } + + private func receiverPathsForPrivateEndpointCleanup( + publicKey: String, + excluding publicationReceiverPaths: [String] + ) async throws -> [String] { + let publishedPaths = publishedPrivatePaymentReceiverPaths(publicKey: publicKey) + let linkedPaths = try await linkedReceiverPaths(publicKey: publicKey) + let excluded = Set(publicationReceiverPaths) + return Array(Set(publishedPaths).union(linkedPaths).subtracting(excluded)) + .filter { PaykitReceiverPath.supported.contains($0) } + .sorted() + } + + private func receiverPathsForCleanup(publicKey: String) async throws -> [String] { + let linkedPaths = try await linkedReceiverPaths(publicKey: publicKey) + let publishedPaths = publishedPrivatePaymentReceiverPaths(publicKey: publicKey) + return Array(Set(linkedPaths).union(publishedPaths)) + .filter { PaykitReceiverPath.supported.contains($0) } + .sorted() + } + + private func linkedReceiverPaths(publicKey: String) async throws -> [String] { + guard let publicKey = PubkyPublicKeyFormat.normalized(publicKey) else { return [] } + let peers = try await PaykitSdkService.shared.linkedPeers() + + let linkedPaths = peers.compactMap { peer -> String? in + guard PubkyPublicKeyFormat.normalized(peer.counterparty) == publicKey, + PaykitReceiverPath.supported.contains(peer.counterpartyReceiverPath) + else { return nil } + return peer.counterpartyReceiverPath + } + return Array(Set(linkedPaths)).sorted() + } + + private func publishedPrivatePaymentReceiverPaths(publicKey: String) -> [String] { + state.contacts[publicKey]?.publishedPrivatePaymentReceiverPaths ?? [] + } + + private func recordPublishedPrivatePaymentList(publicKey: String, receiverPath: String) { + var contactState = state.contacts[publicKey, default: ContactState()] + var paths = Set(contactState.publishedPrivatePaymentReceiverPaths) + paths.insert(receiverPath) + contactState.publishedPrivatePaymentReceiverPaths = Array(paths).sorted() + state.contacts[publicKey] = contactState + } + + private func clearPublishedPrivatePaymentList(publicKey: String, receiverPath: String) { + guard var contactState = state.contacts[publicKey] else { return } + contactState.publishedPrivatePaymentReceiverPaths.removeAll { $0 == receiverPath } + contactState.localInvoicesByReceiverPath.removeValue(forKey: receiverPath) + state.contacts[publicKey] = contactState.hasCacheState ? contactState : nil + } } diff --git a/Bitkit/Services/PrivatePaykitService+Endpoints.swift b/Bitkit/Services/PrivatePaykitService+Endpoints.swift index af09cfe8c..95df5d34a 100644 --- a/Bitkit/Services/PrivatePaykitService+Endpoints.swift +++ b/Bitkit/Services/PrivatePaykitService+Endpoints.swift @@ -6,18 +6,24 @@ import Paykit extension PrivatePaykitService { func handleReceivedPayment(paymentHash: String, wallet: WalletViewModel) async { - guard let publicKey = contactPublicKey(forPrivateInvoicePaymentHash: paymentHash) else { return } - rememberReceivedInvoicePaymentHash(paymentHash, publicKey: publicKey) - await refreshSavedContactEndpoints(for: [publicKey], wallet: wallet) + await refreshReceivedPrivateInvoices(paymentHashes: [paymentHash], wallet: wallet) } func reconcileReceivedPayments(wallet: WalletViewModel) async { let settledHashes = await settledPrivateInvoicePaymentHashes() - guard !settledHashes.isEmpty else { return } + await refreshReceivedPrivateInvoices(paymentHashes: settledHashes, wallet: wallet) + } - for paymentHash in settledHashes { - await handleReceivedPayment(paymentHash: paymentHash, wallet: wallet) + private func refreshReceivedPrivateInvoices(paymentHashes: [String], wallet: WalletViewModel) async { + var publicKeys = Set() + for paymentHash in paymentHashes { + guard let publicKey = contactPublicKey(forPrivateInvoicePaymentHash: paymentHash) else { continue } + rememberReceivedInvoicePaymentHash(paymentHash, publicKey: publicKey) + publicKeys.insert(publicKey) } + + guard !publicKeys.isEmpty else { return } + await refreshSavedContactEndpoints(for: Array(publicKeys), wallet: wallet) } func handleOnchainActivity(wallet: WalletViewModel) async { @@ -45,13 +51,17 @@ extension PrivatePaykitService { @MainActor func buildLocalEndpoints( for publicKey: String, + receiverPath: String, wallet: WalletViewModel, forceRefreshLightning: Bool = false ) async throws -> [PublicPaykitService.Endpoint] { var endpoints: [PublicPaykitService.Endpoint] = [] if PublicPaykitService.isOnchainPaymentOptionEnabled() { - let reservedAddress = try await PrivatePaykitAddressReservationStore.shared.currentOrRotatedAddress(for: publicKey) + let reservedAddress = try await PrivatePaykitAddressReservationStore.shared.currentOrRotatedAddress( + for: publicKey, + receiverPath: receiverPath + ) let onchainPayload = try PublicPaykitService.serializePayload(value: reservedAddress) endpoints.append( PublicPaykitService.Endpoint( @@ -66,7 +76,12 @@ extension PrivatePaykitService { if PublicPaykitService.isLightningPaymentOptionEnabled(), walletHasUsableChannels(wallet) { do { - let invoice = try await currentOrRotatedInvoice(for: publicKey, wallet: wallet, forceRefresh: forceRefreshLightning) + let invoice = try await currentOrRotatedInvoice( + for: publicKey, + receiverPath: receiverPath, + wallet: wallet, + forceRefresh: forceRefreshLightning + ) let invoicePayload = try PublicPaykitService.serializePayload(value: invoice.bolt11) endpoints.append( PublicPaykitService.Endpoint( @@ -89,30 +104,36 @@ extension PrivatePaykitService { return endpoints } - func reservations(from endpoints: [PublicPaykitService.Endpoint], publicKey: String) -> [PaymentEndpointReservationInput] { + func reservations( + from endpoints: [PublicPaykitService.Endpoint], + publicKey: String, + receiverPath: String + ) -> [PaymentEndpointReservationInput] { endpoints.map { endpoint in - let paymentHash = state.contacts[publicKey]?.localInvoice?.takeIfBolt11(endpoint)?.paymentHash + let paymentHash = localInvoice(for: publicKey, receiverPath: receiverPath)?.takeIfBolt11(endpoint)?.paymentHash let attribution = [ "type": "private_paykit", "counterparty": publicKey, + "receiver_path": receiverPath, ].merging(paymentHash.map { ["payment_hash": $0] } ?? [:]) { current, _ in current } return PaymentEndpointReservationInput( - reservationId: reservationId(for: endpoint, publicKey: publicKey), + reservationId: reservationId(for: endpoint, publicKey: publicKey, receiverPath: receiverPath), identifier: endpoint.methodId.rawValue, payload: endpoint.rawPayload, - expiresAt: endpoint.methodId == .bitcoinLightningBolt11 ? state.contacts[publicKey]?.localInvoice?.expiresAt.rfc3339Text : nil, + expiresAt: endpoint.methodId == .bitcoinLightningBolt11 ? localInvoice(for: publicKey, receiverPath: receiverPath)?.expiresAt + .rfc3339Text : nil, attribution: attribution ) } } - private func reservationId(for endpoint: PublicPaykitService.Endpoint, publicKey: String) -> String { + private func reservationId(for endpoint: PublicPaykitService.Endpoint, publicKey: String, receiverPath: 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)" + return "\(publicKey):\(receiverPath):\(endpoint.methodId.rawValue):\(payloadHashPrefix)" } func cacheResolvedEndpoints(_ endpoints: [PublicPaykitService.Endpoint], publicKey: String) { diff --git a/Bitkit/Services/PrivatePaykitService+Invoices.swift b/Bitkit/Services/PrivatePaykitService+Invoices.swift index 7fa638656..62518530a 100644 --- a/Bitkit/Services/PrivatePaykitService+Invoices.swift +++ b/Bitkit/Services/PrivatePaykitService+Invoices.swift @@ -8,10 +8,11 @@ import UIKit extension PrivatePaykitService { func currentOrRotatedInvoice( for publicKey: String, + receiverPath: String, wallet: WalletViewModel, forceRefresh: Bool = false ) async throws -> StoredInvoice { - if !forceRefresh, let invoice = await reusablePrivateInvoice(for: publicKey) { + if !forceRefresh, let invoice = await reusablePrivateInvoice(for: publicKey, receiverPath: receiverPath) { return invoice } @@ -28,7 +29,7 @@ extension PrivatePaykitService { paymentHash: decodedInvoice.paymentHash.hex, expiresAt: Double(decodedInvoice.timestampSeconds + decodedInvoice.expirySeconds) ) - state.contacts[publicKey, default: ContactState()].localInvoice = invoice + setLocalInvoice(invoice, publicKey: publicKey, receiverPath: receiverPath) persistState() return invoice } @@ -49,8 +50,8 @@ extension PrivatePaykitService { persistState() } - func reusablePrivateInvoice(for publicKey: String) async -> StoredInvoice? { - guard let invoice = state.contacts[publicKey]?.localInvoice, + func reusablePrivateInvoice(for publicKey: String, receiverPath: String) async -> StoredInvoice? { + guard let invoice = localInvoice(for: publicKey, receiverPath: receiverPath), invoice.expiresAt > Date().timeIntervalSince1970 + Self.invoiceRefreshBufferSeconds, await !isReceivedInvoiceSettled(paymentHash: invoice.paymentHash), case let .lightning(decodedInvoice) = try? await decode(invoice: invoice.bolt11), @@ -103,13 +104,9 @@ extension PrivatePaykitService { func settledPrivateInvoicePaymentHashes() async -> [String] { let settledHashes = await receivedSettledPaymentHashes() - return state.contacts.compactMap { _, contactState in - guard let paymentHash = contactState.localInvoice?.paymentHash, - settledHashes.contains(paymentHash) - else { return nil } - - return paymentHash - } + return state.contacts.values.flatMap { localInvoices($0) } + .map(\.paymentHash) + .filter(settledHashes.contains) } func isReceivedInvoiceSettled(paymentHash: String) async -> Bool { @@ -145,4 +142,18 @@ extension PrivatePaykitService { } ) } + + func localInvoice(for publicKey: String, receiverPath: String) -> StoredInvoice? { + state.contacts[publicKey]?.localInvoicesByReceiverPath[receiverPath] + } + + func localInvoices(_ contactState: ContactState) -> [StoredInvoice] { + Array(contactState.localInvoicesByReceiverPath.values) + } + + private func setLocalInvoice(_ invoice: StoredInvoice, publicKey: String, receiverPath: String) { + var contactState = state.contacts[publicKey, default: ContactState()] + contactState.localInvoicesByReceiverPath[receiverPath] = invoice + state.contacts[publicKey] = contactState + } } diff --git a/Bitkit/Services/PrivatePaykitService+Models.swift b/Bitkit/Services/PrivatePaykitService+Models.swift index bc0bbbdf3..c284fb59a 100644 --- a/Bitkit/Services/PrivatePaykitService+Models.swift +++ b/Bitkit/Services/PrivatePaykitService+Models.swift @@ -9,16 +9,14 @@ extension PrivatePaykitService { struct ContactState: Codable { var cachedResolvedEndpoints: [StoredPaymentEntry] = [] - var localInvoice: StoredInvoice? + var localInvoicesByReceiverPath: [String: StoredInvoice] = [:] var receivedInvoicePaymentHashes: [String] = [] - var hasPublishedPrivatePaymentList = false - - init() {} + var publishedPrivatePaymentReceiverPaths: [String] = [] var hasCacheState: Bool { - hasPublishedPrivatePaymentList || + !publishedPrivatePaymentReceiverPaths.isEmpty || !cachedResolvedEndpoints.isEmpty || - localInvoice != nil || + !localInvoicesByReceiverPath.isEmpty || !receivedInvoicePaymentHashes.isEmpty } } diff --git a/Bitkit/Services/PrivatePaykitService+Payments.swift b/Bitkit/Services/PrivatePaykitService+Payments.swift index 626f99e40..021126c01 100644 --- a/Bitkit/Services/PrivatePaykitService+Payments.swift +++ b/Bitkit/Services/PrivatePaykitService+Payments.swift @@ -8,7 +8,7 @@ extension PrivatePaykitService { guard !paymentHash.isEmpty else { return nil } return state.contacts.first { _, contactState in - contactState.localInvoice?.paymentHash == paymentHash || + localInvoices(contactState).contains { $0.paymentHash == paymentHash } || contactState.receivedInvoicePaymentHashes.contains(paymentHash) }?.key } @@ -34,7 +34,11 @@ extension PrivatePaykitService { } do { - let prepared = try await PaykitSdkService.shared.prepareAndResolveContactPayment(counterparty: publicKey, includePublicEndpoints: true) + let prepared = try await PaykitSdkService.shared.prepareAndResolveContactPayment( + counterparty: publicKey, + receiverPath: PaykitReceiverPath.wallet, + includePublicEndpoints: true + ) let resolution = prepared.resolution let privateEndpoints = resolvedEndpoints(from: resolution, source: .privatePaymentList) cacheResolvedEndpoints(privateEndpoints, publicKey: publicKey) diff --git a/Bitkit/Services/PrivatePaykitService.swift b/Bitkit/Services/PrivatePaykitService.swift index 9bc2ab8b0..3808d4187 100644 --- a/Bitkit/Services/PrivatePaykitService.swift +++ b/Bitkit/Services/PrivatePaykitService.swift @@ -32,6 +32,11 @@ private actor PrivatePaykitPublicationLock { } } +struct PrivateMessageDrainRetryKey: Hashable { + let publicKey: String + let receiverPath: String +} + // MARK: - Core Actor actor PrivatePaykitService { @@ -62,7 +67,7 @@ actor PrivatePaykitService { var state: PrivatePaykitState var knownSavedContactKeys: Set = [] var pendingMessageDrainRetryTask: Task? - var pendingMessageDrainRetryKeys: Set = [] + var pendingMessageDrainRetryKeys: Set = [] var pendingMessageDrainRetryGeneration = 0 private let publicationLock = PrivatePaykitPublicationLock() diff --git a/Bitkit/Services/PubkyService.swift b/Bitkit/Services/PubkyService.swift index 52ffc9fca..6be3f56ca 100644 --- a/Bitkit/Services/PubkyService.swift +++ b/Bitkit/Services/PubkyService.swift @@ -25,7 +25,21 @@ enum PubkyServiceError: LocalizedError { } } -/// Service layer wrapping BitkitCore key derivation and Paykit SDK workflows. +enum PaykitReceiverPath { + static let wallet = "bitkit/wallet" + static let server = "bitkit/server" + /// Current Bitkit flows only route its own receivers; cross-wallet routing can broaden this allowlist. + static let supported: [String] = [wallet, server] +} + +struct PrivateReceiverPathSelection { + var linkableReceiverPaths: [String] + var publishableReceiverPaths: [String] + var cleanupProtectedReceiverPaths: [String] + var error: Error? +} + +/// Service layer for Pubky sessions, profiles, contacts, and Paykit SDK workflows. enum PubkyService { static func initialize() async throws { try await PaykitSdkService.shared.initialize() @@ -143,8 +157,8 @@ enum PubkyService { 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 saveContact(publicKey: String, label: String?, receiverPaths: [String]? = nil) async throws -> Paykit.ContactRecord { + try await PaykitSdkService.shared.saveContact(publicKey: publicKey, label: label, receiverPaths: receiverPaths) } static func removeContact(publicKey: String) async throws -> Paykit.ContactRecord? { @@ -155,6 +169,10 @@ enum PubkyService { try await PaykitSdkService.shared.resolveContactProfile(publicKey: publicKey, allowPubkyProfileFallback: allowPubkyProfileFallback) } + static func discoverRelevantReceiverPaths(publicKey: String) async throws -> [String] { + try await PaykitSdkService.shared.discoverRelevantReceiverPaths(publicKey: publicKey) + } + // MARK: - Sign Out static func signOut() async throws { @@ -235,7 +253,8 @@ actor PaykitSdkService { let result = try await bootstrap().signUp( localSecretKey: Self.localSecretKey(fromHex: secretKeyHex), homeserverPublicKey: homeserverPublicKey, - signupCode: signupCode + signupCode: signupCode, + requiredCapabilities: Self.requiredCapabilities() ) try await activateBootstrapResult(result, previousPublicKey: previousPublicKey, shouldStoreLocalSecret: true) markWalletBackupDataChanged() @@ -246,7 +265,10 @@ actor PaykitSdkService { 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)) + let result = try await bootstrap().signIn( + localSecretKey: Self.localSecretKey(fromHex: secretKeyHex), + requiredCapabilities: Self.requiredCapabilities() + ) try await activateBootstrapResult(result, previousPublicKey: previousPublicKey, shouldStoreLocalSecret: true) markWalletBackupDataChanged() return result @@ -352,9 +374,17 @@ actor PaykitSdkService { } } - func saveContact(publicKey: String, label: String?) async throws -> Paykit.ContactRecord { + func contactRecord(publicKey: String) async throws -> Paykit.ContactRecord? { + try await operationLock.withLock { + try await handle().contactRecord(publicKey: publicKey) + } + } + + func saveContact(publicKey: String, label: String?, receiverPaths: [String]? = nil) async throws -> Paykit.ContactRecord { try await withStateRevisionTracking { sdk in - try await sdk.saveContact(update: Paykit.ContactUpdate(publicKey: publicKey, label: label)) + let existingPaths = try await sdk.contactRecord(publicKey: publicKey)?.receiverPaths ?? [] + let contactPaths = Self.mergedReceiverPaths(existingPaths + (receiverPaths ?? [])) + return try await sdk.saveContact(update: Paykit.ContactUpdate(publicKey: publicKey, receiverPaths: contactPaths, label: label)) } } @@ -366,7 +396,94 @@ actor PaykitSdkService { func resolveContactProfile(publicKey: String, allowPubkyProfileFallback: Bool) async throws -> Paykit.ContactProfileResolution? { try await operationLock.withLock { - try await handle().resolveContactProfile(publicKey: publicKey, allowPubkyProfileFallback: allowPubkyProfileFallback) + try await handle().resolveContactProfile( + publicKey: publicKey, + receiverPath: PaykitReceiverPath.wallet, + allowPubkyProfileFallback: allowPubkyProfileFallback + ) + } + } + + func discoverRelevantReceiverPaths(publicKey: String) async throws -> [String] { + try await operationLock.withLock { + let sdk = try handle() + let paths = try await sdk.paykitReceiverPaths(publicKey: publicKey) + var discovered = Set() + + for path in paths where PaykitReceiverPath.supported.contains(path) { + if path == PaykitReceiverPath.wallet { + discovered.insert(path) + continue + } + + guard let marker = try await sdk.paykitReceiverMarker(publicKey: publicKey, receiverPath: path), + Self.requiresPrivateLink(marker: marker) + else { continue } + + discovered.insert(path) + } + + return Self.mergedReceiverPaths(Array(discovered)) + } + } + + func privateReceiverPathSelection(publicKey: String, savedReceiverPaths: [String]) async throws -> PrivateReceiverPathSelection { + try await operationLock.withLock { + let paths = Self.mergedReceiverPaths(savedReceiverPaths) + guard let sdk = try? handle() else { + return PrivateReceiverPathSelection( + linkableReceiverPaths: [], + publishableReceiverPaths: [], + cleanupProtectedReceiverPaths: paths, + error: PubkyServiceError.sessionNotActive + ) + } + var linkable: [String] = [] + var publishable: [String] = [] + var cleanupProtected: [String] = [] + var firstError: Error? + + for path in paths { + do { + let marker = try await sdk.paykitReceiverMarker(publicKey: publicKey, receiverPath: path) + if Self.requiresPrivateLink(marker: marker) { + linkable.append(path) + } + if Self.canReceivePrivatePaymentDetails(marker: marker) { + publishable.append(path) + } + } catch is CancellationError { + throw CancellationError() + } catch { + cleanupProtected.append(path) + firstError = firstError ?? error + } + } + + return PrivateReceiverPathSelection( + linkableReceiverPaths: linkable, + publishableReceiverPaths: publishable, + cleanupProtectedReceiverPaths: cleanupProtected, + error: firstError + ) + } + } + + func syncLocalReceiverMarker(isDiscoverable: Bool) async throws { + try await withStateRevisionTracking { sdk in + guard isDiscoverable else { + try await sdk.removePaykitReceiverMarker() + return + } + + let status = try await sdk.identityStatus() + let capabilities = Paykit.PaykitReceiverCapabilities( + privatePayments: status?.privateLinkCapable == true, + paymentRequests: false, + receipts: false, + outgoingPayments: true + ) + _ = try await sdk.publishPaykitReceiverMarker(capabilities: capabilities) } } @@ -388,15 +505,22 @@ actor PaykitSdkService { } } - func ensureLinkWithPeer(_ counterparty: String, maxAdvanceSteps: UInt32 = 8) async throws -> LinkedPeerHandshakeReport { + func ensureLinkWithPeer( + _ counterparty: String, + receiverPath: String, + maxAdvanceSteps: UInt32 = 8 + ) async throws -> LinkedPeerHandshakeReport { try await withStateRevisionTracking { sdk in - try await sdk.ensureLinkWithPeer(counterparty: counterparty, maxAdvanceSteps: maxAdvanceSteps) + try await sdk.ensureLinkWithPeer(counterparty: counterparty, counterpartyReceiverPath: receiverPath, maxAdvanceSteps: maxAdvanceSteps) } } - func clearPrivatePaymentList(to counterparty: String) async throws -> PrivatePaymentListDeliveryReport { + func clearPrivatePaymentList( + to counterparty: String, + receiverPath: String + ) async throws -> PrivatePaymentListDeliveryReport { try await withStateRevisionTracking { sdk in - try await sdk.clearPrivatePaymentListAndProcessOutbound(counterparty: counterparty) + try await sdk.clearPrivatePaymentListAndProcessOutbound(counterparty: counterparty, counterpartyReceiverPath: receiverPath) } } @@ -418,16 +542,21 @@ actor PaykitSdkService { } } - func pendingOutboundPrivateCounterparties() async throws -> [String] { + func pendingOutboundPrivateCounterparties() async throws -> [CounterpartyReceiver] { try await operationLock.withLock { try await handle().pendingOutboundPrivateCounterparties() } } - func prepareAndResolveContactPayment(counterparty: String, includePublicEndpoints: Bool) async throws -> PreparedContactPayment { + func prepareAndResolveContactPayment( + counterparty: String, + receiverPath: String, + includePublicEndpoints: Bool + ) async throws -> PreparedContactPayment { try await withStateRevisionTracking { sdk in try await sdk.prepareAndResolveContactPayment( counterparty: counterparty, + counterpartyReceiverPath: receiverPath, amount: nil, includePublicEndpoints: includePublicEndpoints, maxAdvanceSteps: 8 @@ -435,9 +564,12 @@ actor PaykitSdkService { } } - func resolvePublicContactPayment(counterparty: String) async throws -> ContactPaymentResolution { + func resolvePublicContactPayment( + counterparty: String, + receiverPath: String + ) async throws -> ContactPaymentResolution { try await operationLock.withLock { - try await handle().resolvePublicContactPayment(counterparty: counterparty, amount: nil) + try await handle().resolvePublicContactPayment(counterparty: counterparty, counterpartyReceiverPath: receiverPath, amount: nil) } } @@ -618,12 +750,27 @@ actor PaykitSdkService { return normalizedLhs == normalizedRhs } + private nonisolated static func mergedReceiverPaths(_ paths: [String]) -> [String] { + var seen = Set() + return ([PaykitReceiverPath.wallet] + paths) + .filter { PaykitReceiverPath.supported.contains($0) } + .filter { seen.insert($0).inserted } + } + + private nonisolated static func requiresPrivateLink(marker: Paykit.PaykitReceiverMarker?) -> Bool { + marker?.capabilities.privatePayments == true || marker?.capabilities.paymentRequests == true || marker?.capabilities.receipts == true + } + + private nonisolated static func canReceivePrivatePaymentDetails(marker: Paykit.PaykitReceiverMarker?) -> Bool { + marker?.capabilities.privatePayments == true && marker?.capabilities.outgoingPayments == true + } + private func bootstrap() throws -> PubkySessionBootstrap { try PubkySessionBootstrap() } - private nonisolated static func config() -> PaykitSdkConfig { - var config = Paykit.defaultConfig() + private nonisolated static func config() throws -> PaykitSdkConfig { + var config = try Paykit.defaultConfig(receiverPath: PaykitReceiverPath.wallet) config.profileNamespace = switch Env.network { case .bitcoin: "bitkit.to" default: "staging.bitkit.to" @@ -776,7 +923,7 @@ private final class PaykitSdkPaymentAdapter: SdkPaymentAdapter, @unchecked Senda [] } - func reserveReceivingDetails(counterparty: String) throws -> ReceivingDetailReservationResponse { + func reserveReceivingDetails(counterparty _: String, counterpartyReceiverPath _: String) throws -> ReceivingDetailReservationResponse { ReceivingDetailReservationResponse(kind: .useCurrentReceivingDetails, reservations: []) } diff --git a/Bitkit/Services/PublicPaykitService.swift b/Bitkit/Services/PublicPaykitService.swift index aa429e228..6cbf288bb 100644 --- a/Bitkit/Services/PublicPaykitService.swift +++ b/Bitkit/Services/PublicPaykitService.swift @@ -195,7 +195,10 @@ enum PublicPaykitService { static func fetchPublicEndpoints(publicKey: String) async throws -> [Endpoint] { let normalizedKey = PubkyPublicKeyFormat.normalized(publicKey) ?? publicKey - let resolution = try await PaykitSdkService.shared.resolvePublicContactPayment(counterparty: normalizedKey) + let resolution = try await PaykitSdkService.shared.resolvePublicContactPayment( + counterparty: normalizedKey, + receiverPath: PaykitReceiverPath.wallet + ) var endpointsByMethodId: [MethodId: Endpoint] = [:] for resolvedEndpoint in resolution.payableEndpoints { @@ -250,19 +253,37 @@ enum PublicPaykitService { } @MainActor - static func syncPublishedEndpoints(wallet: WalletViewModel, publish: Bool) async throws { + static func syncPublishedEndpoints( + wallet: WalletViewModel, + publish: Bool + ) async throws { guard publish else { - try await removePublishedEndpoints() + var firstError: Error? + do { + try await removePublishedEndpoints() + } catch { + firstError = firstError ?? error + } + do { + try await syncLocalReceiverMarker(publicSharingEnabled: false) + } catch { + firstError = firstError ?? error + } + if let firstError { + throw firstError + } return } let desiredEndpoints = try await buildWalletEndpoints(wallet: wallet, refreshIfNeeded: true, requireEndpoint: true) + try await syncLocalReceiverMarker(publicSharingEnabled: true) try await applyPublishedEndpoints(desiredEndpoints) } @MainActor static func syncCurrentPublishedEndpoints(wallet: WalletViewModel) async throws { let desiredEndpoints = try await buildWalletEndpoints(wallet: wallet, refreshIfNeeded: false, requireEndpoint: false) + try await syncLocalReceiverMarker(publicSharingEnabled: true) try await applyPublishedEndpoints(desiredEndpoints) } @@ -270,6 +291,17 @@ enum PublicPaykitService { try await applyPublishedEndpoints([]) } + static func syncLocalReceiverMarker( + publicSharingEnabled: Bool? = nil, + privateSharingEnabled: Bool? = nil + ) async throws { + let publicSharing = publicSharingEnabled ?? UserDefaults.standard.bool(forKey: publishingEnabledKey) + let privateSharing = privateSharingEnabled ?? UserDefaults.standard.bool(forKey: PrivatePaykitService.publishingEnabledKey) + try await PaykitSdkService.shared.syncLocalReceiverMarker( + isDiscoverable: publicSharing || privateSharing + ) + } + static func hasPayablePublicEndpoint(publicKey: String) async throws -> Bool { let endpoints = try await payablePublicEndpoints(publicKey: publicKey) return !endpoints.isEmpty diff --git a/Bitkit/Views/Contacts/AddContactView.swift b/Bitkit/Views/Contacts/AddContactView.swift index 5fc567790..8ac42b9f1 100644 --- a/Bitkit/Views/Contacts/AddContactView.swift +++ b/Bitkit/Views/Contacts/AddContactView.swift @@ -219,6 +219,9 @@ struct AddContactView: View { isLoading = false return case .existingContact: + if let normalizedKey = PubkyPublicKeyFormat.normalized(publicKey) { + await contactsManager.refreshContactReceiverPaths(publicKey: normalizedKey, wallet: wallet) + } errorMessage = t("contacts__add_error_existing") canRetryError = false isLoading = false diff --git a/Bitkit/Views/Profile/PayContactsView.swift b/Bitkit/Views/Profile/PayContactsView.swift index 26dd868fd..16ac0d8d9 100644 --- a/Bitkit/Views/Profile/PayContactsView.swift +++ b/Bitkit/Views/Profile/PayContactsView.swift @@ -75,7 +75,22 @@ struct PayContactsView: View { do { if publish { - try await PublicPaykitService.syncPublishedEndpoints(wallet: wallet, publish: true) + let previousSharesPrivate = sharesPrivatePaykitEndpoints + let previousSharesPublic = sharesPublicPaykitEndpoints + let previousConfirmed = hasConfirmedPublicPaykitEndpoints + do { + try await PublicPaykitService.syncPublishedEndpoints( + wallet: wallet, + publish: true + ) + } catch { + await rollbackFailedContactPaymentEnable( + previousSharesPublic: previousSharesPublic, + previousSharesPrivate: previousSharesPrivate, + previousConfirmed: previousConfirmed + ) + throw error + } let canUsePrivateContactPayments = pubkyProfile.hasLocalSecretKeyForCurrentProfile sharesPrivatePaykitEndpoints = canUsePrivateContactPayments sharesPublicPaykitEndpoints = true @@ -112,6 +127,15 @@ struct PayContactsView: View { if publicCleanupError != nil { sharesPublicPaykitEndpoints = previousSharesPublic + if previousSharesPublic { + do { + try await PublicPaykitService.syncPublishedEndpoints(wallet: wallet, publish: true) + PublicPaykitService.setCleanupPending(false) + } catch { + PublicPaykitService.setCleanupPending(true) + Logger.warn("Failed to restore public Paykit endpoints after cleanup failure: \(error)", context: "PayContactsView") + } + } } if let privateCleanupError { @@ -133,13 +157,24 @@ struct PayContactsView: View { } } PrivatePaykitService.setContactSharingCleanupPending(!restoredPrivateSharing) - throw publicCleanupError ?? privateCleanupError } - PrivatePaykitService.setContactSharingCleanupPending(false) - if let publicCleanupError { - throw publicCleanupError + if let cleanupError = publicCleanupError ?? privateCleanupError { + do { + try await PublicPaykitService.syncLocalReceiverMarker( + publicSharingEnabled: sharesPublicPaykitEndpoints, + privateSharingEnabled: sharesPrivatePaykitEndpoints + ) + } catch { + Logger.warn( + "Failed to restore Paykit receiver marker after cleanup failure: \(error)", + context: "PayContactsView" + ) + } + throw cleanupError } + + PrivatePaykitService.setContactSharingCleanupPending(false) } navigation.path = [.profile] } catch { @@ -152,6 +187,24 @@ struct PayContactsView: View { ) } } + + private func rollbackFailedContactPaymentEnable( + previousSharesPublic: Bool, + previousSharesPrivate: Bool, + previousConfirmed: Bool + ) async { + sharesPublicPaykitEndpoints = previousSharesPublic + sharesPrivatePaykitEndpoints = previousSharesPrivate + hasConfirmedPublicPaykitEndpoints = previousConfirmed + + do { + try await PublicPaykitService.syncPublishedEndpoints(wallet: wallet, publish: previousSharesPublic) + PublicPaykitService.setCleanupPending(false) + } catch { + PublicPaykitService.setCleanupPending(true) + Logger.warn("Failed to roll back public Paykit endpoints after enabling contact payments failed: \(error)", context: "PayContactsView") + } + } } #Preview { diff --git a/Bitkit/Views/Scanner/ScannerScreen.swift b/Bitkit/Views/Scanner/ScannerScreen.swift index 9e27dffd4..da353190b 100644 --- a/Bitkit/Views/Scanner/ScannerScreen.swift +++ b/Bitkit/Views/Scanner/ScannerScreen.swift @@ -9,6 +9,7 @@ struct ScannerScreen: View { @EnvironmentObject private var scanner: ScannerManager @EnvironmentObject private var settings: SettingsViewModel @EnvironmentObject private var sheets: SheetViewModel + @EnvironmentObject private var wallet: WalletViewModel @State private var isManualEntryPresented = false @State private var manualEntry = "" @@ -72,7 +73,8 @@ struct ScannerScreen: View { settings: settings, navigation: navigation, pubkyProfile: pubkyProfile, - sheets: sheets + sheets: sheets, + wallet: wallet ) } .sheet(isPresented: $isManualEntryPresented) { diff --git a/Bitkit/Views/Scanner/ScannerSheet.swift b/Bitkit/Views/Scanner/ScannerSheet.swift index 01dd312a9..be4ac5b5c 100644 --- a/Bitkit/Views/Scanner/ScannerSheet.swift +++ b/Bitkit/Views/Scanner/ScannerSheet.swift @@ -14,6 +14,7 @@ struct ScannerSheet: View { @EnvironmentObject private var scanner: ScannerManager @EnvironmentObject private var settings: SettingsViewModel @EnvironmentObject private var sheets: SheetViewModel + @EnvironmentObject private var wallet: WalletViewModel @State private var isManualEntryPresented = false @State private var manualEntry = "" @@ -70,7 +71,8 @@ struct ScannerSheet: View { settings: settings, navigation: navigation, pubkyProfile: pubkyProfile, - sheets: sheets + sheets: sheets, + wallet: wallet ) } .sheet(isPresented: $isManualEntryPresented) { diff --git a/Bitkit/Views/Settings/General/PaymentPreferenceView.swift b/Bitkit/Views/Settings/General/PaymentPreferenceView.swift index d0cd3adc5..cafefcb55 100644 --- a/Bitkit/Views/Settings/General/PaymentPreferenceView.swift +++ b/Bitkit/Views/Settings/General/PaymentPreferenceView.swift @@ -161,12 +161,26 @@ struct PaymentPreferenceView: View { return } + if !sharesPublicPaykitEndpoints { + do { + try await PublicPaykitService.syncLocalReceiverMarker(publicSharingEnabled: false, privateSharingEnabled: true) + } catch { + await rollbackFailedPrivateSharingEnable(previousValue: previousValue, previousCleanupPending: previousCleanupPending) + Logger.error("Failed to enable private contact payments: \(error)", context: "PaymentPreferenceView") + app.toast(type: .error, title: t("common__error"), description: error.localizedDescription) + return + } + } + PrivatePaykitService.setContactSharingCleanupPending(false) hasConfirmedPublicPaykitEndpoints = true } else { do { try await PrivatePaykitService.shared.removePublishedEndpoints() sharesPrivatePaykitEndpoints = false + if !sharesPublicPaykitEndpoints { + try await PublicPaykitService.syncLocalReceiverMarker(publicSharingEnabled: false, privateSharingEnabled: false) + } hasConfirmedPublicPaykitEndpoints = true PrivatePaykitService.setContactSharingCleanupPending(false) } catch { @@ -177,6 +191,10 @@ struct PaymentPreferenceView: View { wallet: wallet ) } + await syncLocalReceiverMarkerBestEffort( + publicSharingEnabled: sharesPublicPaykitEndpoints, + privateSharingEnabled: previousValue + ) Logger.error("Failed to disable private contact payments: \(error)", context: "PaymentPreferenceView") app.toast(type: .error, title: t("common__error"), description: error.localizedDescription) } @@ -185,6 +203,9 @@ struct PaymentPreferenceView: View { private func rollbackFailedPrivateSharingEnable(previousValue: Bool, previousCleanupPending: Bool) async { sharesPrivatePaykitEndpoints = previousValue + if !sharesPublicPaykitEndpoints { + await syncLocalReceiverMarkerBestEffort(publicSharingEnabled: false, privateSharingEnabled: previousValue) + } guard !previousValue else { return } do { @@ -211,9 +232,19 @@ struct PaymentPreferenceView: View { defer { isUpdatingPublic = false } do { - try await PublicPaykitService.syncPublishedEndpoints(wallet: wallet, publish: enabled) + try await PublicPaykitService.syncPublishedEndpoints( + wallet: wallet, + publish: enabled + ) } catch { sharesPublicPaykitEndpoints = previousValue + do { + try await PublicPaykitService.syncPublishedEndpoints(wallet: wallet, publish: previousValue) + PublicPaykitService.setCleanupPending(false) + } catch { + PublicPaykitService.setCleanupPending(true) + Logger.warn("Failed to restore public contact payments after rollback: \(error)", context: "PaymentPreferenceView") + } Logger.error("Failed to update public contact payments: \(error)", context: "PaymentPreferenceView") app.toast(type: .error, title: t("common__error"), description: error.localizedDescription) } @@ -222,6 +253,7 @@ struct PaymentPreferenceView: View { private func reconcilePrivateSharingAvailability() async { guard sharesPrivatePaykitEndpoints, !canUsePrivateContactPayments else { return } sharesPrivatePaykitEndpoints = false + await syncLocalReceiverMarkerBestEffort(publicSharingEnabled: sharesPublicPaykitEndpoints, privateSharingEnabled: false) } private enum PaymentOption { @@ -302,4 +334,16 @@ struct PaymentPreferenceView: View { ) } } + + private func syncLocalReceiverMarkerBestEffort(publicSharingEnabled: Bool, privateSharingEnabled: Bool) async { + do { + try await PublicPaykitService.syncLocalReceiverMarker( + publicSharingEnabled: publicSharingEnabled, + privateSharingEnabled: privateSharingEnabled + ) + } catch { + PublicPaykitService.setCleanupPending(true) + Logger.warn("Failed to sync local Paykit receiver marker: \(error)", context: "PaymentPreferenceView") + } + } } diff --git a/Bitkit/Views/Wallets/Send/SendOptionsView.swift b/Bitkit/Views/Wallets/Send/SendOptionsView.swift index 2d4997e9d..65ddf1c55 100644 --- a/Bitkit/Views/Wallets/Send/SendOptionsView.swift +++ b/Bitkit/Views/Wallets/Send/SendOptionsView.swift @@ -85,7 +85,8 @@ struct SendOptionsView: View { settings: settings, navigation: navigation, pubkyProfile: pubkyProfile, - sheets: sheets + sheets: sheets, + wallet: wallet ) } } diff --git a/BitkitTests/ContactsManagerTests.swift b/BitkitTests/ContactsManagerTests.swift index badac61a3..b3047811e 100644 --- a/BitkitTests/ContactsManagerTests.swift +++ b/BitkitTests/ContactsManagerTests.swift @@ -6,7 +6,7 @@ import XCTest final class ContactsManagerTests: XCTestCase { override func setUp() { super.setUp() - UserDefaults.standard.removeObject(forKey: PaykitFeatureFlags.uiEnabledKey) + UserDefaults.standard.set(false, forKey: PaykitFeatureFlags.uiEnabledKey) } override func tearDown() { diff --git a/BitkitTests/PrivatePaykitServiceTests.swift b/BitkitTests/PrivatePaykitServiceTests.swift index 50e0eab81..c4cc7bdf4 100644 --- a/BitkitTests/PrivatePaykitServiceTests.swift +++ b/BitkitTests/PrivatePaykitServiceTests.swift @@ -46,13 +46,18 @@ final class PrivatePaykitServiceTests: XCTestCase { rawPayload: #"{"value":"lnbc1private"}"# ) - let reservations = await service.reservations(from: [endpoint], publicKey: publicKey) + let reservations = await service.reservations( + from: [endpoint], + publicKey: publicKey, + receiverPath: PaykitReceiverPath.wallet + ) XCTAssertEqual(reservations.count, 1) let reservation = try XCTUnwrap(reservations.first) let attribution = reservation.attribution XCTAssertEqual(attribution["type"], "private_paykit") XCTAssertEqual(attribution["counterparty"], publicKey) + XCTAssertEqual(attribution["receiver_path"], PaykitReceiverPath.wallet) XCTAssertEqual(attribution["payment_hash"], "payment-hash") } @@ -74,17 +79,37 @@ final class PrivatePaykitServiceTests: XCTestCase { rawPayload: #"{"value":"bcrt1qsecond"}"# ) - 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) + let firstReservations = await service.reservations( + from: [firstEndpoint], + publicKey: publicKey, + receiverPath: PaykitReceiverPath.wallet + ) + let repeatedReservations = await service.reservations( + from: [firstEndpoint], + publicKey: publicKey, + receiverPath: PaykitReceiverPath.wallet + ) + let secondReservations = await service.reservations( + from: [secondEndpoint], + publicKey: publicKey, + receiverPath: PaykitReceiverPath.wallet + ) + let serverReservations = await service.reservations( + from: [firstEndpoint], + publicKey: publicKey, + receiverPath: PaykitReceiverPath.server + ) let firstReservation = try XCTUnwrap(firstReservations.first) let repeatedReservation = try XCTUnwrap(repeatedReservations.first) let secondReservation = try XCTUnwrap(secondReservations.first) + let serverReservation = try XCTUnwrap(serverReservations.first) XCTAssertEqual(firstReservation.reservationId, repeatedReservation.reservationId) XCTAssertNotEqual(firstReservation.reservationId, secondReservation.reservationId) - XCTAssertTrue(firstReservation.reservationId.hasPrefix("\(publicKey):\(firstEndpoint.methodId.rawValue):")) + XCTAssertNotEqual(firstReservation.reservationId, serverReservation.reservationId) + XCTAssertEqual(serverReservation.attribution["receiver_path"], PaykitReceiverPath.server) + XCTAssertTrue(firstReservation.reservationId.hasPrefix("\(publicKey):\(PaykitReceiverPath.wallet):\(firstEndpoint.methodId.rawValue):")) XCTAssertLessThanOrEqual(firstReservation.reservationId.count, 128) } @@ -138,9 +163,13 @@ final class PrivatePaykitServiceTests: XCTestCase { endpointData: #"{"value":"lnbc1cached"}"# ), ] - contactState.localInvoice = PrivatePaykitService.StoredInvoice(bolt11: "lnbc1local", paymentHash: "hash", expiresAt: 123) + contactState.localInvoicesByReceiverPath[PaykitReceiverPath.wallet] = PrivatePaykitService.StoredInvoice( + bolt11: "lnbc1local", + paymentHash: "hash", + expiresAt: 123 + ) contactState.receivedInvoicePaymentHashes = ["received-hash"] - contactState.hasPublishedPrivatePaymentList = true + contactState.publishedPrivatePaymentReceiverPaths = [PaykitReceiverPath.wallet] let state = PrivatePaykitService.PrivatePaykitState(contacts: [publicKey: contactState]) let data = try JSONEncoder().encode(state) @@ -153,17 +182,17 @@ final class PrivatePaykitServiceTests: XCTestCase { 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.localInvoicesByReceiverPath[PaykitReceiverPath.wallet]?.bolt11, "lnbc1local") + XCTAssertEqual(decodedContact.localInvoicesByReceiverPath[PaykitReceiverPath.wallet]?.paymentHash, "hash") + XCTAssertEqual(decodedContact.localInvoicesByReceiverPath[PaykitReceiverPath.wallet]?.expiresAt, 123) XCTAssertEqual(decodedContact.receivedInvoicePaymentHashes, ["received-hash"]) - XCTAssertTrue(decodedContact.hasPublishedPrivatePaymentList) + XCTAssertEqual(decodedContact.publishedPrivatePaymentReceiverPaths, [PaykitReceiverPath.wallet]) } } private extension PrivatePaykitService { func setTestLocalInvoice(_ invoice: StoredInvoice, publicKey: String) { state.contacts[publicKey] = ContactState() - state.contacts[publicKey]?.localInvoice = invoice + state.contacts[publicKey]?.localInvoicesByReceiverPath[PaykitReceiverPath.wallet] = invoice } } diff --git a/changelog.d/next/620.added.md b/changelog.d/next/620.added.md new file mode 100644 index 000000000..ccbf4efab --- /dev/null +++ b/changelog.d/next/620.added.md @@ -0,0 +1 @@ +Added receiver-specific Paykit contact support for Bitkit wallet and server integrations. From d8bda823f108528acd920c532b0112f9431a6903 Mon Sep 17 00:00:00 2001 From: benk10 Date: Fri, 10 Jul 2026 11:58:51 +0200 Subject: [PATCH 2/3] fix: harden paykit receiver refresh --- ...PrivatePaykitAddressReservationStore.swift | 8 -- .../PrivatePaykitService+Contacts.swift | 79 +++++++++++-------- Bitkit/Views/Contacts/AddContactView.swift | 6 +- 3 files changed, 47 insertions(+), 46 deletions(-) diff --git a/Bitkit/Services/PrivatePaykitAddressReservationStore.swift b/Bitkit/Services/PrivatePaykitAddressReservationStore.swift index 5eba5af32..6a60c9732 100644 --- a/Bitkit/Services/PrivatePaykitAddressReservationStore.swift +++ b/Bitkit/Services/PrivatePaykitAddressReservationStore.swift @@ -69,14 +69,6 @@ actor PrivatePaykitAddressReservationStore { // MARK: - Contact Assignments - func hasContactAssignment(for publicKey: String) -> Bool { - guard let normalizedKey = PubkyPublicKeyFormat.normalized(publicKey), - let assignment = ledger.contactAssignments.first(where: { Self.publicKey(fromAssignmentKey: $0.key) == normalizedKey })?.value - else { return false } - - return LDKNode.AddressType.from(string: assignment.addressType) != nil - } - func contactPublicKey(forReservedAddress address: String) async -> String? { guard !address.isEmpty else { return nil } diff --git a/Bitkit/Services/PrivatePaykitService+Contacts.swift b/Bitkit/Services/PrivatePaykitService+Contacts.swift index a122db217..114212aec 100644 --- a/Bitkit/Services/PrivatePaykitService+Contacts.swift +++ b/Bitkit/Services/PrivatePaykitService+Contacts.swift @@ -79,6 +79,7 @@ extension PrivatePaykitService { func removePublishedEndpoints(for publicKeys: [String]) async throws { var firstError: Error? + var didChangeState = false for publicKey in normalizedSavedContactKeys(publicKeys) { var didFail = false let cleanupReceiverPaths: [String] @@ -113,17 +114,25 @@ extension PrivatePaykitService { if !didFail { if var contactState = state.contacts[publicKey] { + let hadStateToClear = !contactState.cachedResolvedEndpoints.isEmpty || + !contactState.localInvoicesByReceiverPath.isEmpty || + !contactState.publishedPrivatePaymentReceiverPaths.isEmpty contactState.cachedResolvedEndpoints = [] contactState.localInvoicesByReceiverPath = [:] contactState.publishedPrivatePaymentReceiverPaths = [] - state.contacts[publicKey] = contactState.hasCacheState ? contactState : nil + let shouldRemoveContact = !contactState.hasCacheState + state.contacts[publicKey] = shouldRemoveContact ? nil : contactState + didChangeState = didChangeState || hadStateToClear || shouldRemoveContact } Self.clearDeletedContactCleanupPending([publicKey]) - persistState(markWalletBackup: true) } } + if didChangeState { + persistState(markWalletBackup: true) + } + if let firstError { throw firstError } @@ -208,26 +217,6 @@ extension PrivatePaykitService { return Array(Self.pendingDeletedContactCleanupKeys().subtracting(savedKeys)).sorted() } - func clearUnsavedContactState(savedPublicKeys publicKeys: [String]) async { - await pruneUnsavedContactState(savedPublicKeys: publicKeys) - } - - 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 - } - } - private func syncLocalEndpointPublication( for publicKeys: [String], wallet: WalletViewModel, @@ -268,7 +257,17 @@ extension PrivatePaykitService { var updates = [PrivatePaymentListReservationUpdateInput]() var linkRetryKeys = [PrivateMessageDrainRetryKey]() for publicKey in publicKeys { - let receiverPaths = await receiverPathsForSavedContact(publicKey: publicKey) + let receiverPaths: [String] + do { + receiverPaths = try await receiverPathsForSavedContact(publicKey: publicKey) + } catch { + firstError = firstError ?? error + Logger.warn( + "Failed to read saved Paykit receivers for \(PubkyPublicKeyFormat.redacted(publicKey)) during \(reason): \(error)", + context: "PrivatePaykit" + ) + continue + } let receiverPathSelection: PrivateReceiverPathSelection do { receiverPathSelection = try await PaykitSdkService.shared.privateReceiverPathSelection( @@ -357,7 +356,8 @@ extension PrivatePaykitService { updates, clearUnlistedLinkedPeers: false ) - firstError = firstError ?? applyPrivatePaymentListDeliveryReport(report, reason: reason) + let deliveryError = applyPrivatePaymentListDeliveryReport(report, reason: reason) + firstError = firstError ?? deliveryError let retryKeys = linkRetryKeys + privatePaymentListDeliveryRetryKeys(from: report) await drainAndSchedulePrivateLinkRetries(reason: reason, retryKeys: retryKeys) } catch { @@ -373,8 +373,8 @@ extension PrivatePaykitService { var retryKeys = [PrivateMessageDrainRetryKey]() for publicKey in normalizedSavedContactKeys(publicKeys) { - let receiverPaths = await receiverPathsForSavedContact(publicKey: publicKey) do { + let receiverPaths = try await receiverPathsForSavedContact(publicKey: publicKey) let selection = try await PaykitSdkService.shared.privateReceiverPathSelection( publicKey: publicKey, savedReceiverPaths: receiverPaths @@ -559,14 +559,18 @@ extension PrivatePaykitService { for change in report.queued { guard let publicKey = PubkyPublicKeyFormat.normalized(change.counterparty) else { continue } - recordPublishedPrivatePaymentList(publicKey: publicKey, receiverPath: change.counterpartyReceiverPath) - didChangeState = true + didChangeState = recordPublishedPrivatePaymentList( + publicKey: publicKey, + receiverPath: change.counterpartyReceiverPath + ) || didChangeState } for change in report.cleared { guard let publicKey = PubkyPublicKeyFormat.normalized(change.counterparty) else { continue } - clearPublishedPrivatePaymentList(publicKey: publicKey, receiverPath: change.counterpartyReceiverPath) - didChangeState = true + didChangeState = clearPublishedPrivatePaymentList( + publicKey: publicKey, + receiverPath: change.counterpartyReceiverPath + ) || didChangeState } for change in report.failedToQueue { @@ -628,8 +632,8 @@ extension PrivatePaykitService { return knownSavedContactKeys.contains(normalizedKey) } - private func receiverPathsForSavedContact(publicKey: String) async -> [String] { - guard let record = try? await PaykitSdkService.shared.contactRecord(publicKey: publicKey) else { + private func receiverPathsForSavedContact(publicKey: String) async throws -> [String] { + guard let record = try await PaykitSdkService.shared.contactRecord(publicKey: publicKey) else { return [PaykitReceiverPath.wallet] } @@ -674,18 +678,23 @@ extension PrivatePaykitService { state.contacts[publicKey]?.publishedPrivatePaymentReceiverPaths ?? [] } - private func recordPublishedPrivatePaymentList(publicKey: String, receiverPath: String) { + private func recordPublishedPrivatePaymentList(publicKey: String, receiverPath: String) -> Bool { var contactState = state.contacts[publicKey, default: ContactState()] var paths = Set(contactState.publishedPrivatePaymentReceiverPaths) - paths.insert(receiverPath) + guard paths.insert(receiverPath).inserted else { return false } contactState.publishedPrivatePaymentReceiverPaths = Array(paths).sorted() state.contacts[publicKey] = contactState + return true } - private func clearPublishedPrivatePaymentList(publicKey: String, receiverPath: String) { - guard var contactState = state.contacts[publicKey] else { return } + private func clearPublishedPrivatePaymentList(publicKey: String, receiverPath: String) -> Bool { + guard var contactState = state.contacts[publicKey] else { return false } + let hadPublishedPath = contactState.publishedPrivatePaymentReceiverPaths.contains(receiverPath) + let hadLocalInvoice = contactState.localInvoicesByReceiverPath[receiverPath] != nil + guard hadPublishedPath || hadLocalInvoice else { return false } contactState.publishedPrivatePaymentReceiverPaths.removeAll { $0 == receiverPath } contactState.localInvoicesByReceiverPath.removeValue(forKey: receiverPath) state.contacts[publicKey] = contactState.hasCacheState ? contactState : nil + return true } } diff --git a/Bitkit/Views/Contacts/AddContactView.swift b/Bitkit/Views/Contacts/AddContactView.swift index 8ac42b9f1..8856a674e 100644 --- a/Bitkit/Views/Contacts/AddContactView.swift +++ b/Bitkit/Views/Contacts/AddContactView.swift @@ -219,12 +219,12 @@ struct AddContactView: View { isLoading = false return case .existingContact: - if let normalizedKey = PubkyPublicKeyFormat.normalized(publicKey) { - await contactsManager.refreshContactReceiverPaths(publicKey: normalizedKey, wallet: wallet) - } errorMessage = t("contacts__add_error_existing") canRetryError = false isLoading = false + if let normalizedKey = PubkyPublicKeyFormat.normalized(publicKey) { + await contactsManager.refreshContactReceiverPaths(publicKey: normalizedKey, wallet: wallet) + } return case let .valid(normalizedKey): if let profile = await contactsManager.fetchContactProfile(publicKey: normalizedKey, includePlaceholder: true) { From 5d77275cda7ba117b281ea250d2cd1676f5fbaa5 Mon Sep 17 00:00:00 2001 From: benk10 Date: Fri, 10 Jul 2026 18:34:04 +0200 Subject: [PATCH 3/3] fix: harden paykit receiver state --- Bitkit/AppScene.swift | 4 +++- Bitkit/Managers/ContactsManager.swift | 6 +++++- .../Services/PrivatePaykitService+Contacts.swift | 16 +++++++++++----- 3 files changed, 19 insertions(+), 7 deletions(-) diff --git a/Bitkit/AppScene.swift b/Bitkit/AppScene.swift index 99a0cba16..788047f75 100644 --- a/Bitkit/AppScene.swift +++ b/Bitkit/AppScene.swift @@ -674,8 +674,10 @@ struct AppScene: View { await wallet.refreshPublicPaykitEndpointsOnForeground() if PaykitFeatureFlags.isUIEnabled { await refreshPrivateOnlyPaykitReceiverMarker() + let contactPublicKeys = contactsManager.contacts.map(\.publicKey) await PrivatePaykitService.shared.refreshSavedContactEndpoints( - for: contactsManager.contacts.map(\.publicKey), + for: contactPublicKeys, + savedPublicKeys: contactPublicKeys, wallet: wallet ) } diff --git a/Bitkit/Managers/ContactsManager.swift b/Bitkit/Managers/ContactsManager.swift index 5d41f9d7a..a72f8065f 100644 --- a/Bitkit/Managers/ContactsManager.swift +++ b/Bitkit/Managers/ContactsManager.swift @@ -298,7 +298,11 @@ class ContactsManager: ObservableObject { do { let receiverPaths = try await Self.relevantReceiverPaths(for: prefixedKey) _ = try await PubkyService.saveContact(publicKey: prefixedKey, label: contact.profile.name, receiverPaths: receiverPaths) - await PrivatePaykitService.shared.refreshSavedContactEndpoints(for: [prefixedKey], wallet: wallet) + await PrivatePaykitService.shared.refreshSavedContactEndpoints( + for: [prefixedKey], + savedPublicKeys: contacts.map(\.publicKey), + wallet: wallet + ) } catch is CancellationError { return } catch { diff --git a/Bitkit/Services/PrivatePaykitService+Contacts.swift b/Bitkit/Services/PrivatePaykitService+Contacts.swift index 114212aec..4107ff85e 100644 --- a/Bitkit/Services/PrivatePaykitService+Contacts.swift +++ b/Bitkit/Services/PrivatePaykitService+Contacts.swift @@ -26,7 +26,16 @@ extension PrivatePaykitService { ) } - func refreshSavedContactEndpoints(for publicKeys: [String], wallet: WalletViewModel, forceRefreshLightning: Bool = false) async { + func refreshSavedContactEndpoints( + for publicKeys: [String], + savedPublicKeys: [String]? = nil, + wallet: WalletViewModel, + forceRefreshLightning: Bool = false + ) async { + if let savedPublicKeys { + _ = rememberSavedContacts(savedPublicKeys + publicKeys, replacing: false) + } + _ = await refreshSavedContactEndpointsReturningError( for: publicKeys, wallet: wallet, @@ -99,10 +108,7 @@ extension PrivatePaykitService { } let retryKey = PrivateMessageDrainRetryKey(publicKey: publicKey, receiverPath: receiverPath) await drainPendingPrivateMessages(reason: "cleanup", advancing: [retryKey]) - let pending = try await PaykitSdkService.shared.pendingOutboundPrivateCounterparties() - if pending.contains(where: { - PubkyPublicKeyFormat.normalized($0.counterparty) == publicKey && $0.counterpartyReceiverPath == receiverPath - }) { + if await pendingPrivateMessageDrainKeys([retryKey]).contains(retryKey) { throw PrivatePaykitError.privateUnavailable } } catch {