From cb633e5b5d36fa1534e87ffcb2ff0edc98e19e61 Mon Sep 17 00:00:00 2001 From: benk10 Date: Wed, 22 Jul 2026 11:28:18 +0200 Subject: [PATCH 1/2] refactor: batch paykit contact cleanup --- .../PrivatePaykitService+Contacts.swift | 130 ++++++++++-------- 1 file changed, 76 insertions(+), 54 deletions(-) diff --git a/Bitkit/Services/PrivatePaykitService+Contacts.swift b/Bitkit/Services/PrivatePaykitService+Contacts.swift index 059ed35a0..b2244eaca 100644 --- a/Bitkit/Services/PrivatePaykitService+Contacts.swift +++ b/Bitkit/Services/PrivatePaykitService+Contacts.swift @@ -87,54 +87,67 @@ extension PrivatePaykitService { } func removePublishedEndpoints(for publicKeys: [String]) async throws { + let publicKeys = normalizedSavedContactKeys(publicKeys) + guard !publicKeys.isEmpty else { return } + + let linkedReceiverPaths: [String: Set] + do { + linkedReceiverPaths = try await linkedReceiverPathsByPublicKey() + } catch { + Self.markDeletedContactCleanupPending(publicKeys) + throw error + } + var firstError: Error? + var failedPublicKeys = Set() + var clearedRetryKeys = [PrivateMessageDrainRetryKey]() var didChangeState = false - for publicKey in normalizedSavedContactKeys(publicKeys) { - var didFail = false - let cleanupReceiverPaths: [String] - do { - cleanupReceiverPaths = try await receiverPathsForCleanup(publicKey: publicKey) - } catch { - firstError = firstError ?? error - Self.markDeletedContactCleanupPending([publicKey]) - continue - } - + for publicKey in publicKeys { + let cleanupReceiverPaths = receiverPathsForCleanup( + publicKey: publicKey, + linkedReceiverPaths: linkedReceiverPaths[publicKey, default: []] + ) 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]) - if await pendingPrivateMessageDrainKeys([retryKey]).contains(retryKey) { - throw PrivatePaykitError.privateUnavailable - } + clearedRetryKeys.append(PrivateMessageDrainRetryKey(publicKey: publicKey, receiverPath: receiverPath)) } catch { - didFail = true + failedPublicKeys.insert(publicKey) firstError = firstError ?? error - Self.markDeletedContactCleanupPending([publicKey]) } } + } - 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 = [] - let shouldRemoveContact = !contactState.hasCacheState - state.contacts[publicKey] = shouldRemoveContact ? nil : contactState - didChangeState = didChangeState || hadStateToClear || shouldRemoveContact - } + if !clearedRetryKeys.isEmpty { + await drainPendingPrivateMessages(reason: "cleanup", advancing: clearedRetryKeys) + let pendingRetryKeys = await pendingPrivateMessageDrainKeys(clearedRetryKeys) + if !pendingRetryKeys.isEmpty { + failedPublicKeys.formUnion(pendingRetryKeys.map(\.publicKey)) + firstError = firstError ?? PrivatePaykitError.privateUnavailable + } + } - Self.clearDeletedContactCleanupPending([publicKey]) + let successfulPublicKeys = Set(publicKeys).subtracting(failedPublicKeys) + for publicKey in successfulPublicKeys { + if var contactState = state.contacts[publicKey] { + let hadStateToClear = !contactState.cachedResolvedEndpoints.isEmpty || + !contactState.localInvoicesByReceiverPath.isEmpty || + !contactState.publishedPrivatePaymentReceiverPaths.isEmpty + contactState.cachedResolvedEndpoints = [] + contactState.localInvoicesByReceiverPath = [:] + contactState.publishedPrivatePaymentReceiverPaths = [] + let shouldRemoveContact = !contactState.hasCacheState + state.contacts[publicKey] = shouldRemoveContact ? nil : contactState + didChangeState = didChangeState || hadStateToClear || shouldRemoveContact } } + Self.markDeletedContactCleanupPending(Array(failedPublicKeys)) + Self.clearDeletedContactCleanupPending(Array(successfulPublicKeys)) + if didChangeState { persistState(markWalletBackup: true) } @@ -265,6 +278,16 @@ extension PrivatePaykitService { var firstError: Error? var updates = [PrivatePaymentListReservationUpdateInput]() var linkRetryKeys = [PrivateMessageDrainRetryKey]() + let linkedReceiverPaths: [String: Set] + let linkedReceiverPathsError: Error? + do { + linkedReceiverPaths = try await linkedReceiverPathsByPublicKey() + linkedReceiverPathsError = nil + } catch { + linkedReceiverPaths = [:] + linkedReceiverPathsError = error + } + for publicKey in publicKeys { let receiverPaths: [String] do { @@ -296,18 +319,19 @@ extension PrivatePaykitService { ) } let cleanupReceiverPaths: [String] - do { - cleanupReceiverPaths = try await receiverPathsForPrivateEndpointCleanup( - publicKey: publicKey, - excluding: publicationReceiverPaths + receiverPathSelection.cleanupProtectedReceiverPaths - ) - } catch { + if let linkedReceiverPathsError { cleanupReceiverPaths = [] - firstError = firstError ?? error + firstError = firstError ?? linkedReceiverPathsError Logger.warn( - "Failed to inspect private Paykit links for \(PubkyPublicKeyFormat.redacted(publicKey)) during \(reason): \(error)", + "Failed to inspect private Paykit links for \(PubkyPublicKeyFormat.redacted(publicKey)) during \(reason): \(linkedReceiverPathsError)", context: "PrivatePaykit" ) + } else { + cleanupReceiverPaths = receiverPathsForPrivateEndpointCleanup( + publicKey: publicKey, + excluding: publicationReceiverPaths + receiverPathSelection.cleanupProtectedReceiverPaths, + linkedReceiverPaths: linkedReceiverPaths[publicKey, default: []] + ) } for receiverPath in Set(linkableReceiverPaths).union(cleanupReceiverPaths) { @@ -652,35 +676,33 @@ extension PrivatePaykitService { private func receiverPathsForPrivateEndpointCleanup( publicKey: String, - excluding publicationReceiverPaths: [String] - ) async throws -> [String] { + excluding publicationReceiverPaths: [String], + linkedReceiverPaths: Set + ) -> [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)) + return Array(Set(publishedPaths).union(linkedReceiverPaths).subtracting(excluded)) .filter { PaykitReceiverPath.supported.contains($0) } .sorted() } - private func receiverPathsForCleanup(publicKey: String) async throws -> [String] { - let linkedPaths = try await linkedReceiverPaths(publicKey: publicKey) + private func receiverPathsForCleanup(publicKey: String, linkedReceiverPaths: Set) -> [String] { let publishedPaths = publishedPrivatePaymentReceiverPaths(publicKey: publicKey) - return Array(Set(linkedPaths).union(publishedPaths)) + return Array(linkedReceiverPaths.union(publishedPaths)) .filter { PaykitReceiverPath.supported.contains($0) } .sorted() } - private func linkedReceiverPaths(publicKey: String) async throws -> [String] { - guard let publicKey = PubkyPublicKeyFormat.normalized(publicKey) else { return [] } + private func linkedReceiverPathsByPublicKey() async throws -> [String: Set] { let peers = try await PaykitSdkService.shared.linkedPeers() - - let linkedPaths = peers.compactMap { peer -> String? in - guard PubkyPublicKeyFormat.normalized(peer.counterparty) == publicKey, + var linkedPaths = [String: Set]() + for peer in peers { + guard let publicKey = PubkyPublicKeyFormat.normalized(peer.counterparty), PaykitReceiverPath.supported.contains(peer.counterpartyReceiverPath) - else { return nil } - return peer.counterpartyReceiverPath + else { continue } + linkedPaths[publicKey, default: []].insert(peer.counterpartyReceiverPath) } - return Array(Set(linkedPaths)).sorted() + return linkedPaths } private func publishedPrivatePaymentReceiverPaths(publicKey: String) -> [String] { From 0dd87ec41f7fa8bcc60f532a1c4c1863daead687 Mon Sep 17 00:00:00 2001 From: benk10 Date: Wed, 22 Jul 2026 11:52:14 +0200 Subject: [PATCH 2/2] fix: preserve paykit cleanup retries --- .../PrivatePaykitService+Contacts.swift | 78 +++++++++++++------ 1 file changed, 54 insertions(+), 24 deletions(-) diff --git a/Bitkit/Services/PrivatePaykitService+Contacts.swift b/Bitkit/Services/PrivatePaykitService+Contacts.swift index b2244eaca..96a57ae39 100644 --- a/Bitkit/Services/PrivatePaykitService+Contacts.swift +++ b/Bitkit/Services/PrivatePaykitService+Contacts.swift @@ -90,12 +90,15 @@ extension PrivatePaykitService { let publicKeys = normalizedSavedContactKeys(publicKeys) guard !publicKeys.isEmpty else { return } - let linkedReceiverPaths: [String: Set] + let linkedReceiverPathsSnapshot: [String: Set]? do { - linkedReceiverPaths = try await linkedReceiverPathsByPublicKey() + linkedReceiverPathsSnapshot = try await linkedReceiverPathsByPublicKey() } catch { - Self.markDeletedContactCleanupPending(publicKeys) - throw error + linkedReceiverPathsSnapshot = nil + Logger.warn( + "Failed to inspect private Paykit links for cleanup; retrying per contact: \(error)", + context: "PrivatePaykit" + ) } var firstError: Error? @@ -103,9 +106,25 @@ extension PrivatePaykitService { var clearedRetryKeys = [PrivateMessageDrainRetryKey]() var didChangeState = false for publicKey in publicKeys { + let contactLinkedReceiverPaths: Set + if let linkedReceiverPathsSnapshot { + contactLinkedReceiverPaths = linkedReceiverPathsSnapshot[publicKey, default: []] + } else { + do { + contactLinkedReceiverPaths = try await linkedReceiverPaths(publicKey: publicKey) + } catch { + contactLinkedReceiverPaths = [] + failedPublicKeys.insert(publicKey) + firstError = firstError ?? error + Logger.warn( + "Failed to inspect private Paykit links for \(PubkyPublicKeyFormat.redacted(publicKey)) during cleanup: \(error)", + context: "PrivatePaykit" + ) + } + } let cleanupReceiverPaths = receiverPathsForCleanup( publicKey: publicKey, - linkedReceiverPaths: linkedReceiverPaths[publicKey, default: []] + linkedReceiverPaths: contactLinkedReceiverPaths ) for receiverPath in cleanupReceiverPaths { do { @@ -278,14 +297,15 @@ extension PrivatePaykitService { var firstError: Error? var updates = [PrivatePaymentListReservationUpdateInput]() var linkRetryKeys = [PrivateMessageDrainRetryKey]() - let linkedReceiverPaths: [String: Set] - let linkedReceiverPathsError: Error? + let linkedReceiverPathsSnapshot: [String: Set]? do { - linkedReceiverPaths = try await linkedReceiverPathsByPublicKey() - linkedReceiverPathsError = nil + linkedReceiverPathsSnapshot = try await linkedReceiverPathsByPublicKey() } catch { - linkedReceiverPaths = [:] - linkedReceiverPathsError = error + linkedReceiverPathsSnapshot = nil + Logger.warn( + "Failed to inspect private Paykit links during \(reason); retrying per contact: \(error)", + context: "PrivatePaykit" + ) } for publicKey in publicKeys { @@ -318,21 +338,26 @@ extension PrivatePaykitService { context: "PrivatePaykit" ) } - let cleanupReceiverPaths: [String] - if let linkedReceiverPathsError { - cleanupReceiverPaths = [] - firstError = firstError ?? linkedReceiverPathsError - Logger.warn( - "Failed to inspect private Paykit links for \(PubkyPublicKeyFormat.redacted(publicKey)) during \(reason): \(linkedReceiverPathsError)", - context: "PrivatePaykit" - ) + let contactLinkedReceiverPaths: Set + if let linkedReceiverPathsSnapshot { + contactLinkedReceiverPaths = linkedReceiverPathsSnapshot[publicKey, default: []] } else { - cleanupReceiverPaths = receiverPathsForPrivateEndpointCleanup( - publicKey: publicKey, - excluding: publicationReceiverPaths + receiverPathSelection.cleanupProtectedReceiverPaths, - linkedReceiverPaths: linkedReceiverPaths[publicKey, default: []] - ) + do { + contactLinkedReceiverPaths = try await linkedReceiverPaths(publicKey: publicKey) + } catch { + contactLinkedReceiverPaths = [] + firstError = firstError ?? error + Logger.warn( + "Failed to inspect private Paykit links for \(PubkyPublicKeyFormat.redacted(publicKey)) during \(reason): \(error)", + context: "PrivatePaykit" + ) + } } + let cleanupReceiverPaths = receiverPathsForPrivateEndpointCleanup( + publicKey: publicKey, + excluding: publicationReceiverPaths + receiverPathSelection.cleanupProtectedReceiverPaths, + linkedReceiverPaths: contactLinkedReceiverPaths + ) for receiverPath in Set(linkableReceiverPaths).union(cleanupReceiverPaths) { linkRetryKeys.append(PrivateMessageDrainRetryKey(publicKey: publicKey, receiverPath: receiverPath)) @@ -705,6 +730,11 @@ extension PrivatePaykitService { return linkedPaths } + private func linkedReceiverPaths(publicKey: String) async throws -> Set { + guard let publicKey = PubkyPublicKeyFormat.normalized(publicKey) else { return [] } + return try await linkedReceiverPathsByPublicKey()[publicKey, default: []] + } + private func publishedPrivatePaymentReceiverPaths(publicKey: String) -> [String] { state.contacts[publicKey]?.publishedPrivatePaymentReceiverPaths ?? [] }