From 6d68812c0a0c25c900eb70a5d10cd71352c9cdb4 Mon Sep 17 00:00:00 2001 From: benk10 Date: Wed, 30 Sep 2026 22:53:38 -0500 Subject: [PATCH 01/68] feat: share paykit state across apps --- Bitkit.xcodeproj/project.pbxproj | 2 +- .../xcshareddata/swiftpm/Package.resolved | 4 +- Bitkit/AppScene.swift | 38 +- Bitkit/BitkitApp.swift | 5 - Bitkit/MainNavView.swift | 2 +- Bitkit/Managers/ContactsManager.swift | 27 +- Bitkit/Managers/PubkyProfileManager.swift | 4 +- Bitkit/Managers/ScannerManager.swift | 2 +- Bitkit/Models/PaykitPaymentStateBackup.swift | 6 +- Bitkit/Models/PubkyAuthRequest.swift | 71 +- Bitkit/Models/PubkyProfile.swift | 2 +- .../Localization/en.lproj/Localizable.strings | 2 + Bitkit/Services/ContactPaymentsService.swift | 31 +- .../Services/PaykitPaymentProofService.swift | 28 +- .../PaykitPaymentRequestService.swift | 88 +-- .../PaykitReceivedPaymentContacts.swift | 110 +++ Bitkit/Services/PaykitSubscription.swift | 13 +- ...PrivatePaykitAddressReservationStore.swift | 35 +- .../PrivatePaykitService+Backup.swift | 15 +- .../PrivatePaykitService+Contacts.swift | 354 +++------- .../PrivatePaykitService+Endpoints.swift | 19 +- .../PrivatePaykitService+Errors.swift | 4 + .../PrivatePaykitService+Invoices.swift | 23 +- .../PrivatePaykitService+Models.swift | 18 +- .../PrivatePaykitService+Payments.swift | 146 ++-- .../Services/PrivatePaykitService+State.swift | 6 +- Bitkit/Services/PrivatePaykitService.swift | 8 +- Bitkit/Services/PubkyService.swift | 621 ++++++----------- Bitkit/Services/PublicPaykitService.swift | 44 +- Bitkit/Utilities/Keychain.swift | 8 +- Bitkit/Views/Contacts/AddContactView.swift | 2 +- .../PubkyAuthApprovalSheet.swift | 15 +- .../Views/Wallets/Send/LnurlPayConfirm.swift | 4 + .../Wallets/Send/SendConfirmationView.swift | 8 + Bitkit/Views/Wallets/Send/SendSheet.swift | 10 +- BitkitTests/ContactPaymentsServiceTests.swift | 5 +- BitkitTests/ContactsManagerTests.swift | 4 +- BitkitTests/PaykitContactLifecycleTests.swift | 103 ++- BitkitTests/PaykitIssuerInteropTests.swift | 6 +- .../PaykitPaymentProofServiceTests.swift | 80 ++- .../PaykitPaymentRequestServiceTests.swift | 647 +++++++++++++----- .../PaykitPaymentStateBackupTests.swift | 36 +- .../PaykitReceiverNoiseKeyStoreTests.swift | 147 ---- BitkitTests/PaykitSdkClientConfigTests.swift | 299 +++++--- BitkitTests/PaykitStorageCallbackTests.swift | 74 +- .../PaykitSubscriptionProposalTests.swift | 2 + .../PaymentNavigationHelperTests.swift | 6 +- BitkitTests/PrivatePaykitServiceTests.swift | 212 +++--- BitkitTests/PubkyAuthApprovalSheetTests.swift | 103 ++- BitkitTests/PubkyAuthRequestTests.swift | 85 ++- BitkitTests/PubkyAuthURLSchemeTests.swift | 8 +- BitkitTests/PubkyIdentityRepublishTests.swift | 3 +- BitkitTests/PubkyProfileManagerTests.swift | 22 +- .../WatchOnlyAccountServiceTests.swift | 46 +- Docs/paykit-issuer-interoperability.md | 2 +- Docs/pubky-auth-companion-claims.md | 59 ++ Docs/watch-only-account-claim-v1.md | 52 -- .../next/paykit-shared-runtime.changed.md | 1 + journeys/README.md | 2 +- journeys/payment-requests/README.md | 6 +- .../contact-request-or-pay.xml | 2 +- .../delete-and-readd-contact.xml | 2 +- .../issuer-interoperability.xml | 6 +- journeys/payment-requests/request-summary.xml | 2 +- journeys/pubky-auth/open-watch-only-link.xml | 4 +- journeys/pubky-marketplace/README.md | 74 +- .../paykit-only-approval.xml | 19 + .../pubky-marketplace/paykit-reconnect.xml | 18 + journeys/pubky-marketplace/wallet-leg.xml | 12 +- journeys/subscriptions/create-and-propose.xml | 2 +- 70 files changed, 2022 insertions(+), 1904 deletions(-) create mode 100644 Bitkit/Services/PaykitReceivedPaymentContacts.swift delete mode 100644 BitkitTests/PaykitReceiverNoiseKeyStoreTests.swift create mode 100644 Docs/pubky-auth-companion-claims.md delete mode 100644 Docs/watch-only-account-claim-v1.md create mode 100644 changelog.d/next/paykit-shared-runtime.changed.md create mode 100644 journeys/pubky-marketplace/paykit-only-approval.xml create mode 100644 journeys/pubky-marketplace/paykit-reconnect.xml diff --git a/Bitkit.xcodeproj/project.pbxproj b/Bitkit.xcodeproj/project.pbxproj index d1c1db580..288f76d68 100644 --- a/Bitkit.xcodeproj/project.pbxproj +++ b/Bitkit.xcodeproj/project.pbxproj @@ -1178,7 +1178,7 @@ repositoryURL = "https://github.com/pubky/paykit-rs"; requirement = { kind = exactVersion; - version = "0.1.0-rc56"; + version = "0.1.0-rc58"; }; }; 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 0d91d3e52..d06d64a81 100644 --- a/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -33,8 +33,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pubky/paykit-rs", "state" : { - "revision" : "24162ebbcc703251d8038f2e176d1f4cfb117c6a", - "version" : "0.1.0-rc56" + "revision" : "258e3cd2329b275c9148c86e8ef540fc78c8d7f8", + "version" : "0.1.0-rc58" } }, { diff --git a/Bitkit/AppScene.swift b/Bitkit/AppScene.swift index 38ea88e97..535182e5e 100644 --- a/Bitkit/AppScene.swift +++ b/Bitkit/AppScene.swift @@ -421,7 +421,7 @@ struct AppScene: View { paykitPaymentRequestManager.activate(identity: pk) Task { try? await contactsManager.loadContacts(for: pk) - await refreshPrivateOnlyPaykitReceiverMarker() + await refreshPrivateOnlyPaykitApp() await refreshIncomingPaykitPaymentRequests(presentItems: false) await handlePendingPaykitSubscriptionNotification() if PaykitSubscriptionNotificationTargetStore.load() == nil { @@ -1013,7 +1013,7 @@ struct AppScene: View { await retryPendingPaykitEndpointRemoval() } guard PaykitFeatureFlags.isUIEnabled else { return } - await refreshPrivateOnlyPaykitReceiverMarker() + await refreshPrivateOnlyPaykitApp() await PrivatePaykitAddressReservationStore.shared.reconcileReservedIndexesWithLdk() await PrivatePaykitService.shared.prepareSavedContacts( contactsManager.contacts.map(\.publicKey), @@ -1064,7 +1064,7 @@ struct AppScene: View { await retryPendingPaykitEndpointRemoval() await wallet.refreshPublicPaykitEndpointsOnForeground() if PaykitFeatureFlags.isUIEnabled { - await refreshPrivateOnlyPaykitReceiverMarker() + await refreshPrivateOnlyPaykitApp() let contactPublicKeys = contactsManager.contacts.map(\.publicKey) await PrivatePaykitService.shared.startInitialLinkBurst( for: contactPublicKeys, @@ -1079,16 +1079,16 @@ struct AppScene: View { } } - private func refreshPrivateOnlyPaykitReceiverMarker() async { + private func refreshPrivateOnlyPaykitApp() 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() + try await PublicPaykitService.syncPaykitApp() } catch { - Logger.warn("Failed to refresh private Paykit receiver marker: \(error)", context: "AppScene") + Logger.warn("Failed to refresh private Paykit app registration: \(error)", context: "AppScene") } } @@ -1104,7 +1104,16 @@ struct AppScene: View { if refreshMaintenance { await PaykitPaymentProofService.shared.reconcile() } + let identity = pubkyProfile.publicKey await paykitPaymentRequestManager.refresh() + guard pubkyProfile.authState == .authenticated, + PubkyPublicKeyFormat.matches(identity, pubkyProfile.publicKey) + else { return } + do { + try await CoreService.shared.activity.backfillReceivedPaykitContacts(paykitPaymentRequestManager.receivedPaymentContacts) + } catch { + Logger.warn("Failed to attribute received Paykit payments: \(error)", context: "AppScene") + } if presentItems { await presentNextIncomingPaykitItem() } @@ -1411,7 +1420,6 @@ struct AppScene: View { } else if !paykitPaymentRequestManager.subscriptions.contains(where: { $0.paymentRequestId == target.paymentRequestId && PubkyPublicKeyFormat.matches($0.counterparty, target.counterparty) && - $0.counterpartyReceiverPath == target.counterpartyReceiverPath && $0.isActive(at: Date()) }) { PaykitSubscriptionNotificationTargetStore.clear() @@ -1442,6 +1450,15 @@ struct AppScene: View { } private func retryPendingPaykitEndpointRemoval() async { + let privateCleanupPending = UserDefaults.standard.bool(forKey: PrivatePaykitService.cleanupPendingKey) + await PrivatePaykitService.shared.retryPendingEndpointReconciliation( + wallet: wallet, + savedPublicKeys: contactsManager.contacts.map(\.publicKey) + ) + if privateCleanupPending, !UserDefaults.standard.bool(forKey: PrivatePaykitService.cleanupPendingKey) { + PublicPaykitService.setCleanupPending(true) + } + if PublicPaykitService.isCleanupPending { do { switch PublicPaykitService.pendingReconciliationMode() { @@ -1449,18 +1466,13 @@ struct AppScene: View { try await PublicPaykitService.syncCurrentPublishedEndpoints(wallet: wallet) case .removePublishedState: try await PublicPaykitService.removePublishedEndpoints() - try await PublicPaykitService.syncLocalReceiverMarker() + try await PublicPaykitService.syncPaykitApp() } PublicPaykitService.setCleanupPending(false) } catch { Logger.warn("Failed to reconcile public Paykit state: \(error)", context: "AppScene") } } - - await PrivatePaykitService.shared.retryPendingEndpointReconciliation( - wallet: wallet, - savedPublicKeys: contactsManager.contacts.map(\.publicKey) - ) } /// Removes all delivered notifications from Notification Center so the app can handle them when opened. diff --git a/Bitkit/BitkitApp.swift b/Bitkit/BitkitApp.swift index f293250c7..36c5914b1 100644 --- a/Bitkit/BitkitApp.swift +++ b/Bitkit/BitkitApp.swift @@ -13,28 +13,24 @@ struct PaykitSubscriptionNotificationTarget: Codable, Equatable { let payerIdentity: String let paymentRequestId: String let counterparty: String - let counterpartyReceiverPath: String let billingPeriodStartsAt: String init?(userInfo: [AnyHashable: Any]) { guard let payerIdentity = userInfo["payer_identity"] as? String, let paymentRequestId = userInfo["payment_request_id"] as? String, let counterparty = userInfo["counterparty"] as? String, - let counterpartyReceiverPath = userInfo["counterparty_receiver_path"] as? String, let billingPeriodStartsAt = userInfo["billing_period_starts_at"] as? String else { return nil } self.payerIdentity = payerIdentity self.paymentRequestId = paymentRequestId self.counterparty = counterparty - self.counterpartyReceiverPath = counterpartyReceiverPath self.billingPeriodStartsAt = billingPeriodStartsAt } func matches(_ request: PaykitPaymentRequest) -> Bool { paymentRequestId == request.paymentRequestId && PubkyPublicKeyFormat.matches(counterparty, request.counterparty) && - counterpartyReceiverPath == request.counterpartyReceiverPath && request.billingPeriod.map { PaykitSubscriptionTimestamp.string(from: $0.startsAt) == billingPeriodStartsAt } == true @@ -43,7 +39,6 @@ struct PaykitSubscriptionNotificationTarget: Codable, Equatable { func matches(_ requestId: PaykitPaymentRequest.ID) -> Bool { paymentRequestId == requestId.paymentRequestId && PubkyPublicKeyFormat.matches(counterparty, requestId.counterparty) && - counterpartyReceiverPath == requestId.counterpartyReceiverPath && requestId.billingPeriodStartsAt.map { PaykitSubscriptionTimestamp.string(from: $0) == billingPeriodStartsAt } == true diff --git a/Bitkit/MainNavView.swift b/Bitkit/MainNavView.swift index 0d6bae8c4..db3c0ddb6 100644 --- a/Bitkit/MainNavView.swift +++ b/Bitkit/MainNavView.swift @@ -777,7 +777,7 @@ struct MainNavView: View { ) { navigation.navigate(route) if case let .contactDetail(publicKey) = route { - await contactsManager.refreshContactReceiverPaths(publicKey: publicKey, wallet: wallet) + await contactsManager.refreshContactLink(publicKey: publicKey, wallet: wallet) } clipboardUri = nil return diff --git a/Bitkit/Managers/ContactsManager.swift b/Bitkit/Managers/ContactsManager.swift index c55cabb76..78182ab1e 100644 --- a/Bitkit/Managers/ContactsManager.swift +++ b/Bitkit/Managers/ContactsManager.swift @@ -320,11 +320,9 @@ class ContactsManager: ObservableObject { try await resolveContactProfile(publicKey: prefixedKey, includePlaceholder: true) } - let receiverPaths = try await Self.relevantReceiverPaths(for: prefixedKey) _ = try await PubkyService.saveContact( publicKey: prefixedKey, label: profile.name, - receiverPaths: receiverPaths, restorePrivateConnection: true ) @@ -335,25 +333,24 @@ class ContactsManager: ObservableObject { contacts.sort { $0.displayName.localizedCaseInsensitiveCompare($1.displayName) == .orderedAscending } } - func refreshContactReceiverPaths(publicKey: String, wallet: WalletViewModel) async { + func refreshContactLink(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) + _ = try await PubkyService.saveContact(publicKey: prefixedKey, label: contact.profile.name) await PrivatePaykitService.shared.startInitialLinkBurst( for: [prefixedKey], savedPublicKeys: contacts.map(\.publicKey), wallet: wallet, - reason: "contact receiver refresh" + reason: "contact link refresh" ) } catch is CancellationError { return } catch { Logger.warn( - "Failed to refresh contact receiver paths for \(PubkyPublicKeyFormat.redacted(prefixedKey)): \(error)", + "Failed to refresh contact link for \(PubkyPublicKeyFormat.redacted(prefixedKey)): \(error)", context: "ContactsManager" ) } @@ -375,7 +372,7 @@ class ContactsManager: ObservableObject { try Task.checkCancellation() guard !existingKeys.contains(contact.publicKey) else { continue } do { - // The preview already resolved this profile. Receiver discovery runs during contact refresh. + // The preview already resolved this profile. try await saveContact(contact.publicKey, contact.displayName) imported.append(contact) existingKeys.insert(contact.publicKey) @@ -659,20 +656,6 @@ 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 08a79aed2..9540e9f31 100644 --- a/Bitkit/Managers/PubkyProfileManager.swift +++ b/Bitkit/Managers/PubkyProfileManager.swift @@ -854,9 +854,9 @@ class PubkyProfileManager: ObservableObject { } do { - try await PublicPaykitService.syncLocalReceiverMarker(publicSharingEnabled: false, privateSharingEnabled: false) + try await PublicPaykitService.syncPaykitApp(privateSharingEnabled: false) } catch PubkyServiceError.sessionNotActive { - Logger.debug("Skipping Paykit receiver marker cleanup because no session is active", context: context) + Logger.debug("Skipping Paykit app capability update because no session is active", context: context) } catch { firstError = firstError ?? error } diff --git a/Bitkit/Managers/ScannerManager.swift b/Bitkit/Managers/ScannerManager.swift index 8cf300667..a3f2561df 100644 --- a/Bitkit/Managers/ScannerManager.swift +++ b/Bitkit/Managers/ScannerManager.swift @@ -139,7 +139,7 @@ class ScannerManager: ObservableObject { let wallet { Task { - await contactsManager.refreshContactReceiverPaths(publicKey: publicKey, wallet: wallet) + await contactsManager.refreshContactLink(publicKey: publicKey, wallet: wallet) } } return true diff --git a/Bitkit/Models/PaykitPaymentStateBackup.swift b/Bitkit/Models/PaykitPaymentStateBackup.swift index d2897bf50..cc6279b16 100644 --- a/Bitkit/Models/PaykitPaymentStateBackup.swift +++ b/Bitkit/Models/PaykitPaymentStateBackup.swift @@ -8,13 +8,11 @@ struct PaykitPaymentStateBackup: Codable { struct RequestID: Codable { let paymentRequestId: String let counterparty: String - let counterpartyReceiverPath: String let billingPeriodStartsAt: String? init(_ id: PaykitPaymentRequest.ID, billingPeriod: PaykitBillingPeriod?) { paymentRequestId = id.paymentRequestId counterparty = id.counterparty - counterpartyReceiverPath = id.counterpartyReceiverPath billingPeriodStartsAt = billingPeriod?.sdkValue.startsAt } @@ -22,7 +20,6 @@ struct PaykitPaymentStateBackup: Codable { PaykitPaymentRequest.ID( paymentRequestId: paymentRequestId, counterparty: counterparty, - counterpartyReceiverPath: counterpartyReceiverPath, billingPeriodStartsAt: billingPeriod?.startsAt ) } @@ -62,6 +59,7 @@ struct PaykitPaymentStateBackup: Codable { let identity: String let requestId: RequestID + let paymentAppId: String let paymentEndpointIdentifier: String let kind: PaykitPaymentProofKind let paymentStarted: Bool @@ -76,6 +74,7 @@ struct PaykitPaymentStateBackup: Codable { init(_ proof: PendingPaykitPaymentProof) { identity = proof.identity requestId = RequestID(proof.requestId, billingPeriod: proof.billingPeriod) + paymentAppId = proof.paymentAppId paymentEndpointIdentifier = proof.paymentEndpointIdentifier kind = proof.kind paymentStarted = proof.paymentStarted @@ -98,6 +97,7 @@ struct PaykitPaymentStateBackup: Codable { return PendingPaykitPaymentProof( identity: identity, requestId: requestId.restored(billingPeriod: period), + paymentAppId: paymentAppId, paymentEndpointIdentifier: paymentEndpointIdentifier, kind: kind, billingPeriod: period, diff --git a/Bitkit/Models/PubkyAuthRequest.swift b/Bitkit/Models/PubkyAuthRequest.swift index 79bd1d72c..75ca6cf50 100644 --- a/Bitkit/Models/PubkyAuthRequest.swift +++ b/Bitkit/Models/PubkyAuthRequest.swift @@ -2,24 +2,67 @@ import BitkitCore import Foundation import Paykit -enum PubkyAuthClaim: String, Equatable { - case watchOnlyAccountV1 = "watch-only-account-v1" +struct PubkyAuthClaim: Equatable { + private enum Item: String { + case paykitAccessV1 = "paykit-access-v1" + case watchOnlyAccountV1 = "watch-only-account-v1" + } + + static let watchOnlyAccountV1 = Self(items: [.watchOnlyAccountV1]) + static let paykitAccessV1 = Self(items: [.paykitAccessV1]) + + private let items: [Item] + + private init(items: [Item]) { + self.items = items + } + + init?(rawValue: String) { + let values = rawValue.split(separator: ".", omittingEmptySubsequences: false) + let items = values.compactMap { Item(rawValue: String($0)) } + guard !items.isEmpty, items.count == values.count, Set(items).count == items.count else { return nil } + self.items = items + } + + var rawValue: String { + // Preserve the requested order because the SDK binds this value into the signature and relay channel. + items.map(\.rawValue).joined(separator: ".") + } static let queryParameter = "x-bitkit-claim" - static let watchOnlyAccountCapabilities = "/pub/paykit/v0/bitkit/server/:rw,/pub/paykit/v0/private/bitkit/server/:rw" - private static let watchOnlyAccountCapabilitySet = Set(watchOnlyAccountCapabilities.split(separator: ",").map(String.init)) + static let requiredCapabilities = Paykit.requiredSessionCapabilities() - static func matchesWatchOnlyAccountCapabilities(_ capabilities: String) -> Bool { - guard let requestedCapabilitySet = capabilitySet(capabilities) else { return false } - return requestedCapabilitySet == watchOnlyAccountCapabilitySet + var includesWatchOnlyAccount: Bool { + items.contains(.watchOnlyAccountV1) + } + + var includesPaykitAccess: Bool { + items.contains(.paykitAccessV1) + } + + func encode(accountPayload: Data?, paykitKey: PaykitIdentitySecretKey?) throws -> Data { + guard includesWatchOnlyAccount == (accountPayload != nil), + includesPaykitAccess == (paykitKey != nil) + else { + throw PubkyAuthRequestError.invalidUrl + } + if let accountPayload, + accountPayload.count != WatchOnlyAccountClaimCodec.payloadLength || accountPayload.first != 1 + { + throw WatchOnlyAccountError.invalidExtendedPublicKey + } + var payload = accountPayload ?? Data([1]) + if let paykitKey { + payload.append(contentsOf: withUnsafeBytes(of: paykitKey.keyGeneration().bigEndian, Array.init)) + payload.append(paykitKey.exportBytes()) + } + return payload } - private static func capabilitySet(_ capabilities: String) -> Set? { - let entries = capabilities + static func matchesRequiredCapabilities(_ capabilities: String) -> Bool { + capabilities .split(separator: ",", omittingEmptySubsequences: false) - .map { $0.trimmingCharacters(in: .whitespaces) } - guard !entries.contains(where: \.isEmpty) else { return nil } - return Set(entries) + .allSatisfy { $0.trimmingCharacters(in: .whitespaces) == requiredCapabilities } } } @@ -293,7 +336,7 @@ struct PubkyAuthRequest { throw PubkyAuthRequestError.duplicateBitkitClaim } guard let claimValue = claimValues.first else { - if requiresBitkitClaim || PubkyAuthClaim.matchesWatchOnlyAccountCapabilities(capabilities) { + if requiresBitkitClaim { throw PubkyAuthRequestError.missingBitkitClaim } return nil @@ -301,7 +344,7 @@ struct PubkyAuthRequest { guard let claim = PubkyAuthClaim(rawValue: claimValue) else { throw PubkyAuthRequestError.unsupportedBitkitClaim(claimValue) } - guard PubkyAuthClaim.matchesWatchOnlyAccountCapabilities(capabilities) else { + guard PubkyAuthClaim.matchesRequiredCapabilities(capabilities) else { throw PubkyAuthRequestError.invalidBitkitClaimCapabilities } diff --git a/Bitkit/Models/PubkyProfile.swift b/Bitkit/Models/PubkyProfile.swift index aa263e44d..0c3574362 100644 --- a/Bitkit/Models/PubkyProfile.swift +++ b/Bitkit/Models/PubkyProfile.swift @@ -134,7 +134,7 @@ struct PubkyProfile { self = PubkyProfileData.from(paykitProfile: paykitProfile).toProfile(publicKey: publicKey) } - init(resolution: Paykit.ContactProfileResolution) { + init(resolution: Paykit.ProfileResolution) { let publicKey = Self.normalizedPublicKey(resolution.publicKey) if let paykitProfile = resolution.paykitProfile { self.init(publicKey: publicKey, paykitProfile: paykitProfile) diff --git a/Bitkit/Resources/Localization/en.lproj/Localizable.strings b/Bitkit/Resources/Localization/en.lproj/Localizable.strings index 3ac9a1716..62d9d200e 100644 --- a/Bitkit/Resources/Localization/en.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/en.lproj/Localizable.strings @@ -711,6 +711,8 @@ "pubky_auth__watch_only_account_name_error" = "Enter an account name between 1 and 64 characters."; "pubky_auth__watch_only_intro_approve" = "Approve"; "pubky_auth__watch_only_intro_description" = "To earn, you need to share a watch-only Bitcoin account with Paykit. It can view sales activity, but cannot spend funds."; +"pubky_auth__paykit_access_title" = "PRIVATE PAYKIT ACCESS"; +"pubky_auth__paykit_access_description" = "Read and manage your private Paykit data, and send Paykit messages on your behalf. Your Pubky identity secret and wallet spending keys are not shared."; "pubky_auth__watch_only_intro_relay" = "Your authorization will be delivered to {relay}."; "pubky_auth__watch_only_intro_nav_title" = "Earn"; "pubky_auth__watch_only_intro_title" = "EARN BITCOIN\nFROM YOUR\nCONTENT"; diff --git a/Bitkit/Services/ContactPaymentsService.swift b/Bitkit/Services/ContactPaymentsService.swift index 17cac48f6..51d7ae324 100644 --- a/Bitkit/Services/ContactPaymentsService.swift +++ b/Bitkit/Services/ContactPaymentsService.swift @@ -4,6 +4,7 @@ enum ContactPaymentsService { static let confirmedPreferenceKey = "hasConfirmedPublicPaykitEndpoints" struct Operations { + let syncPaykitApp: (_ privatePaymentsEnabled: Bool) async throws -> Void let syncPublicEndpoints: (_ publish: Bool) async throws -> Void let preparePrivateEndpoints: (_ contactPublicKeys: [String], _ requireImmediatePublication: Bool) async -> Error? let removePrivateEndpoints: () async throws -> Void @@ -13,6 +14,9 @@ enum ContactPaymentsService { @MainActor static func live(wallet: WalletViewModel) -> Operations { Operations( + syncPaykitApp: { enabled in + try await PublicPaykitService.syncPaykitApp(privateSharingEnabled: enabled) + }, syncPublicEndpoints: { publish in try await PublicPaykitService.syncPublishedEndpoints(wallet: wallet, publish: publish) }, @@ -123,6 +127,7 @@ enum ContactPaymentsService { defaults.set(canUsePrivatePayments, forKey: PrivatePaykitService.publishingEnabledKey) defaults.set(true, forKey: confirmedPreferenceKey) + try await operations.syncPaykitApp(canUsePrivatePayments) if canUsePrivatePayments, let error = await operations.preparePrivateEndpoints( contactPublicKeys, @@ -153,6 +158,10 @@ enum ContactPaymentsService { ) } + defaults.set(false, forKey: PublicPaykitService.publishingEnabledKey) + defaults.set(false, forKey: PrivatePaykitService.publishingEnabledKey) + defaults.set(true, forKey: confirmedPreferenceKey) + do { try await operations.syncPublicEndpoints(false) operations.setPublicCleanupPending(false) @@ -160,10 +169,6 @@ enum ContactPaymentsService { operations.setPublicCleanupPending(true) throw error } - - defaults.set(false, forKey: PublicPaykitService.publishingEnabledKey) - defaults.set(false, forKey: PrivatePaykitService.publishingEnabledKey) - defaults.set(true, forKey: confirmedPreferenceKey) } @MainActor @@ -179,6 +184,16 @@ enum ContactPaymentsService { defaults.set(restoresPrivateEndpoints, forKey: PrivatePaykitService.publishingEnabledKey) defaults.set(state.hasConfirmedPreference, forKey: confirmedPreferenceKey) + if !restoresPrivateEndpoints { + do { + try await operations.removePrivateEndpoints() + operations.setPrivateCleanupPending(state.privateCleanupPending) + } catch { + operations.setPrivateCleanupPending(true) + Logger.warn("Failed to clean up private contact payments: \(error)", context: "ContactPaymentsService") + } + } + do { try await operations.syncPublicEndpoints(state.sharesPublicEndpoints) operations.setPublicCleanupPending(state.publicCleanupPending) @@ -197,14 +212,6 @@ enum ContactPaymentsService { } else { operations.setPrivateCleanupPending(state.privateCleanupPending) } - } else { - do { - try await operations.removePrivateEndpoints() - operations.setPrivateCleanupPending(state.privateCleanupPending) - } catch { - operations.setPrivateCleanupPending(true) - Logger.warn("Failed to clean up private contact payments: \(error)", context: "ContactPaymentsService") - } } } } diff --git a/Bitkit/Services/PaykitPaymentProofService.swift b/Bitkit/Services/PaykitPaymentProofService.swift index a2ec54aa9..b74e080f8 100644 --- a/Bitkit/Services/PaykitPaymentProofService.swift +++ b/Bitkit/Services/PaykitPaymentProofService.swift @@ -23,6 +23,7 @@ struct PaykitOnchainPaymentResolution: Equatable { struct PendingPaykitPaymentProof: Codable, Equatable { let identity: String let requestId: PaykitPaymentRequest.ID + let paymentAppId: String let paymentEndpointIdentifier: String let kind: PaykitPaymentProofKind let billingPeriod: PaykitBillingPeriod? @@ -41,6 +42,7 @@ struct PendingPaykitPaymentProof: Codable, Equatable { init( identity: String, requestId: PaykitPaymentRequest.ID, + paymentAppId: String, paymentEndpointIdentifier: String, kind: PaykitPaymentProofKind, billingPeriod: PaykitBillingPeriod? = nil, @@ -54,6 +56,7 @@ struct PendingPaykitPaymentProof: Codable, Equatable { ) { self.identity = identity self.requestId = requestId + self.paymentAppId = paymentAppId self.paymentEndpointIdentifier = paymentEndpointIdentifier self.kind = kind self.billingPeriod = billingPeriod @@ -100,7 +103,6 @@ protocol PaykitPaymentProofSdkHandling: Sendable { func processPendingPrivateMessages() async throws -> [Paykit.OutboundPrivateCounterpartySendReport] func submitPaymentProof( counterparty: String, - counterpartyReceiverPath: String, paymentRequestId: String, proof: Paykit.PaymentProofSubmission ) async throws -> Paykit.PaymentRequestRecord @@ -231,11 +233,13 @@ actor PaykitPaymentProofService { func prepare( request: PaykitPaymentRequest, + paymentAppId: String, paymentEndpointIdentifier: String, kind: PaykitPaymentProofKind ) async throws { let proof = try await pendingProof( request: request, + paymentAppId: paymentAppId, paymentEndpointIdentifier: paymentEndpointIdentifier, kind: kind ) @@ -262,13 +266,14 @@ actor PaykitPaymentProofService { private func pendingProof( request: PaykitPaymentRequest, + paymentAppId: String, paymentEndpointIdentifier: String, kind: PaykitPaymentProofKind ) async throws -> PendingPaykitPaymentProof { guard request.acceptedPaymentEndpointIdentifiers.contains(paymentEndpointIdentifier), Self.endpoint(paymentEndpointIdentifier, supports: kind), let identityStatus = try await sdk.identityStatus(), - identityStatus.liveSessionAvailable, + identityStatus.capability == .privateLinkCapable, let publicKey = identityStatus.publicKey, let identity = PubkyPublicKeyFormat.normalized(publicKey) else { @@ -278,6 +283,7 @@ actor PaykitPaymentProofService { return PendingPaykitPaymentProof( identity: identity, requestId: request.id, + paymentAppId: paymentAppId, paymentEndpointIdentifier: paymentEndpointIdentifier, kind: kind, billingPeriod: request.billingPeriod, @@ -363,11 +369,13 @@ actor PaykitPaymentProofService { func completeOnchainPayment( _ request: PaykitPaymentRequest, txid: String, + paymentAppId: String, paymentEndpointIdentifier: String ) async { guard let identity = try? await currentIdentity() else { return } let fallbackProof = try? await pendingProof( request: request, + paymentAppId: paymentAppId, paymentEndpointIdentifier: paymentEndpointIdentifier, kind: .onchain ) @@ -485,7 +493,7 @@ actor PaykitPaymentProofService { var pendingProofs = try await loadProofs() guard !pendingProofs.isEmpty else { return } guard let identityStatus = try await sdk.identityStatus(), - identityStatus.liveSessionAvailable, + identityStatus.capability == .privateLinkCapable, let publicKey = identityStatus.publicKey, let identity = PubkyPublicKeyFormat.normalized(publicKey) else { return } @@ -544,7 +552,7 @@ actor PaykitPaymentProofService { do { let records = try await sdk.paymentRequests() guard let identityStatus = try await sdk.identityStatus(), - identityStatus.liveSessionAvailable, + identityStatus.capability == .privateLinkCapable, PubkyPublicKeyFormat.matches(identityStatus.publicKey, identity) else { return pendingProofs } @@ -569,7 +577,6 @@ actor PaykitPaymentProofService { guard record.localRole == .payer, record.paymentRequestId == pendingProof.requestId.paymentRequestId, PubkyPublicKeyFormat.matches(record.counterparty, pendingProof.requestId.counterparty), - record.counterpartyReceiverPath == pendingProof.requestId.counterpartyReceiverPath, pendingProof.billingPeriod != nil || record.state == .proofSubmitted else { return false } @@ -627,8 +634,7 @@ actor PaykitPaymentProofService { PubkyPublicKeyFormat.matches($0.identity, identity) && $0.requestId.billingPeriodStartsAt != nil && $0.requestId.paymentRequestId == subscriptionId.paymentRequestId && - $0.requestId.counterparty == subscriptionId.counterparty && - $0.requestId.counterpartyReceiverPath == subscriptionId.counterpartyReceiverPath + $0.requestId.counterparty == subscriptionId.counterparty } let protectedRequestIds: Set = Set(proofs.compactMap { proof in guard belongsToSubscription(proof) else { return nil } @@ -662,20 +668,20 @@ actor PaykitPaymentProofService { guard !pendingProof.hasUnsupportedOnchainWallet, let proofData = pendingProof.proofData else { return false } do { guard let identityStatus = try await sdk.identityStatus(), - identityStatus.liveSessionAvailable, + identityStatus.capability == .privateLinkCapable, PubkyPublicKeyFormat.matches(identityStatus.publicKey, pendingProof.identity) else { return false } let records = try await sdk.paymentRequests() guard let request = records.first(where: { $0.paymentRequestId == pendingProof.requestId.paymentRequestId && - PubkyPublicKeyFormat.matches($0.counterparty, pendingProof.requestId.counterparty) && - $0.counterpartyReceiverPath == pendingProof.requestId.counterpartyReceiverPath + PubkyPublicKeyFormat.matches($0.counterparty, pendingProof.requestId.counterparty) }) else { return false } let proofText = try Self.proofText(kind: pendingProof.kind, data: proofData) let isAlreadyQueued = request.paymentProofs.contains(where: { Self.billingPeriod($0.billingPeriod, matches: pendingProof.billingPeriod) && + $0.paymentAppId == pendingProof.paymentAppId && $0.paymentEndpointIdentifier == pendingProof.paymentEndpointIdentifier && Self.proofValues($0.proof.exportText()) == Self.proofValues(proofText) }) @@ -683,10 +689,10 @@ actor PaykitPaymentProofService { if !isAlreadyQueued { _ = try await sdk.submitPaymentProof( counterparty: pendingProof.requestId.counterparty, - counterpartyReceiverPath: pendingProof.requestId.counterpartyReceiverPath, paymentRequestId: pendingProof.requestId.paymentRequestId, proof: Paykit.PaymentProofSubmission( billingPeriod: pendingProof.billingPeriod?.sdkValue, + paymentAppId: pendingProof.paymentAppId, paymentEndpointIdentifier: pendingProof.paymentEndpointIdentifier, allowanceId: nil, conversionQuoteId: nil, diff --git a/Bitkit/Services/PaykitPaymentRequestService.swift b/Bitkit/Services/PaykitPaymentRequestService.swift index 1fb33407e..3a1779240 100644 --- a/Bitkit/Services/PaykitPaymentRequestService.swift +++ b/Bitkit/Services/PaykitPaymentRequestService.swift @@ -38,25 +38,21 @@ struct PaykitPaymentRequest: Identifiable, Hashable { struct ID: Codable, Hashable { let paymentRequestId: String let counterparty: String - let counterpartyReceiverPath: String let billingPeriodStartsAt: Date? init( paymentRequestId: String, counterparty: String, - counterpartyReceiverPath: String, billingPeriodStartsAt: Date? = nil ) { self.paymentRequestId = paymentRequestId self.counterparty = counterparty - self.counterpartyReceiverPath = counterpartyReceiverPath self.billingPeriodStartsAt = billingPeriodStartsAt } } let paymentRequestId: String let counterparty: String - let counterpartyReceiverPath: String let amountValue: String let amountSats: UInt64 let note: String? @@ -77,7 +73,6 @@ struct PaykitPaymentRequest: Identifiable, Hashable { ID( paymentRequestId: paymentRequestId, counterparty: counterparty, - counterpartyReceiverPath: counterpartyReceiverPath, billingPeriodStartsAt: billingPeriod?.startsAt ) } @@ -169,7 +164,6 @@ struct PaykitPaymentRequest: Identifiable, Hashable { return .success(PaykitPaymentRequest( paymentRequestId: record.paymentRequestId, counterparty: record.counterparty, - counterpartyReceiverPath: record.counterpartyReceiverPath, amountValue: terms.amount.value, amountSats: amountSats, note: Self.note(from: terms.metadata), @@ -213,7 +207,6 @@ struct PaykitPaymentRequest: Identifiable, Hashable { ) { paymentRequestId = createdRecord.paymentRequestId counterparty = target.publicKey - counterpartyReceiverPath = target.receiverPath amountValue = WalletViewModel.formatBitcoinAmount(sats: draft.amountSats) amountSats = draft.amountSats let trimmedNote = draft.note.trimmingCharacters(in: .whitespacesAndNewlines) @@ -235,7 +228,6 @@ struct PaykitPaymentRequest: Identifiable, Hashable { PaykitPaymentRequest( paymentRequestId: paymentRequestId, counterparty: counterparty, - counterpartyReceiverPath: counterpartyReceiverPath, amountValue: amountValue, amountSats: amountSats, note: note, @@ -259,7 +251,6 @@ struct PaykitPaymentRequest: Identifiable, Hashable { ) { paymentRequestId = subscription.paymentRequestId counterparty = subscription.counterparty - counterpartyReceiverPath = subscription.counterpartyReceiverPath amountValue = subscription.amountValue amountSats = subscription.amountSats note = subscription.note @@ -276,7 +267,6 @@ struct PaykitPaymentRequest: Identifiable, Hashable { private init( paymentRequestId: String, counterparty: String, - counterpartyReceiverPath: String, amountValue: String, amountSats: UInt64, note: String?, @@ -291,7 +281,6 @@ struct PaykitPaymentRequest: Identifiable, Hashable { ) { self.paymentRequestId = paymentRequestId self.counterparty = counterparty - self.counterpartyReceiverPath = counterpartyReceiverPath self.amountValue = amountValue self.amountSats = amountSats self.note = note @@ -326,8 +315,7 @@ struct PaykitPaymentRequest: Identifiable, Hashable { func belongs(to subscription: PaykitSubscription) -> Bool { billingPeriod != nil && paymentRequestId == subscription.paymentRequestId && - counterparty == subscription.counterparty && - counterpartyReceiverPath == subscription.counterpartyReceiverPath + counterparty == subscription.counterparty } static func sats(fromBitcoinAmount amount: String) -> UInt64? { @@ -383,10 +371,9 @@ struct PaykitPaymentRequest: Identifiable, Hashable { struct PaykitPaymentRequestTarget: Identifiable, Equatable, Hashable { let publicKey: String - let receiverPath: String var id: String { - "\(publicKey)|\(receiverPath)" + publicKey } } @@ -425,15 +412,18 @@ struct PaykitPaymentRequestSnapshot: Equatable { let incoming: [PaykitPaymentRequest] let history: [PaykitPaymentRequest] let subscriptions: [PaykitSubscription] + let receivedPaymentContacts: PaykitReceivedPaymentContacts init( incoming: [PaykitPaymentRequest], history: [PaykitPaymentRequest], - subscriptions: [PaykitSubscription] = [] + subscriptions: [PaykitSubscription] = [], + receivedPaymentContacts: PaykitReceivedPaymentContacts = PaykitReceivedPaymentContacts() ) { self.incoming = incoming self.history = history self.subscriptions = subscriptions + self.receivedPaymentContacts = receivedPaymentContacts } } @@ -502,30 +492,28 @@ protocol PaykitPaymentRequestSdkHandling: Sendable { func processPendingPrivateMessages() async throws -> [Paykit.OutboundPrivateCounterpartySendReport] func receivePrivateMessagesFromLinkedPeers() async throws -> [Paykit.PrivateStreamCounterpartyIntakeReport] func paymentRequests() async throws -> [Paykit.PaymentRequestRecord] + func sharedPaymentRequests() async throws -> [Paykit.PaymentRequestRecord] func identityStatus() async throws -> Paykit.IdentityStatus? func linkedPeers() async throws -> [Paykit.LinkedPeerRecord] - func paymentRequestReceiverPaths(publicKey: String) async throws -> [String] + func canReceivePaymentRequests(publicKey: String) async throws -> Bool func proposePaymentRequest( counterparty: String, - counterpartyReceiverPath: String, terms: Paykit.PaymentRequestTerms, expectedIdentity: String ) async throws -> Paykit.PaymentRequestRecord func uploadProfileAvatar(bytes: Data, contentType: String, expectedIdentity: String?) async throws -> String func acceptPaymentRequest( counterparty: String, - counterpartyReceiverPath: String, paymentRequestId: String ) async throws -> Paykit.PaymentRequestRecord + func claimPaymentRequestForExecution(counterparty: String, paymentRequestId: String) async throws -> Paykit.PaymentRequestRecord func rejectPaymentRequest( counterparty: String, - counterpartyReceiverPath: String, paymentRequestId: String, reason: String? ) async throws -> Paykit.PaymentRequestRecord func cancelPaymentRequest( counterparty: String, - counterpartyReceiverPath: String, paymentRequestId: String, reason: String? ) async throws -> Paykit.PaymentRequestRecord @@ -561,11 +549,12 @@ struct PaykitPaymentRequestService { let intakeReports = try await sdk.receivePrivateMessagesFromLinkedPeers() logIntakeFailures(intakeReports) let synchronizationDate = now() - let records = try await sdk.paymentRequests() + let sharedRecords = try await sdk.sharedPaymentRequests() + let records = sharedRecords.filter(PaykitSdkService.isBitkitPaymentRequest) let blockedPeers = try await sdk.linkedPeers().filter { $0.state == .blocked } let availableRecords = records.filter { record in !blockedPeers.contains { - PubkyPublicKeyFormat.matches($0.counterparty, record.counterparty) && $0.counterpartyReceiverPath == record.counterpartyReceiverPath + PubkyPublicKeyFormat.matches($0.counterparty, record.counterparty) } } var rejections: [IncomingPaykitPaymentRequestRejection] = [] @@ -599,7 +588,8 @@ struct PaykitPaymentRequestService { return PaykitPaymentRequestSnapshot( incoming: incoming, history: history, - subscriptions: subscriptions + subscriptions: subscriptions, + receivedPaymentContacts: PaykitReceivedPaymentContacts(records: sharedRecords) ) } @@ -607,7 +597,6 @@ struct PaykitPaymentRequestService { try await sdk.paymentRequests().first { $0.paymentRequestId == request.paymentRequestId && $0.counterparty == request.counterparty && - $0.counterpartyReceiverPath == request.counterpartyReceiverPath && $0.localRole == .payer }?.state } @@ -625,7 +614,7 @@ struct PaykitPaymentRequestService { let unavailable = PaykitPaymentRequestTargetDiscovery(targets: [], isComplete: true) guard isPrivatePaymentPublishingEnabled(), !Self.acceptedPaymentEndpointIdentifiers().isEmpty else { return unavailable } guard let identityStatus = try await sdk.identityStatus(), - identityStatus.liveSessionAvailable, + identityStatus.capability == .privateLinkCapable, PubkyPublicKeyFormat.matches(identityStatus.publicKey, expectedIdentity) else { return unavailable } var seenSavedKeys = Set() @@ -633,40 +622,30 @@ struct PaykitPaymentRequestService { seenSavedKeys.insert($0).inserted } let linkedPeers = try await sdk.linkedPeers().filter { $0.state == .linked } - var linkedPathsByPublicKey: [String: Set] = [:] - - for peer in linkedPeers { - guard let publicKey = PubkyPublicKeyFormat.normalized(peer.counterparty), - PaykitReceiverPath.supported.contains(peer.counterpartyReceiverPath) - else { continue } - linkedPathsByPublicKey[publicKey, default: []].insert(peer.counterpartyReceiverPath) - } + let linkedKeys = Set(linkedPeers.compactMap { PubkyPublicKeyFormat.normalized($0.counterparty) }) var targets: [PaykitPaymentRequestTarget] = [] var isComplete = true for publicKey in savedKeys { - guard let linkedPaths = linkedPathsByPublicKey[publicKey] else { continue } - let capablePaths: [String] + guard linkedKeys.contains(publicKey) else { continue } + let canReceive: Bool do { try Task.checkCancellation() - capablePaths = try await sdk.paymentRequestReceiverPaths(publicKey: publicKey) + canReceive = try await sdk.canReceivePaymentRequests(publicKey: publicKey) } catch is CancellationError { throw CancellationError() } catch { isComplete = false logWarning("Failed to inspect Paykit payment request support for \(PubkyPublicKeyFormat.redacted(publicKey)): \(error)") if let previousTarget = previousTargets.first(where: { - PubkyPublicKeyFormat.matches($0.publicKey, publicKey) && linkedPaths.contains($0.receiverPath) + PubkyPublicKeyFormat.matches($0.publicKey, publicKey) }) { targets.append(previousTarget) } continue } - guard let receiverPath = PaykitReceiverPath.supported.first(where: { - linkedPaths.contains($0) && capablePaths.contains($0) - }) else { continue } - - targets.append(PaykitPaymentRequestTarget(publicKey: publicKey, receiverPath: receiverPath)) + guard canReceive else { continue } + targets.append(PaykitPaymentRequestTarget(publicKey: publicKey)) } return PaykitPaymentRequestTargetDiscovery(targets: targets, isComplete: isComplete) } @@ -699,13 +678,14 @@ struct PaykitPaymentRequestService { proposalExpiresAt: Self.timestamp(draft.expiresAt), recurrence: nil, acceptedPaymentEndpointIdentifiers: acceptedPaymentEndpointIdentifiers, + paymentEndpoints: nil, + requiredAppId: "bitkit", conversion: nil, paymentDeadline: nil, metadata: Paykit.PrivateJsonObject(text: metadataText) ) let record = try await sdk.proposePaymentRequest( counterparty: target.publicKey, - counterpartyReceiverPath: target.receiverPath, terms: terms, expectedIdentity: expectedIdentity ) @@ -780,7 +760,6 @@ struct PaykitPaymentRequestService { try PaykitSubscriptionProposal.validate(terms) let record = try await sdk.proposePaymentRequest( counterparty: target.publicKey, - counterpartyReceiverPath: target.receiverPath, terms: terms, expectedIdentity: expectedIdentity ) @@ -830,6 +809,8 @@ struct PaykitPaymentRequestService { proposalExpiresAt: Self.timestamp(draft.expiresAt), recurrence: recurrence, acceptedPaymentEndpointIdentifiers: endpoints, + paymentEndpoints: nil, + requiredAppId: "bitkit", conversion: nil, paymentDeadline: nil, metadata: Paykit.PrivateJsonObject(text: metadataText) @@ -838,8 +819,7 @@ struct PaykitPaymentRequestService { func ensurePaymentAllowed(_ request: PaykitPaymentRequest) async throws { guard try await !sdk.linkedPeers().contains(where: { - $0.state == .blocked && PubkyPublicKeyFormat.matches($0.counterparty, request.counterparty) && - $0.counterpartyReceiverPath == request.counterpartyReceiverPath + $0.state == .blocked && PubkyPublicKeyFormat.matches($0.counterparty, request.counterparty) }) else { throw PaykitPaymentRequestError.requestUnavailable } } @@ -850,12 +830,15 @@ struct PaykitPaymentRequestService { _ = try await sdk.acceptPaymentRequest( counterparty: request.counterparty, - counterpartyReceiverPath: request.counterpartyReceiverPath, paymentRequestId: request.paymentRequestId ) _ = try? await processPendingMessages() } + func claimForPayment(_ request: PaykitPaymentRequest) async throws { + _ = try await sdk.claimPaymentRequestForExecution(counterparty: request.counterparty, paymentRequestId: request.paymentRequestId) + } + func reject(_ request: PaykitPaymentRequest) async throws { guard !request.isExpired(at: now()) else { throw PaykitPaymentRequestError.requestExpired @@ -863,7 +846,6 @@ struct PaykitPaymentRequestService { _ = try await sdk.rejectPaymentRequest( counterparty: request.counterparty, - counterpartyReceiverPath: request.counterpartyReceiverPath, paymentRequestId: request.paymentRequestId, reason: nil ) @@ -873,7 +855,6 @@ struct PaykitPaymentRequestService { func cancel(_ request: PaykitPaymentRequest) async throws { _ = try await sdk.cancelPaymentRequest( counterparty: request.counterparty, - counterpartyReceiverPath: request.counterpartyReceiverPath, paymentRequestId: request.paymentRequestId, reason: nil ) @@ -887,7 +868,6 @@ struct PaykitPaymentRequestService { let record = try await sdk.acceptPaymentRequest( counterparty: subscription.counterparty, - counterpartyReceiverPath: subscription.counterpartyReceiverPath, paymentRequestId: subscription.paymentRequestId ) _ = try? await processPendingMessages() @@ -904,7 +884,6 @@ struct PaykitPaymentRequestService { let record = try await sdk.cancelPaymentRequest( counterparty: subscription.counterparty, - counterpartyReceiverPath: subscription.counterpartyReceiverPath, paymentRequestId: subscription.paymentRequestId, reason: nil ) @@ -985,7 +964,6 @@ struct PaykitPaymentRequestService { guard let messageId = record.proposalOutboundMessageId else { return false } return reports.contains { report in PubkyPublicKeyFormat.matches(report.counterparty, record.counterparty) && - report.counterpartyReceiverPath == record.counterpartyReceiverPath && report.report?.sent.contains(messageId) == true } } @@ -1074,6 +1052,7 @@ final class PaykitPaymentRequestManager { private(set) var pendingRequests: [PaykitPaymentRequest] = [] private(set) var historyRequests: [PaykitPaymentRequest] = [] private(set) var subscriptions: [PaykitSubscription] = [] + private(set) var receivedPaymentContacts = PaykitReceivedPaymentContacts() private(set) var eligibleTargets: [PaykitPaymentRequestTarget] = [] private(set) var requestedPresentationId: PaykitPaymentRequest.ID? private(set) var requestedSubscriptionProposalId: PaykitSubscription.ID? @@ -1510,6 +1489,7 @@ final class PaykitPaymentRequestManager { preservePending: !request.requiresAcceptance ) { try await ensurePaymentAllowed($0) + try await service.claimForPayment($0) try await consumePrivatePaymentList() if $0.requiresAcceptance { try await service.accept($0) @@ -1715,6 +1695,7 @@ final class PaykitPaymentRequestManager { pendingRequests = [] historyRequests = [] subscriptions = [] + receivedPaymentContacts = PaykitReceivedPaymentContacts() eligibleTargets = [] processingRequestIds = [] approvedPaymentRequestIds = [] @@ -1972,6 +1953,7 @@ final class PaykitPaymentRequestManager { let refreshDate = now() let handledRequestedExpirationId = recordRequestedPresentationExpiration(at: refreshDate) let previousPending = pendingRequests + receivedPaymentContacts = snapshot.receivedPaymentContacts subscriptions = snapshot.subscriptions.map { $0.withExpiredLifecycle(at: refreshDate) } let visibleProposalIds = Set(subscriptions.filter { $0.isPayer && $0.isProposalVisible(at: refreshDate) diff --git a/Bitkit/Services/PaykitReceivedPaymentContacts.swift b/Bitkit/Services/PaykitReceivedPaymentContacts.swift new file mode 100644 index 000000000..83becbf09 --- /dev/null +++ b/Bitkit/Services/PaykitReceivedPaymentContacts.swift @@ -0,0 +1,110 @@ +import BitkitCore +import Foundation +import LDKNode +import Paykit + +struct PaykitReceivedPaymentContacts: Equatable { + private var onchain: [String: Set] = [:] + private var paymentHashes: [String: Set] = [:] + + init(records: [PaymentRequestRecord] = [], network: LDKNode.Network = Env.network) { + for record in records { + guard record.localRole == .payee, + record.state != .invalidConflict, + record.state != .unknown, + record.invalidReason == nil, + record.counterparty.trimmingCharacters(in: .whitespacesAndNewlines).count <= PubkyPublicKeyFormat.maximumInputLength, + let counterparty = PubkyPublicKeyFormat.normalized(record.counterparty), + let terms = record.terms, terms.amount.asset == PaykitIssuerInterop.bitcoinAsset, + let endpoints = terms.paymentEndpoints + else { continue } + + for (identifier, data) in endpoints { + guard terms.acceptedPaymentEndpointIdentifiers.contains(identifier), + let method = PublicPaykitService.MethodId(rawValue: identifier), + let payload = PaykitIssuerInterop.parseEndpointPayload(data) + else { continue } + + if method.onchainNetwork == network { + guard let address = try? validateBitcoinAddress(address: payload.value) else { continue } + let addressNetwork = NetworkValidationHelper.convertNetworkType(address.network) + // Test networks share legacy address encodings; the endpoint identifies the intended network. + guard addressNetwork == network || (addressNetwork == .testnet && (network == .signet || network == .regtest)) else { continue } + onchain[payload.value, default: []].insert(counterparty) + } else if method == .bitcoinLightningBolt11, + let invoice = try? Bolt11Invoice.fromStr(invoiceStr: payload.value), + invoice.currency() == Self.currency(for: network) + { + paymentHashes[invoice.paymentHash().lowercased(), default: []].insert(counterparty) + } + } + } + } + + var isEmpty: Bool { + onchain.isEmpty && paymentHashes.isEmpty + } + + func contact(onchainAddresses: [String]) -> String? { + let contacts = Set(onchainAddresses.flatMap { onchain[$0] ?? [] }) + return contacts.count == 1 ? contacts.first : nil + } + + func contact(paymentHash: String) -> String? { + let contacts = paymentHashes[paymentHash.lowercased()] ?? [] + return contacts.count == 1 ? contacts.first : nil + } + + func attributing(_ activity: Activity, outputAddresses: [String] = []) -> Activity? { + switch activity { + case var .onchain(payment): + guard payment.txType == .received, payment.contact == nil, + let contact = contact(onchainAddresses: outputAddresses) + else { return nil } + payment.contact = contact + payment.updatedAt = UInt64(Date().timeIntervalSince1970) + return .onchain(payment) + case var .lightning(payment): + guard payment.txType == .received, payment.contact == nil, payment.status == .succeeded, + let contact = contact(paymentHash: payment.id) + else { return nil } + payment.contact = contact + payment.updatedAt = UInt64(Date().timeIntervalSince1970) + return .lightning(payment) + } + } + + private static func currency(for network: LDKNode.Network) -> LDKNode.Currency { + switch network { + case .bitcoin: .bitcoin + case .testnet: .bitcoinTestnet + case .signet: .signet + case .regtest: .regtest + } + } +} + +extension ActivityService { + func backfillReceivedPaykitContacts(_ contacts: PaykitReceivedPaymentContacts) async throws { + guard !contacts.isEmpty else { return } + try await ServiceQueue.background(.core) { + let activities = try getActivities( + walletId: WalletScope.default, filter: .all, txType: .received, + tags: nil, search: nil, minDate: nil, maxDate: nil, limit: nil, sortDirection: nil + ) + var changed = false + defer { if changed { self.notifyActivitiesChanged() } } + for activity in activities { + var outputAddresses: [String] = [] + if case let .onchain(payment) = activity { + guard payment.contact == nil else { continue } + guard let details = try BitkitCore.getTransactionDetails(walletId: payment.walletId, txId: payment.txId) else { continue } + outputAddresses = details.outputs.compactMap(\.scriptpubkeyAddress) + } + guard let updated = contacts.attributing(activity, outputAddresses: outputAddresses) else { continue } + try updateActivity(activityId: activity.activityId, activity: updated) + changed = true + } + } + } +} diff --git a/Bitkit/Services/PaykitSubscription.swift b/Bitkit/Services/PaykitSubscription.swift index 82fad3803..bf0ed4af4 100644 --- a/Bitkit/Services/PaykitSubscription.swift +++ b/Bitkit/Services/PaykitSubscription.swift @@ -3,7 +3,7 @@ import Foundation import Paykit import UserNotifications -struct PaykitPreciseInstant: Codable, Comparable, Hashable, Sendable { +struct PaykitPreciseInstant: Codable, Comparable, Hashable { let seconds: Int64 let nanoseconds: Int let timestamp: String @@ -424,7 +424,6 @@ struct PaykitSubscription: Identifiable, Hashable { struct ID: Codable, Hashable { let paymentRequestId: String let counterparty: String - let counterpartyReceiverPath: String } struct Payment: Hashable { @@ -434,7 +433,6 @@ struct PaykitSubscription: Identifiable, Hashable { let paymentRequestId: String let counterparty: String - let counterpartyReceiverPath: String let amountValue: String let amountSats: UInt64 let note: String? @@ -457,8 +455,7 @@ struct PaykitSubscription: Identifiable, Hashable { var id: ID { ID( paymentRequestId: paymentRequestId, - counterparty: counterparty, - counterpartyReceiverPath: counterpartyReceiverPath + counterparty: counterparty ) } @@ -551,7 +548,6 @@ struct PaykitSubscription: Identifiable, Hashable { paymentRequestId = record.paymentRequestId counterparty = record.counterparty - counterpartyReceiverPath = record.counterpartyReceiverPath amountValue = terms.amount.value self.amountSats = amountSats note = PaykitPaymentRequest.note(from: terms.metadata).map { String($0.prefix(256)) } @@ -772,7 +768,6 @@ actor PaykitSubscriptionNotificationScheduler { "payer_identity": payerIdentity, "payment_request_id": subscription.paymentRequestId, "counterparty": subscription.counterparty, - "counterparty_receiver_path": subscription.counterpartyReceiverPath, "billing_period_starts_at": PaykitSubscriptionTimestamp.string(from: period.startsAt), ] var calendar = Calendar(identifier: .gregorian) @@ -812,14 +807,14 @@ enum PaykitSubscriptionNotificationIdentifier { static func identifier(identity: String, subscription: PaykitSubscription, period: PaykitBillingPeriod) -> String { "\(prefix)\(identity)|\(subscription.counterparty)|" + - "\(subscription.counterpartyReceiverPath)|\(subscription.paymentRequestId)|" + + "\(subscription.paymentRequestId)|" + PaykitSubscriptionTimestamp.string(from: period.startsAt) } static func identifier(identity: String, requestId: PaykitPaymentRequest.ID) -> String? { guard let startsAt = requestId.billingPeriodStartsAt else { return nil } return "\(prefix)\(identity)|\(requestId.counterparty)|" + - "\(requestId.counterpartyReceiverPath)|\(requestId.paymentRequestId)|" + + "\(requestId.paymentRequestId)|" + PaykitSubscriptionTimestamp.string(from: startsAt) } } diff --git a/Bitkit/Services/PrivatePaykitAddressReservationStore.swift b/Bitkit/Services/PrivatePaykitAddressReservationStore.swift index 6a60c9732..59277318f 100644 --- a/Bitkit/Services/PrivatePaykitAddressReservationStore.swift +++ b/Bitkit/Services/PrivatePaykitAddressReservationStore.swift @@ -84,7 +84,7 @@ actor PrivatePaykitAddressReservationStore { reservedAddress == address else { continue } - return Self.publicKey(fromAssignmentKey: assignmentKey) + return assignmentKey } } @@ -101,14 +101,14 @@ actor PrivatePaykitAddressReservationStore { reservedAddress == address else { continue } - return Self.publicKey(fromAssignmentKey: assignmentKey) + return assignmentKey } return nil } - func currentOrRotatedAddress(for publicKey: String, receiverPath: String) async throws -> String { - let assignmentKey = try Self.contactAssignmentKey(publicKey: publicKey, receiverPath: receiverPath) + func currentOrRotatedAddress(for publicKey: String) async throws -> String { + let assignmentKey = try Self.contactAssignmentKey(publicKey: publicKey) if let existing = try await reservedAddressDetails(forAssignmentKey: assignmentKey) { guard isAddressTypeMonitored(existing.addressType) else { clearCurrentContactAssignment(assignmentKey: assignmentKey) @@ -156,14 +156,14 @@ actor PrivatePaykitAddressReservationStore { for (assignmentKey, assignment) in ledger.contactAssignments { let key = Self.assignmentKey(assignment) guard seenAssignmentKeys.insert(key).inserted else { continue } - assignments.append((publicKey: Self.publicKey(fromAssignmentKey: assignmentKey), assignment: assignment)) + assignments.append((publicKey: assignmentKey, assignment: assignment)) } 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: Self.publicKey(fromAssignmentKey: assignmentKey), assignment: assignment)) + assignments.append((publicKey: assignmentKey, assignment: assignment)) } } @@ -182,11 +182,11 @@ actor PrivatePaykitAddressReservationStore { do { if try await CoreService.shared.utility.isAddressUsed(address: address) { - publicKeys.append(Self.publicKey(fromAssignmentKey: assignmentKey)) + publicKeys.append(assignmentKey) } } catch { Logger.warn( - "Unable to check private Paykit reserved address usage for \(PubkyPublicKeyFormat.redacted(Self.publicKey(fromAssignmentKey: assignmentKey))): \(error)", + "Unable to check private Paykit reserved address usage for \(PubkyPublicKeyFormat.redacted(assignmentKey)): \(error)", context: "PrivatePaykit" ) } @@ -301,8 +301,8 @@ actor PrivatePaykitAddressReservationStore { 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 } + ledger.contactAssignments.removeValue(forKey: normalizedKey) + ledger.contactAssignmentHistory.removeValue(forKey: normalizedKey) let removedCurrent = ledger.contactAssignments.count != previousCount let removedHistory = ledger.contactAssignmentHistory.count != previousHistoryCount guard removedCurrent || removedHistory else { return } @@ -314,9 +314,9 @@ 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(Self.publicKey(fromAssignmentKey: $0.key)) } + ledger.contactAssignments = ledger.contactAssignments.filter { normalizedKeys.contains($0.key) } ledger.contactAssignmentHistory = ledger.contactAssignmentHistory - .filter { normalizedKeys.contains(Self.publicKey(fromAssignmentKey: $0.key)) } + .filter { normalizedKeys.contains($0.key) } guard ledger.contactAssignments.count != previousCount || ledger.contactAssignmentHistory.count != previousHistoryCount else { return } persist() @@ -384,18 +384,11 @@ actor PrivatePaykitAddressReservationStore { "\(assignment.addressType):\(assignment.receiveIndex)" } - private static func contactAssignmentKey(publicKey: String, receiverPath: String) throws -> String { + private static func contactAssignmentKey(publicKey: 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 + return normalizedKey } // MARK: - Cached Receive Address diff --git a/Bitkit/Services/PrivatePaykitService+Backup.swift b/Bitkit/Services/PrivatePaykitService+Backup.swift index 913f9df1c..08fe0efcc 100644 --- a/Bitkit/Services/PrivatePaykitService+Backup.swift +++ b/Bitkit/Services/PrivatePaykitService+Backup.swift @@ -10,9 +10,7 @@ extension PrivatePaykitService { let backup = try await Backup( sdkState: PaykitSdkService.shared.exportBackupState(), consumedPrivatePaymentListVersions: state.contacts.compactMapValues { contactState in - contactState.consumedPrivatePaymentListVersionsByReceiverPath.isEmpty - ? nil - : contactState.consumedPrivatePaymentListVersionsByReceiverPath + contactState.consumedPrivatePaymentListVersion } ) let data = try JSONEncoder().encode(backup) @@ -23,6 +21,11 @@ extension PrivatePaykitService { } func restoreBackup(_ backup: String?) async throws { + let decoded = try backup.map { try JSONDecoder().decode(Backup.self, from: Data($0.utf8)) } + if let decoded { + // Wallet restore must not rewind the identity's live state or Noise counters. + try Keychain.upsert(key: .paykitRecoveryBackup, data: Data(decoded.sdkState.utf8)) + } initialLinkBurstTask?.cancel() initialLinkBurstTask = nil initialLinkBurstPublicKeys.removeAll() @@ -34,11 +37,9 @@ extension PrivatePaykitService { privatePaymentListConsumptions.removeAll() state = PrivatePaykitState(contacts: [:]) knownSavedContactKeys.removeAll() - if let backup { - let decoded = try JSONDecoder().decode(Backup.self, from: Data(backup.utf8)) - try await PaykitSdkService.shared.restoreBackupState(decoded.sdkState) + if let decoded { for (publicKey, versions) in decoded.consumedPrivatePaymentListVersions { - state.contacts[publicKey, default: ContactState()].consumedPrivatePaymentListVersionsByReceiverPath = versions + state.contacts[publicKey, default: ContactState()].consumedPrivatePaymentListVersion = versions } } else { await PaykitSdkService.shared.clearState() diff --git a/Bitkit/Services/PrivatePaykitService+Contacts.swift b/Bitkit/Services/PrivatePaykitService+Contacts.swift index 0f459218e..d64757b05 100644 --- a/Bitkit/Services/PrivatePaykitService+Contacts.swift +++ b/Bitkit/Services/PrivatePaykitService+Contacts.swift @@ -15,11 +15,8 @@ extension PrivatePaykitService { struct EndpointPublicationOperations { let currentPublicKey: () async -> String? - let linkedReceiverPaths: (_ reason: String) async -> (paths: [String: Set], error: Error?) - let receiverPaths: (_ publicKey: String) async throws -> [String] - let receiverPathSelection: (_ publicKey: String, _ receiverPaths: [String]) async throws -> PrivateReceiverPathSelection - let ensureLink: (_ publicKey: String, _ receiverPath: String) async throws -> Void - let buildEndpoints: (_ publicKey: String, _ receiverPath: String) async throws -> [PublicPaykitService.Endpoint] + let ensureLink: (_ publicKey: String) async throws -> Void + let buildEndpoints: (_ publicKey: String) async throws -> [PublicPaykitService.Endpoint] let syncPaymentLists: (_ updates: [PrivatePaymentListReservationUpdateInput]) async throws -> PrivatePaymentListDeliveryReport } @@ -174,33 +171,28 @@ extension PrivatePaykitService { } private func removePublishedEndpointsLocked(for publicKeys: [String]) async throws { + let linkedPublicKeys = try await Set(PaykitSdkService.shared.linkedPeers() + .filter { $0.state != .notLinked } + .compactMap { PubkyPublicKeyFormat.normalized($0.counterparty) }) + let cleanupKeys = privatePaymentListCleanupKeys(publicKeys, linkedPublicKeys: linkedPublicKeys) let publicKeySet = Set(publicKeys) let cleanupStateSnapshots = Dictionary(uniqueKeysWithValues: publicKeys.map { publicKey in (publicKey, publishedEndpointCleanupState(publicKey: publicKey)) }) - let linkedReceiverPathsSnapshot = await linkedReceiverPathsSnapshot(reason: "cleanup") - - var firstError = linkedReceiverPathsSnapshot.error - var failedPublicKeys = linkedReceiverPathsSnapshot.error == nil ? Set() : publicKeySet - var clearedRetryKeys = [PrivateMessageDrainRetryKey]() - for publicKey in publicKeys { - let cleanupReceiverPaths = receiverPathsForCleanup( - publicKey: publicKey, - linkedReceiverPaths: linkedReceiverPathsSnapshot.paths[publicKey, default: []] - ) - for receiverPath in cleanupReceiverPaths { - do { - guard let report = try await PaykitSdkService.shared.clearPrivatePaymentList(to: publicKey, receiverPath: receiverPath) - else { continue } - if !report.failedToQueue.isEmpty || !report.failedToDeliver.isEmpty { - throw PrivatePaykitError.privateUnavailable - } - clearedRetryKeys.append(PrivateMessageDrainRetryKey(publicKey: publicKey, receiverPath: receiverPath)) - } catch { - failedPublicKeys.insert(publicKey) - firstError = firstError ?? error + var firstError: Error? + var failedPublicKeys = Set() + var clearedRetryKeys = [String]() + for publicKey in cleanupKeys { + do { + guard let report = try await PaykitSdkService.shared.clearPrivatePaymentList(to: publicKey) else { continue } + if !report.failedToQueue.isEmpty || !report.failedToDeliver.isEmpty { + throw PrivatePaykitError.privateUnavailable } + clearedRetryKeys.append(publicKey) + } catch { + failedPublicKeys.insert(publicKey) + firstError = firstError ?? error } } @@ -208,7 +200,7 @@ extension PrivatePaykitService { await drainPendingPrivateMessages(reason: "cleanup", advancing: clearedRetryKeys) let pendingRetryKeys = await pendingPrivateMessageDrainKeys(clearedRetryKeys) if !pendingRetryKeys.isEmpty { - failedPublicKeys.formUnion(pendingRetryKeys.map(\.publicKey)) + failedPublicKeys.formUnion(pendingRetryKeys) firstError = firstError ?? PrivatePaykitError.privateUnavailable } } @@ -238,6 +230,12 @@ extension PrivatePaykitService { } } + func privatePaymentListCleanupKeys(_ publicKeys: [String], linkedPublicKeys: Set) -> [String] { + publicKeys.filter { + linkedPublicKeys.contains($0) || state.contacts[$0]?.hasPublishedPrivatePaymentList == true + } + } + func removeSavedContact(publicKey: String) async { guard let normalizedKey = PubkyPublicKeyFormat.normalized(publicKey) else { return } knownSavedContactKeys.remove(normalizedKey) @@ -393,25 +391,12 @@ extension PrivatePaykitService { currentPublicKey: { await PubkyService.currentPublicKey() }, - linkedReceiverPaths: { reason in - await self.linkedReceiverPathsSnapshot(reason: reason) - }, - receiverPaths: { publicKey in - try await self.receiverPathsForSavedContact(publicKey: publicKey) - }, - receiverPathSelection: { publicKey, receiverPaths in - try await PaykitSdkService.shared.privateReceiverPathSelection( - publicKey: publicKey, - savedReceiverPaths: receiverPaths - ) - }, - ensureLink: { publicKey, receiverPath in - _ = try await PaykitSdkService.shared.ensureLinkWithPeer(publicKey, receiverPath: receiverPath) + ensureLink: { publicKey in + _ = try await PaykitSdkService.shared.ensureLinkWithPeer(publicKey) }, - buildEndpoints: { publicKey, receiverPath in + buildEndpoints: { publicKey in try await self.buildLocalEndpoints( for: publicKey, - receiverPath: receiverPath, wallet: wallet, forceRefreshLightning: forceRefreshLightning ) @@ -438,85 +423,34 @@ extension PrivatePaykitService { return requireImmediatePublication ? PubkyServiceError.sessionNotActive : nil } - let linkedReceiverPathsSnapshot = await operations.linkedReceiverPaths(reason) - var firstError = linkedReceiverPathsSnapshot.error + var firstError: Error? var updates = [PrivatePaymentListReservationUpdateInput]() - var linkRetryKeys = [PrivateMessageDrainRetryKey]() + var linkRetryKeys = [String]() for publicKey in publicKeys { - let receiverPaths: [String] do { - receiverPaths = try await operations.receiverPaths(publicKey) + try await operations.ensureLink(publicKey) + } catch PaykitError.NotFound { + continue } catch { - firstError = firstError ?? error Logger.warn( - "Failed to read saved Paykit receivers for \(PubkyPublicKeyFormat.redacted(publicKey)) during \(reason): \(error)", + "Failed to prepare private Paykit link for \(PubkyPublicKeyFormat.redacted(publicKey)) during \(reason): \(error)", context: "PrivatePaykit" ) - continue } - let receiverPathSelection: PrivateReceiverPathSelection + linkRetryKeys.append(publicKey) do { - receiverPathSelection = try await operations.receiverPathSelection(publicKey, receiverPaths) + let endpoints = try await operations.buildEndpoints(publicKey) + updates.append(PrivatePaymentListReservationUpdateInput( + counterparty: publicKey, + reservations: reservations(from: endpoints, publicKey: publicKey) + )) } catch { firstError = firstError ?? error Logger.warn( - "Failed to select private Paykit receiver paths for \(PubkyPublicKeyFormat.redacted(publicKey)) during \(reason): \(error)", + "Failed to prepare private Paykit endpoints for \(PubkyPublicKeyFormat.redacted(publicKey)) during \(reason): \(error)", context: "PrivatePaykit" ) - continue - } - let linkableReceiverPaths = receiverPathSelection.linkableReceiverPaths - let publicationReceiverPaths = receiverPathSelection.publishableReceiverPaths - if let error = receiverPathSelection.error { - Logger.warn( - "Failed to inspect private Paykit receiver markers for \(PubkyPublicKeyFormat.redacted(publicKey)) during \(reason): \(error)", - context: "PrivatePaykit" - ) - } - let cleanupReceiverPaths = receiverPathsForPrivateEndpointCleanup( - publicKey: publicKey, - excluding: publicationReceiverPaths + receiverPathSelection.cleanupProtectedReceiverPaths, - linkedReceiverPaths: linkedReceiverPathsSnapshot.paths[publicKey, default: []] - ) - - for receiverPath in Set(linkableReceiverPaths).union(cleanupReceiverPaths) { - linkRetryKeys.append(PrivateMessageDrainRetryKey(publicKey: publicKey, receiverPath: receiverPath)) - do { - try await operations.ensureLink(publicKey, 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 operations.buildEndpoints(publicKey, receiverPath) - 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 - } } } @@ -542,34 +476,7 @@ extension PrivatePaykitService { private func prepareRelevantPrivateLinksIfAvailable(_ publicKeys: [String], reason: String) async { guard await canUsePrivateLinks() else { return } - var retryKeys = [PrivateMessageDrainRetryKey]() - for publicKey in normalizedSavedContactKeys(publicKeys) { - do { - let receiverPaths = try await receiverPathsForSavedContact(publicKey: publicKey) - 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) + await drainAndSchedulePrivateLinkRetries(reason: reason, retryKeys: normalizedSavedContactKeys(publicKeys)) } private func canUsePrivateLinks() async -> Bool { @@ -580,7 +487,7 @@ extension PrivatePaykitService { return PubkyProfileManager.hasLocalSecretKey(for: ownPublicKey) } - private func drainAndSchedulePrivateLinkRetries(reason: String, retryKeys: [PrivateMessageDrainRetryKey]) async { + private func drainAndSchedulePrivateLinkRetries(reason: String, retryKeys: [String]) async { let retryKeys = Array(Set(retryKeys)) guard !retryKeys.isEmpty else { return } @@ -591,26 +498,18 @@ extension PrivatePaykitService { } } - 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 privatePaymentListDeliveryRetryKeys(from report: PrivatePaymentListDeliveryReport) -> [String] { + normalizedSavedContactKeys((report.queued + report.cleared).map(\.counterparty) + report.failedToDeliver.map(\.counterparty)) } - private func drainPendingPrivateMessages(reason: String, advancing retryKeys: [PrivateMessageDrainRetryKey]) async { + private func drainPendingPrivateMessages(reason: String, advancing retryKeys: [String]) async { do { for retryKey in Set(retryKeys) { do { - _ = try await PaykitSdkService.shared.ensureLinkWithPeer(retryKey.publicKey, receiverPath: retryKey.receiverPath) + _ = try await PaykitSdkService.shared.ensureLinkWithPeer(retryKey) } catch { Logger.warn( - "Failed to advance private Paykit link for \(PubkyPublicKeyFormat.redacted(retryKey.publicKey)) during \(reason): \(error)", + "Failed to advance private Paykit link for \(PubkyPublicKeyFormat.redacted(retryKey)) during \(reason): \(error)", context: "PrivatePaykit" ) } @@ -624,7 +523,7 @@ extension PrivatePaykitService { } } - private func schedulePendingPrivateMessageDrainRetries(reason: String, retryKeys: [PrivateMessageDrainRetryKey]) { + private func schedulePendingPrivateMessageDrainRetries(reason: String, retryKeys: [String]) { let retryKeys = Set(retryKeys) guard !retryKeys.isEmpty else { return } @@ -650,11 +549,11 @@ extension PrivatePaykitService { } } - func schedulePrivatePaymentRecovery(for publicKey: String, receiverPath: String) { + func schedulePrivatePaymentRecovery(for publicKey: String) { guard let publicKey = PubkyPublicKeyFormat.normalized(publicKey) else { return } schedulePendingPrivateMessageDrainRetries( reason: "payment recovery", - retryKeys: [PrivateMessageDrainRetryKey(publicKey: publicKey, receiverPath: receiverPath)] + retryKeys: [publicKey] ) } @@ -675,22 +574,22 @@ extension PrivatePaykitService { pendingMessageDrainRetryKeys.removeAll() } - private func updatePendingMessageDrainRetryKeys(_ retryKeys: [PrivateMessageDrainRetryKey]) async { + private func updatePendingMessageDrainRetryKeys(_ retryKeys: [String]) async { let remainingKeys = await pendingPrivateMessageDrainKeys(retryKeys) pendingMessageDrainRetryKeys.subtract(retryKeys) pendingMessageDrainRetryKeys.formUnion(remainingKeys) } - private func pendingPrivateMessageDrainKeys(_ retryKeys: [PrivateMessageDrainRetryKey]) async -> Set { + private func pendingPrivateMessageDrainKeys(_ retryKeys: [String]) async -> Set { let retryKeys = Set(retryKeys) guard !retryKeys.isEmpty else { return [] } - let linkedPeers: [PrivateMessageDrainRetryKey: LinkedPeerState] + let linkedPeers: [String: LinkedPeerState] do { - var peersByKey: [PrivateMessageDrainRetryKey: LinkedPeerState] = [:] + var peersByKey: [String: 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 + peersByKey[publicKey] = peer.state } linkedPeers = peersByKey } catch { @@ -698,13 +597,10 @@ extension PrivatePaykitService { return retryKeys } - let pendingOutbound: Set + let pendingOutbound: Set do { - 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) - }) + let pending = try await PaykitSdkService.shared.pendingOutboundPrivateCounterparties() + pendingOutbound = Set(pending.compactMap(PubkyPublicKeyFormat.normalized)) } catch { Logger.warn("Failed to inspect pending private Paykit messages: \(error)", context: "PrivatePaykit") return retryKeys @@ -730,24 +626,18 @@ extension PrivatePaykitService { for change in report.queued { guard let publicKey = PubkyPublicKeyFormat.normalized(change.counterparty) else { continue } - didChangeState = recordPublishedPrivatePaymentList( - publicKey: publicKey, - receiverPath: change.counterpartyReceiverPath - ) || didChangeState + didChangeState = recordPublishedPrivatePaymentList(publicKey: publicKey) || didChangeState } for change in report.cleared { guard let publicKey = PubkyPublicKeyFormat.normalized(change.counterparty) else { continue } - didChangeState = clearPublishedPrivatePaymentList( - publicKey: publicKey, - receiverPath: change.counterpartyReceiverPath - ) || didChangeState + didChangeState = clearPublishedPrivatePaymentList(publicKey: publicKey) || didChangeState } for change in report.failedToQueue { let publicKey = PubkyPublicKeyFormat.normalized(change.counterparty) ?? change.counterparty Logger.warn( - "Failed to queue private Paykit endpoints for \(PubkyPublicKeyFormat.redacted(publicKey)) during \(reason): \(change.error ?? "unknown error")", + "Failed to queue private Paykit endpoints for \(PubkyPublicKeyFormat.redacted(publicKey)) during \(reason): \(change.error?.redactedContext() ?? "unknown error")", context: "PrivatePaykit" ) firstError = firstError ?? PrivatePaykitError.privateUnavailable @@ -803,95 +693,12 @@ extension PrivatePaykitService { return knownSavedContactKeys.contains(normalizedKey) } - private func receiverPathsForSavedContact(publicKey: String) async throws -> [String] { - let record = try await PaykitSdkService.shared.contactRecord(publicKey: publicKey) - let savedPaths = supportedReceiverPaths(record?.receiverPaths ?? []) - - do { - let discoveredPaths = try await PubkyService.discoverRelevantReceiverPaths(publicKey: publicKey) - let mergedPaths = supportedReceiverPaths(savedPaths + discoveredPaths) - guard mergedPaths != savedPaths else { return savedPaths } - - let updatedRecord = try await PubkyService.saveContact( - publicKey: publicKey, - label: record?.label, - receiverPaths: mergedPaths - ) - Self.initialLinkBurstStartedSubject.send() - Logger.info( - "Discovered new Paykit receiver paths for \(PubkyPublicKeyFormat.redacted(publicKey))", - context: "PrivatePaykit" - ) - return supportedReceiverPaths(updatedRecord.receiverPaths) - } catch is CancellationError { - throw CancellationError() - } catch { - Logger.warn( - "Failed to refresh Paykit receiver paths for \(PubkyPublicKeyFormat.redacted(publicKey)); using saved paths: \(error)", - context: "PrivatePaykit" - ) - return savedPaths - } - } - - func supportedReceiverPaths(_ receiverPaths: [String]) -> [String] { - let savedPaths = Set(receiverPaths) - let paths = PaykitReceiverPath.supported.filter { savedPaths.contains($0) } - return paths.isEmpty ? [PaykitReceiverPath.wallet] : paths - } - - private func receiverPathsForPrivateEndpointCleanup( - publicKey: String, - excluding publicationReceiverPaths: [String], - linkedReceiverPaths: Set - ) -> [String] { - let publishedPaths = publishedPrivatePaymentReceiverPaths(publicKey: publicKey) - let excluded = Set(publicationReceiverPaths) - return Array(Set(publishedPaths).union(linkedReceiverPaths).subtracting(excluded)) - .filter { PaykitReceiverPath.supported.contains($0) } - .sorted() - } - - private func receiverPathsForCleanup(publicKey: String, linkedReceiverPaths: Set) -> [String] { - let publishedPaths = publishedPrivatePaymentReceiverPaths(publicKey: publicKey) - return Array(linkedReceiverPaths.union(publishedPaths)) - .filter { PaykitReceiverPath.supported.contains($0) } - .sorted() - } - - private func linkedReceiverPathsByPublicKey() async throws -> [String: Set] { - let peers = try await PaykitSdkService.shared.linkedPeers() - var linkedPaths = [String: Set]() - for peer in peers { - guard let publicKey = PubkyPublicKeyFormat.normalized(peer.counterparty), - PaykitReceiverPath.supported.contains(peer.counterpartyReceiverPath) - else { continue } - linkedPaths[publicKey, default: []].insert(peer.counterpartyReceiverPath) - } - return linkedPaths - } - - private func linkedReceiverPathsSnapshot(reason: String) async -> (paths: [String: Set], error: Error?) { - do { - return try await (linkedReceiverPathsByPublicKey(), nil) - } catch { - Logger.warn("Failed to inspect private Paykit links during \(reason); retrying once: \(error)", context: "PrivatePaykit") - } - - do { - return try await (linkedReceiverPathsByPublicKey(), nil) - } catch { - Logger.warn("Failed to inspect private Paykit links during \(reason) after retry: \(error)", context: "PrivatePaykit") - return ([:], error) - } - } - private func publishedEndpointCleanupState(publicKey: String) -> PublishedEndpointCleanupState { let contactState = state.contacts[publicKey] return PublishedEndpointCleanupState( cachedResolvedEndpoints: contactState?.cachedResolvedEndpoints ?? [], - localInvoicesByReceiverPath: contactState?.localInvoicesByReceiverPath ?? [:], - publishedPrivatePaymentReceiverPaths: contactState?.publishedPrivatePaymentReceiverPaths ?? [] + localInvoice: contactState?.localInvoice, + hasPublishedPrivatePaymentList: contactState?.hasPublishedPrivatePaymentList ?? false ) } @@ -904,11 +711,11 @@ extension PrivatePaykitService { for publicKey in successfulPublicKeys { if var contactState = state.contacts[publicKey] { let hadStateToClear = !contactState.cachedResolvedEndpoints.isEmpty || - !contactState.localInvoicesByReceiverPath.isEmpty || - !contactState.publishedPrivatePaymentReceiverPaths.isEmpty + contactState.localInvoice != nil || + contactState.hasPublishedPrivatePaymentList contactState.cachedResolvedEndpoints = [] - contactState.localInvoicesByReceiverPath = [:] - contactState.publishedPrivatePaymentReceiverPaths = [] + contactState.localInvoice = nil + contactState.hasPublishedPrivatePaymentList = false let shouldRemoveContact = !contactState.hasCacheState state.contacts[publicKey] = shouldRemoveContact ? nil : contactState didChangeState = didChangeState || hadStateToClear || shouldRemoveContact @@ -920,33 +727,26 @@ extension PrivatePaykitService { return didChangeState } - private func publishedPrivatePaymentReceiverPaths(publicKey: String) -> [String] { - state.contacts[publicKey]?.publishedPrivatePaymentReceiverPaths ?? [] - } - - private func recordPublishedPrivatePaymentList(publicKey: String, receiverPath: String) -> Bool { + private func recordPublishedPrivatePaymentList(publicKey: String) -> Bool { var contactState = state.contacts[publicKey, default: ContactState()] - var paths = Set(contactState.publishedPrivatePaymentReceiverPaths) - guard paths.insert(receiverPath).inserted else { return false } - contactState.publishedPrivatePaymentReceiverPaths = Array(paths).sorted() + guard !contactState.hasPublishedPrivatePaymentList else { return false } + contactState.hasPublishedPrivatePaymentList = true state.contacts[publicKey] = contactState return true } - private func clearPublishedPrivatePaymentList(publicKey: String, receiverPath: String) -> Bool { + private func clearPublishedPrivatePaymentList(publicKey: 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) + guard contactState.hasPublishedPrivatePaymentList || contactState.localInvoice != nil else { return false } + contactState.hasPublishedPrivatePaymentList = false + contactState.localInvoice = nil state.contacts[publicKey] = contactState.hasCacheState ? contactState : nil return true } private struct PublishedEndpointCleanupState: Equatable { let cachedResolvedEndpoints: [StoredPaymentEntry] - let localInvoicesByReceiverPath: [String: StoredInvoice] - let publishedPrivatePaymentReceiverPaths: [String] + let localInvoice: StoredInvoice? + let hasPublishedPrivatePaymentList: Bool } } diff --git a/Bitkit/Services/PrivatePaykitService+Endpoints.swift b/Bitkit/Services/PrivatePaykitService+Endpoints.swift index 0b618442d..990a7b646 100644 --- a/Bitkit/Services/PrivatePaykitService+Endpoints.swift +++ b/Bitkit/Services/PrivatePaykitService+Endpoints.swift @@ -51,7 +51,6 @@ extension PrivatePaykitService { @MainActor func buildLocalEndpoints( for publicKey: String, - receiverPath: String, wallet: WalletViewModel, forceRefreshLightning: Bool = false ) async throws -> [PublicPaykitService.Endpoint] { @@ -59,8 +58,7 @@ extension PrivatePaykitService { if PublicPaykitService.isOnchainPaymentOptionEnabled() { let reservedAddress = try await PrivatePaykitAddressReservationStore.shared.currentOrRotatedAddress( - for: publicKey, - receiverPath: receiverPath + for: publicKey ) let onchainPayload = try PublicPaykitService.serializePayload(value: reservedAddress) endpoints.append( @@ -78,7 +76,6 @@ extension PrivatePaykitService { do { let invoice = try await currentOrRotatedInvoice( for: publicKey, - receiverPath: receiverPath, wallet: wallet, forceRefresh: forceRefreshLightning ) @@ -106,34 +103,32 @@ extension PrivatePaykitService { func reservations( from endpoints: [PublicPaykitService.Endpoint], - publicKey: String, - receiverPath: String + publicKey: String ) -> [PrivatePaymentEndpointReservationInput] { endpoints.map { endpoint in - let paymentHash = localInvoice(for: publicKey, receiverPath: receiverPath)?.takeIfBolt11(endpoint)?.paymentHash + let paymentHash = localInvoice(for: publicKey)?.takeIfBolt11(endpoint)?.paymentHash let attribution = [ "type": "private_paykit", "counterparty": publicKey, - "receiver_path": receiverPath, ].merging(paymentHash.map { ["payment_hash": $0] } ?? [:]) { current, _ in current } return PrivatePaymentEndpointReservationInput( - reservationId: reservationId(for: endpoint, publicKey: publicKey, receiverPath: receiverPath), + reservationId: reservationId(for: endpoint, publicKey: publicKey), identifier: endpoint.methodId.rawValue, payload: endpoint.rawPayload, - expiresAt: endpoint.methodId == .bitcoinLightningBolt11 ? localInvoice(for: publicKey, receiverPath: receiverPath)?.expiresAt + expiresAt: endpoint.methodId == .bitcoinLightningBolt11 ? localInvoice(for: publicKey)?.expiresAt .rfc3339Text : nil, attribution: attribution ) } } - private func reservationId(for endpoint: PublicPaykitService.Endpoint, publicKey: String, receiverPath: String) -> String { + private func reservationId(for endpoint: PublicPaykitService.Endpoint, publicKey: String) -> String { let payloadHashPrefix = SHA256.hash(data: Data(endpoint.rawPayload.utf8)) .prefix(8) .map { String(format: "%02x", $0) } .joined() - return "\(publicKey):\(receiverPath):\(endpoint.methodId.rawValue):\(payloadHashPrefix)" + return "\(publicKey):\(endpoint.methodId.rawValue):\(payloadHashPrefix)" } func cacheResolvedEndpoints(_ endpoints: [PublicPaykitService.Endpoint], publicKey: String) { diff --git a/Bitkit/Services/PrivatePaykitService+Errors.swift b/Bitkit/Services/PrivatePaykitService+Errors.swift index 2081a0226..63ff2e36a 100644 --- a/Bitkit/Services/PrivatePaykitService+Errors.swift +++ b/Bitkit/Services/PrivatePaykitService+Errors.swift @@ -47,6 +47,10 @@ enum PaykitResolutionFailureDiagnostics { private static func paykitReason(_ error: PaykitError) -> String { switch error { + case let .ConcurrentUpdate(code, _): + "concurrent_update/\(safeCode(code))" + case let .SharedStateBusy(code, _): + "shared_state_busy/\(safeCode(code))" case let .Storage(code, _): "storage/\(safeCode(code))" case let .Identity(code, _): diff --git a/Bitkit/Services/PrivatePaykitService+Invoices.swift b/Bitkit/Services/PrivatePaykitService+Invoices.swift index b917be173..733d1d671 100644 --- a/Bitkit/Services/PrivatePaykitService+Invoices.swift +++ b/Bitkit/Services/PrivatePaykitService+Invoices.swift @@ -8,11 +8,10 @@ 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, receiverPath: receiverPath) { + if !forceRefresh, let invoice = await reusablePrivateInvoice(for: publicKey) { return invoice } @@ -29,7 +28,7 @@ extension PrivatePaykitService { paymentHash: decodedInvoice.paymentHash.hex, expiresAt: Double(decodedInvoice.timestampSeconds + decodedInvoice.expirySeconds) ) - setLocalInvoice(invoice, publicKey: publicKey, receiverPath: receiverPath) + setLocalInvoice(invoice, publicKey: publicKey) persistState() return invoice } @@ -50,8 +49,8 @@ extension PrivatePaykitService { persistState() } - func reusablePrivateInvoice(for publicKey: String, receiverPath: String) async -> StoredInvoice? { - guard let invoice = localInvoice(for: publicKey, receiverPath: receiverPath), + func reusablePrivateInvoice(for publicKey: String) async -> StoredInvoice? { + guard let invoice = localInvoice(for: publicKey), invoice.expiresAt > Date().timeIntervalSince1970 + Self.invoiceRefreshBufferSeconds, await !isReceivedInvoiceSettled(paymentHash: invoice.paymentHash), case let .lightning(decodedInvoice) = try? await decode(invoice: invoice.bolt11), @@ -107,7 +106,7 @@ extension PrivatePaykitService { func settledPrivateInvoicePaymentHashes() async -> [String] { let settledHashes = await receivedSettledPaymentHashes() - return state.contacts.values.flatMap { localInvoices($0) } + return state.contacts.values.compactMap(\.localInvoice) .map(\.paymentHash) .filter(settledHashes.contains) } @@ -146,17 +145,13 @@ extension PrivatePaykitService { ) } - func localInvoice(for publicKey: String, receiverPath: String) -> StoredInvoice? { - state.contacts[publicKey]?.localInvoicesByReceiverPath[receiverPath] + func localInvoice(for publicKey: String) -> StoredInvoice? { + state.contacts[publicKey]?.localInvoice } - func localInvoices(_ contactState: ContactState) -> [StoredInvoice] { - Array(contactState.localInvoicesByReceiverPath.values) - } - - private func setLocalInvoice(_ invoice: StoredInvoice, publicKey: String, receiverPath: String) { + private func setLocalInvoice(_ invoice: StoredInvoice, publicKey: String) { var contactState = state.contacts[publicKey, default: ContactState()] - contactState.localInvoicesByReceiverPath[receiverPath] = invoice + contactState.localInvoice = invoice state.contacts[publicKey] = contactState } } diff --git a/Bitkit/Services/PrivatePaykitService+Models.swift b/Bitkit/Services/PrivatePaykitService+Models.swift index 08ec8e0e4..590477986 100644 --- a/Bitkit/Services/PrivatePaykitService+Models.swift +++ b/Bitkit/Services/PrivatePaykitService+Models.swift @@ -9,23 +9,23 @@ extension PrivatePaykitService { struct ContactState: Codable { var cachedResolvedEndpoints: [StoredPaymentEntry] = [] - var consumedPrivatePaymentListVersionsByReceiverPath: [String: UInt64] = [:] - var localInvoicesByReceiverPath: [String: StoredInvoice] = [:] + var consumedPrivatePaymentListVersion: UInt64? + var localInvoice: StoredInvoice? var receivedInvoicePaymentHashes: [String] = [] - var publishedPrivatePaymentReceiverPaths: [String] = [] + var hasPublishedPrivatePaymentList = false var hasCacheState: Bool { - !publishedPrivatePaymentReceiverPaths.isEmpty || + hasPublishedPrivatePaymentList || !cachedResolvedEndpoints.isEmpty || - !consumedPrivatePaymentListVersionsByReceiverPath.isEmpty || - !localInvoicesByReceiverPath.isEmpty || + consumedPrivatePaymentListVersion != nil || + localInvoice != nil || !receivedInvoicePaymentHashes.isEmpty } var hasContactOwnedCacheState: Bool { - !publishedPrivatePaymentReceiverPaths.isEmpty || + hasPublishedPrivatePaymentList || !cachedResolvedEndpoints.isEmpty || - !localInvoicesByReceiverPath.isEmpty || + localInvoice != nil || !receivedInvoicePaymentHashes.isEmpty } } @@ -53,6 +53,6 @@ extension PrivatePaykitService { struct Backup: Codable { let sdkState: String - let consumedPrivatePaymentListVersions: [String: [String: UInt64]] + let consumedPrivatePaymentListVersions: [String: UInt64] } } diff --git a/Bitkit/Services/PrivatePaykitService+Payments.swift b/Bitkit/Services/PrivatePaykitService+Payments.swift index 672c090dd..270e9e381 100644 --- a/Bitkit/Services/PrivatePaykitService+Payments.swift +++ b/Bitkit/Services/PrivatePaykitService+Payments.swift @@ -34,7 +34,7 @@ extension PrivatePaykitService { guard !paymentHash.isEmpty else { return nil } return state.contacts.first { _, contactState in - localInvoices(contactState).contains { $0.paymentHash == paymentHash } || + contactState.localInvoice?.paymentHash == paymentHash || contactState.receivedInvoicePaymentHashes.contains(paymentHash) }?.key } @@ -57,11 +57,11 @@ extension PrivatePaykitService { } } - var result = try await beginContactPayment(to: normalizedKey, receiverPath: PaykitReceiverPath.wallet) + var result = try await beginContactPayment(to: normalizedKey) for delay in Self.privatePaymentResolutionRetryDelays { guard case .waitingForUpdatedPaymentList = result else { return result } try await Task.sleep(nanoseconds: delay) - result = try await beginContactPayment(to: normalizedKey, receiverPath: PaykitReceiverPath.wallet) + result = try await beginContactPayment(to: normalizedKey) } return result } @@ -76,7 +76,6 @@ extension PrivatePaykitService { return try await beginContactPayment( to: publicKey, - receiverPath: request.counterpartyReceiverPath, paymentRequest: request ) } @@ -93,26 +92,26 @@ extension PrivatePaykitService { private func beginContactPayment( to publicKey: String, - receiverPath: String, paymentRequest: PaykitPaymentRequest? = nil ) async throws -> PublicPaykitPaymentLaunchResult { - let consumedVersion = state.contacts[publicKey]?.consumedPrivatePaymentListVersionsByReceiverPath[receiverPath] + let consumedVersion = state.contacts[publicKey]?.consumedPrivatePaymentListVersion let previousPaymentListVersion = consumedVersion.map(String.init) ?? "none" - let amount = paymentRequest.map { - PaymentAmountContext(value: $0.amountValue, asset: PaykitIssuerInterop.bitcoinAsset) - } - do { - let prepared = try await PaykitSdkService.shared.prepareAndResolvePrivateContactPayment( - counterparty: publicKey, - receiverPath: receiverPath, - amount: amount, - afterPrivatePaymentListVersion: consumedVersion - ) + let prepared: PreparedPrivateContactPayment = if let paymentRequest { + try await PaykitSdkService.shared.prepareAndResolvePrivatePaymentRequest( + counterparty: publicKey, + paymentRequestId: paymentRequest.paymentRequestId, + afterPrivatePaymentListVersion: consumedVersion + ) + } else { + try await PaykitSdkService.shared.prepareAndResolvePrivateContactPayment( + counterparty: publicKey, + afterPrivatePaymentListVersion: consumedVersion + ) + } let resolution = prepared.resolution let linkState = try await currentLinkState( publicKey: publicKey, - receiverPath: receiverPath, preparedState: prepared.linkReport?.state ) @@ -123,35 +122,20 @@ extension PrivatePaykitService { if paymentRequest != nil, Self.paymentRequestNeedsPrivateLinkRecovery(resolutionState: resolution.state, linkState: linkState) { - schedulePrivatePaymentRecovery(for: publicKey, receiverPath: receiverPath) + schedulePrivatePaymentRecovery(for: publicKey) return .privateLinkPending } - let privateEndpoints = resolvedEndpoints(from: resolution) - cacheResolvedEndpoints(privateEndpoints, publicKey: publicKey) - let acceptedIdentifiers = paymentRequest.map { Set($0.acceptedPaymentEndpointIdentifiers) } - let acceptedEndpoints = privateEndpoints.filter { endpoint in - acceptedIdentifiers?.contains(endpoint.methodId.rawValue) ?? true - } - - let payableEndpoints = await privatePayableEndpoints(from: acceptedEndpoints, publicKey: publicKey) - - if !payableEndpoints.isEmpty, let paymentListVersion = resolution.privatePaymentListVersion { - Logger.info( - "Opened private Paykit payment for \(PubkyPublicKeyFormat.redacted(publicKey)) using payment list version \(paymentListVersion) after \(previousPaymentListVersion)", - context: "PrivatePaykit" - ) - return .opened( - paymentRequest: PublicPaykitService.paymentRequest(from: payableEndpoints), - privatePaymentContext: PrivatePaykitPaymentContext( - receiverPath: receiverPath, - paymentListVersion: paymentListVersion - ) - ) - } + let result = await privatePaymentResult( + publicKey: publicKey, + paymentRequest: paymentRequest, + resolution: resolution, + validateEndpoints: { await self.privatePayableEndpoints(from: $0, publicKey: publicKey) } + ) + if case .opened = result { return result } if resolution.state == .recoveryPending { - schedulePrivatePaymentRecovery(for: publicKey, receiverPath: receiverPath) + schedulePrivatePaymentRecovery(for: publicKey) } if resolution.status == .waitingForUpdatedPaymentList { @@ -159,11 +143,11 @@ extension PrivatePaykitService { "Waiting for a private Paykit payment list newer than \(previousPaymentListVersion) for \(PubkyPublicKeyFormat.redacted(publicKey)); public resolution is disabled for this request", context: "PrivatePaykit" ) - schedulePrivatePaymentRecovery(for: publicKey, receiverPath: receiverPath) + schedulePrivatePaymentRecovery(for: publicKey) return .waitingForUpdatedPaymentList } - return acceptedEndpoints.isEmpty ? .noEndpoint : .notOpened + return result } catch { if error is CancellationError || Task.isCancelled { throw error @@ -176,22 +160,22 @@ extension PrivatePaykitService { ) if paymentRequest != nil, PaykitResolutionFailureDiagnostics.isRecoveryRequired(error) { - schedulePrivatePaymentRecovery(for: publicKey, receiverPath: receiverPath) + schedulePrivatePaymentRecovery(for: publicKey) return .privateLinkPending } let linkState: LinkedPeerState? do { - linkState = try await currentLinkState(publicKey: publicKey, receiverPath: receiverPath) + linkState = try await currentLinkState(publicKey: publicKey) } catch { if paymentRequest != nil, PaykitResolutionFailureDiagnostics.isRecoveryRequired(error) { - schedulePrivatePaymentRecovery(for: publicKey, receiverPath: receiverPath) + schedulePrivatePaymentRecovery(for: publicKey) return .privateLinkPending } throw error } if paymentRequest != nil, Self.paymentRequestNeedsPrivateLinkRecovery(linkState: linkState) { - schedulePrivatePaymentRecovery(for: publicKey, receiverPath: receiverPath) + schedulePrivatePaymentRecovery(for: publicKey) return .privateLinkPending } guard paymentRequest == nil, canUsePublicPayment(linkState: linkState) else { @@ -201,6 +185,41 @@ extension PrivatePaykitService { } } + func privatePaymentResult( + publicKey: String, + paymentRequest: PaykitPaymentRequest?, + resolution: PrivateContactPaymentResolution, + validateEndpoints: ([PublicPaykitService.Endpoint]) async -> [PublicPaykitService.Endpoint] + ) async -> PublicPaykitPaymentLaunchResult { + let privateEndpoints = resolvedEndpoints(from: resolution) + let paymentListVersion = resolution.privatePaymentListVersion + if paymentListVersion != nil { + cacheResolvedEndpoints(privateEndpoints, publicKey: publicKey) + } + let acceptedIdentifiers = paymentRequest.map { Set($0.acceptedPaymentEndpointIdentifiers) } + let acceptedEndpoints = privateEndpoints.filter { endpoint in + acceptedIdentifiers?.contains(endpoint.methodId.rawValue) ?? true + } + let payableEndpoints = await validateEndpoints(acceptedEndpoints) + guard !payableEndpoints.isEmpty, paymentListVersion != nil || paymentRequest != nil else { + return acceptedEndpoints.isEmpty ? .noEndpoint : .notOpened + } + Logger.info( + "Opened private Paykit payment for \(PubkyPublicKeyFormat.redacted(publicKey)) using payment list version \(paymentListVersion.map(String.init) ?? "none")", + context: "PrivatePaykit" + ) + return .opened( + paymentRequest: PublicPaykitService.paymentRequest(from: payableEndpoints), + privatePaymentContext: PrivatePaykitPaymentContext( + paymentAppsByEndpoint: Dictionary( + payableEndpoints.compactMap { endpoint in endpoint.appId.map { (endpoint.methodId.rawValue, $0) } }, + uniquingKeysWith: { first, _ in first } + ), + paymentListVersion: paymentListVersion + ) + ) + } + func consumePrivatePaymentList( publicKey: String, context: PrivatePaykitPaymentContext, @@ -209,17 +228,18 @@ extension PrivatePaykitService { guard let publicKey = PubkyPublicKeyFormat.normalized(publicKey) else { throw PrivatePaykitError.invalidPublicKey } + guard let paymentListVersion = context.paymentListVersion else { return } var contactState = state.contacts[publicKey, default: ContactState()] - let consumedVersion = contactState.consumedPrivatePaymentListVersionsByReceiverPath[context.receiverPath] + let consumedVersion = contactState.consumedPrivatePaymentListVersion if let consumedVersion, - context.paymentListVersion <= consumedVersion + paymentListVersion <= consumedVersion { throw PrivatePaykitError.paymentListAlreadyConsumed } let previousContactState = state.contacts[publicKey] - contactState.consumedPrivatePaymentListVersionsByReceiverPath[context.receiverPath] = context.paymentListVersion + contactState.consumedPrivatePaymentListVersion = paymentListVersion contactState.cachedResolvedEndpoints.removeAll() state.contacts[publicKey] = contactState do { @@ -231,15 +251,14 @@ extension PrivatePaykitService { let consumptionKey = PrivatePaymentListConsumptionKey( attemptId: attemptId, - publicKey: publicKey, - receiverPath: context.receiverPath + publicKey: publicKey ) privatePaymentListConsumptions[consumptionKey] = PrivatePaymentListConsumption( - paymentListVersion: context.paymentListVersion, + paymentListVersion: paymentListVersion, previousPaymentListVersion: consumedVersion ) Logger.info( - "Consumed private Paykit payment list version \(context.paymentListVersion) for \(PubkyPublicKeyFormat.redacted(publicKey))", + "Consumed private Paykit payment list version \(paymentListVersion) for \(PubkyPublicKeyFormat.redacted(publicKey))", context: "PrivatePaykit" ) } @@ -253,14 +272,14 @@ extension PrivatePaykitService { guard let publicKey = PubkyPublicKeyFormat.normalized(publicKey) else { throw PrivatePaykitError.invalidPublicKey } + guard let paymentListVersion = context.paymentListVersion else { return } let consumptionKey = PrivatePaymentListConsumptionKey( attemptId: attemptId, - publicKey: publicKey, - receiverPath: context.receiverPath + publicKey: publicKey ) guard let consumption = privatePaymentListConsumptions[consumptionKey], - consumption.paymentListVersion == context.paymentListVersion + consumption.paymentListVersion == paymentListVersion else { return } guard outcome == .definitePreBroadcastFailure else { @@ -269,13 +288,13 @@ extension PrivatePaykitService { } var contactState = state.contacts[publicKey, default: ContactState()] - guard contactState.consumedPrivatePaymentListVersionsByReceiverPath[context.receiverPath] == context.paymentListVersion else { + guard contactState.consumedPrivatePaymentListVersion == paymentListVersion else { privatePaymentListConsumptions[consumptionKey] = nil return } let previousContactState = state.contacts[publicKey] - contactState.consumedPrivatePaymentListVersionsByReceiverPath[context.receiverPath] = consumption.previousPaymentListVersion + contactState.consumedPrivatePaymentListVersion = consumption.previousPaymentListVersion state.contacts[publicKey] = contactState do { try persistStateOrThrow(markWalletBackup: true) @@ -285,14 +304,13 @@ extension PrivatePaykitService { throw error } Logger.info( - "Released private Paykit payment list version \(context.paymentListVersion) for \(PubkyPublicKeyFormat.redacted(publicKey))", + "Released private Paykit payment list version \(paymentListVersion) for \(PubkyPublicKeyFormat.redacted(publicKey))", context: "PrivatePaykit" ) } private func currentLinkState( publicKey: String, - receiverPath: String, preparedState: LinkedPeerState? = nil ) async throws -> LinkedPeerState? { if let preparedState { @@ -300,7 +318,7 @@ extension PrivatePaykitService { } return try await PaykitSdkService.shared.linkedPeers().first { - PubkyPublicKeyFormat.matches($0.counterparty, publicKey) && $0.counterpartyReceiverPath == receiverPath + PubkyPublicKeyFormat.matches($0.counterparty, publicKey) }?.state } @@ -414,7 +432,9 @@ extension PrivatePaykitService { private func resolvedEndpoints(from resolution: PrivateContactPaymentResolution) -> [PublicPaykitService.Endpoint] { resolution.payableEndpoints.compactMap { - return PublicPaykitService.parseEndpoint(identifier: $0.identifier, payload: $0.target.payload) + var endpoint = PublicPaykitService.parseEndpoint(identifier: $0.identifier, payload: $0.target.payload) + endpoint?.appId = $0.appId + return endpoint } } } diff --git a/Bitkit/Services/PrivatePaykitService+State.swift b/Bitkit/Services/PrivatePaykitService+State.swift index 7a78eea84..49eb67090 100644 --- a/Bitkit/Services/PrivatePaykitService+State.swift +++ b/Bitkit/Services/PrivatePaykitService+State.swift @@ -24,12 +24,12 @@ extension PrivatePaykitService { func clearContactState(publicKey: String) async { guard let normalizedKey = PubkyPublicKeyFormat.normalized(publicKey) else { return } privatePaymentListConsumptions = privatePaymentListConsumptions.filter { $0.key.publicKey != normalizedKey } - let consumedVersions = state.contacts[normalizedKey]?.consumedPrivatePaymentListVersionsByReceiverPath ?? [:] - if consumedVersions.isEmpty { + let consumedVersion = state.contacts[normalizedKey]?.consumedPrivatePaymentListVersion + if consumedVersion == nil { state.contacts[normalizedKey] = nil } else { var contactState = ContactState() - contactState.consumedPrivatePaymentListVersionsByReceiverPath = consumedVersions + contactState.consumedPrivatePaymentListVersion = consumedVersion state.contacts[normalizedKey] = contactState } await PrivatePaykitAddressReservationStore.shared.clearContactAssignment(publicKey: normalizedKey) diff --git a/Bitkit/Services/PrivatePaykitService.swift b/Bitkit/Services/PrivatePaykitService.swift index fad1c0329..b07d1a7a3 100644 --- a/Bitkit/Services/PrivatePaykitService.swift +++ b/Bitkit/Services/PrivatePaykitService.swift @@ -32,15 +32,9 @@ private actor PrivatePaykitPublicationLock { } } -struct PrivateMessageDrainRetryKey: Hashable { - let publicKey: String - let receiverPath: String -} - struct PrivatePaymentListConsumptionKey: Hashable { let attemptId: UUID let publicKey: String - let receiverPath: String } struct PrivatePaymentListConsumption { @@ -84,7 +78,7 @@ actor PrivatePaykitService { var state: PrivatePaykitState var knownSavedContactKeys: Set = [] var pendingMessageDrainRetryTask: Task? - var pendingMessageDrainRetryKeys: Set = [] + var pendingMessageDrainRetryKeys: Set = [] var pendingMessageDrainRetryGeneration = 0 // A restart makes the send outcome uncertain, so only live attempts may release a consumed list. var privatePaymentListConsumptions: [PrivatePaymentListConsumptionKey: PrivatePaymentListConsumption] = [:] diff --git a/Bitkit/Services/PubkyService.swift b/Bitkit/Services/PubkyService.swift index 3d2e636bc..661a9098f 100644 --- a/Bitkit/Services/PubkyService.swift +++ b/Bitkit/Services/PubkyService.swift @@ -1,6 +1,5 @@ import BitkitCore import Combine -import CryptoKit import Foundation import Paykit @@ -27,20 +26,6 @@ enum PubkyServiceError: LocalizedError { } } -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? -} - struct PubkyRegisteredIdentity { let result: PubkySessionBootstrapResult let walletGeneration: Int @@ -108,21 +93,26 @@ enum PubkyService { static func approveAuthWithCompanionClaim( authUrl: String, approvedClientID: String, - unsignedPayload: Data, + claim: PubkyAuthClaim, + accountPayload: Data?, secretKeyHex: String, sdkService: PaykitSdkService = .shared ) async throws { try await sdkService.republishIdentityIfNeeded(publicKey: pubkyPublicKeyFromSecret(secretKeyHex: secretKeyHex)) try Task.checkCancellation() + let paykitKey = claim.includesPaykitAccess + ? try await sdkService.paykitKeyForAuthorization(secretKeyHex: secretKeyHex) + : nil + let payload = try claim.encode(accountPayload: accountPayload, paykitKey: paykitKey) try await sdkService.approveAuthWithCompanionClaim( authUrl: authUrl, - expectedCapabilities: PubkyAuthClaim.watchOnlyAccountCapabilities, + expectedCapabilities: PubkyAuthClaim.requiredCapabilities, approvedClientID: approvedClientID, secretKeyHex: secretKeyHex, claim: Paykit.PubkyAuthCompanionClaim( queryParameter: PubkyAuthClaim.queryParameter, - claimType: PubkyAuthClaim.watchOnlyAccountV1.rawValue, - unsignedPayload: unsignedPayload + claimType: claim.rawValue, + unsignedPayload: payload ) ) } @@ -137,7 +127,7 @@ enum PubkyService { } typealias OrdinaryAuthApproval = (String, String, String, String) async throws -> Void - typealias CompanionAuthApproval = (String, String, Data, String) async throws -> Void + typealias CompanionAuthApproval = (String, String, PubkyAuthClaim, Data?, String) async throws -> Void @MainActor static func approveAuthRequest( @@ -154,18 +144,22 @@ enum PubkyService { secretKeyHex: secretKeyHex ) }, - companionApproval: @escaping CompanionAuthApproval = { authUrl, clientID, unsignedPayload, secretKeyHex in + companionApproval: @escaping CompanionAuthApproval = { authUrl, clientID, claim, accountPayload, secretKeyHex in try await approveAuthWithCompanionClaim( authUrl: authUrl, approvedClientID: clientID, - unsignedPayload: unsignedPayload, + claim: claim, + accountPayload: accountPayload, secretKeyHex: secretKeyHex ) } ) async throws { - let accountManager = accountManager ?? .shared - if request.bitkitClaim == .watchOnlyAccountV1 { - let preparedClaim = try await accountManager.prepareUnsignedClaim(authUrl: authUrl, name: accountName) + guard authUrl == request.rawUrl else { throw PubkyServiceError.invalidAuthUrl } + if let claim = request.bitkitClaim, claim.includesWatchOnlyAccount { + let accountManager = accountManager ?? .shared + let preparedClaim = try await accountManager.prepareUnsignedClaim( + authUrl: authUrl, name: accountName + ) let authorizationAttempt = try accountManager.acquireSetupAuthorizationAttempt(id: preparedClaim.0.id) defer { accountManager.finishSetupAuthorizationAttempt(authorizationAttempt) } @@ -180,7 +174,7 @@ enum PubkyService { } do { - try await companionApproval(authUrl, request.clientID, preparedClaim.1, secretKeyHex) + try await companionApproval(authUrl, request.clientID, claim, preparedClaim.1, secretKeyHex) } catch { if !didDeliverCompanionClaim(error: error) { await cancelIncompleteAuthorization( @@ -192,6 +186,8 @@ enum PubkyService { } try await accountManager.markSetupActive(attempt: authorizationAttempt) + } else if let claim = request.bitkitClaim { + try await companionApproval(authUrl, request.clientID, claim, nil, secretKeyHex) } else { try await ordinaryApproval(authUrl, request.capabilities, request.clientID, secretKeyHex) } @@ -287,13 +283,12 @@ enum PubkyService { try await PaykitSdkService.shared.contactRecords() } - static func saveContact(publicKey: String, label: String?, receiverPaths: [String]? = nil, + static func saveContact(publicKey: String, label: String?, restorePrivateConnection: Bool = false) async throws -> Paykit.ContactRecord { try await PaykitSdkService.shared.saveContact( publicKey: publicKey, label: label, - receiverPaths: receiverPaths, restorePrivateConnection: restorePrivateConnection ) } @@ -302,14 +297,10 @@ enum PubkyService { try await PaykitSdkService.shared.removeContact(publicKey: publicKey) } - static func resolveContactProfile(publicKey: String, allowPubkyProfileFallback: Bool) async throws -> Paykit.ContactProfileResolution? { + static func resolveContactProfile(publicKey: String, allowPubkyProfileFallback: Bool) async throws -> Paykit.ProfileResolution? { 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 { @@ -333,7 +324,6 @@ actor PaykitSdkService { walletBackupDataChangedSubject.eraseToAnyPublisher() } - private let stateStore = PaykitSdkStateBlobStore() private let sessionProvider = PaykitSdkSessionProvider() private let paymentAdapter = PaykitSdkPaymentAdapter() private let operationLock = PaykitSdkOperationLock() @@ -363,6 +353,7 @@ actor PaykitSdkService { } private func initializeLocked() async throws { + try await refreshPaykitKey() var sdk = try handle() do { _ = try await sdk.initialize() @@ -381,7 +372,7 @@ actor PaykitSdkService { } sessionProvider.resumeStoredSessionAccess() } - await publishReceiverMarkerIfLiveSessionAvailable(using: sdk) + await publishAppIfLiveSessionAvailable(using: sdk) } /// Keep credential reads and fallback activation atomic with sign-out and identity changes. @@ -457,12 +448,12 @@ actor PaykitSdkService { } func currentPublicKey() async throws -> String? { - try await operationLock.withLock { - if let status = try await handle().identityStatus(), let publicKey = status.publicKey { + try await withSdk { sdk in + if let status = try await sdk.identityStatus(), let publicKey = status.publicKey { return publicKey } - guard let publicKey = try await handle().initialize().identity.publicKey else { + guard let publicKey = try await sdk.initialize().publicKey else { return nil } @@ -471,8 +462,8 @@ actor PaykitSdkService { } func identityStatus() async throws -> IdentityStatus? { - try await operationLock.withLock { - try await handle().identityStatus() + try await withSdk { sdk in + try await sdk.identityStatus() } } @@ -483,13 +474,11 @@ actor PaykitSdkService { } private func importSessionLocked(secret: String) async throws -> PubkySessionBootstrapResult { - let previousPublicKey = try await currentSdkStatePublicKey() + let previousPublicKey = try await cachedIdentityPublicKey() let localSecret = try sessionProvider.loadLocalSecretKey() - let receiverNoiseSecretKey = try sessionProvider.loadOrDeriveReceiverNoiseSecretKey() let result = try await bootstrap().importSession( sessionSecret: secret, localSecretKey: localSecret, - receiverNoiseSecretKey: receiverNoiseSecretKey, requiredCapabilities: Self.requiredCapabilities() ) try await activateBootstrapResult(result, previousPublicKey: previousPublicKey) @@ -499,11 +488,9 @@ actor PaykitSdkService { func signUp(secretKeyHex: String, homeserverPublicKey: String, signupCode: String?) async throws -> PubkySessionBootstrapResult { try await operationLock.withLock { - let previousPublicKey = try await currentSdkStatePublicKey() - let receiverNoiseSecretKey = try sessionProvider.loadOrDeriveReceiverNoiseSecretKey() + let previousPublicKey = try await cachedIdentityPublicKey() let result = try await bootstrap().signUp( localSecretKey: Self.localSecretKey(fromHex: secretKeyHex), - receiverNoiseSecretKey: receiverNoiseSecretKey, homeserverPublicKey: homeserverPublicKey, signupCode: signupCode, requiredCapabilities: Self.requiredCapabilities() @@ -523,7 +510,6 @@ actor PaykitSdkService { let generation = try operationLock.walletGeneration() let result = try await bootstrap().signUp( localSecretKey: Self.localSecretKey(fromHex: secretKeyHex), - receiverNoiseSecretKey: sessionProvider.loadOrDeriveReceiverNoiseSecretKey(), homeserverPublicKey: homeserverPublicKey, signupCode: signupCode, requiredCapabilities: Self.requiredCapabilities() @@ -534,7 +520,7 @@ actor PaykitSdkService { func activateRegisteredIdentity(_ identity: PubkyRegisteredIdentity) async throws { try await operationLock.withLock(generation: identity.walletGeneration) { - let previousPublicKey = try await currentSdkStatePublicKey() + let previousPublicKey = try await cachedIdentityPublicKey() try await activateBootstrapResult(identity.result, previousPublicKey: previousPublicKey) markWalletBackupDataChanged() } @@ -547,11 +533,9 @@ actor PaykitSdkService { } private func signInLocked(secretKeyHex: String) async throws -> PubkySessionBootstrapResult { - let previousPublicKey = try await currentSdkStatePublicKey() - let receiverNoiseSecretKey = try sessionProvider.loadOrDeriveReceiverNoiseSecretKey() + let previousPublicKey = try await cachedIdentityPublicKey() let result = try await bootstrap().signIn( localSecretKey: Self.localSecretKey(fromHex: secretKeyHex), - receiverNoiseSecretKey: receiverNoiseSecretKey, requiredCapabilities: Self.requiredCapabilities() ) try await activateBootstrapResult(result, previousPublicKey: previousPublicKey) @@ -597,7 +581,9 @@ actor PaykitSdkService { func publishPaykitProfile(_ profile: Paykit.PaykitProfile) async throws -> Paykit.PaykitProfileRecord { try await withStateRevisionTracking { sdk in - try await sdk.publishPaykitProfile(profile: profile) + guard let publicKey = try await sdk.identityStatus()?.publicKey else { throw PubkyServiceError.sessionNotActive } + let current = try await sdk.fetchPaykitProfile(publicKey: publicKey) + return try await sdk.publishPaykitProfile(profile: profile, expectedRevision: current?.revision) } } @@ -605,7 +591,7 @@ actor PaykitSdkService { let record = try await withStateRevisionTracking { sdk in if let expectedIdentity { guard let identity = try await sdk.identityStatus(), - identity.liveSessionAvailable, + identity.capability == .publicOnly || identity.capability == .privateLinkCapable, PubkyPublicKeyFormat.matches(identity.publicKey, expectedIdentity) else { throw PaykitPaymentRequestError.requestUnavailable } } @@ -616,53 +602,52 @@ actor PaykitSdkService { func deletePaykitProfile() async throws { try await withStateRevisionTracking { sdk in - try await sdk.deletePaykitProfile() + guard let publicKey = try await sdk.identityStatus()?.publicKey else { throw PubkyServiceError.sessionNotActive } + guard let current = try await sdk.fetchPaykitProfile(publicKey: publicKey) else { return } + try await sdk.deletePaykitProfile(expectedRevision: current.revision) } } func fetchPubkyFollows(publicKey: String) async throws -> [String] { try await operationLock.withLock { - try await handle().fetchPubkyFollows(publicKey: publicKey) + try await handle().fetchPubkyFollows(publicKey: publicKey, maxEntries: 1000) } } func contactRecords() async throws -> [Paykit.ContactRecord] { - try await operationLock.withLock { - try await handle().contactRecords() + try await withSdk { sdk in + try await sdk.contactRecords() } } func contactRecord(publicKey: String) async throws -> Paykit.ContactRecord? { - try await operationLock.withLock { - try await handle().contactRecord(publicKey: publicKey) + try await withSdk { sdk in + try await sdk.contactRecord(publicKey: publicKey) } } func saveContact( publicKey: String, label: String?, - receiverPaths: [String]? = nil, restorePrivateConnection: Bool = false ) async throws -> Paykit.ContactRecord { try await withStateRevisionTracking { sdk in let existing = try await sdk.contactRecord(publicKey: publicKey) guard restorePrivateConnection || existing != nil else { throw PubkyServiceError.profileNotFound } - let existingPaths = existing?.receiverPaths ?? [] - let contactPaths = Self.mergedReceiverPaths(existingPaths + (receiverPaths ?? [])) if restorePrivateConnection { let blockedPeers = try await sdk.linkedPeers().filter { $0.state == .blocked && PubkyPublicKeyFormat.matches($0.counterparty, publicKey) } do { for peer in blockedPeers { - _ = try await sdk.unblockPeer(counterparty: peer.counterparty, counterpartyReceiverPath: peer.counterpartyReceiverPath) + _ = try await sdk.unblockPeer(counterparty: peer.counterparty) } - return try await sdk.saveContact(update: Paykit.ContactUpdate(publicKey: publicKey, receiverPaths: contactPaths, label: label)) + return try await sdk.saveContact(update: Paykit.ContactUpdate(publicKey: publicKey, label: label)) } catch { let restorationError = error for peer in blockedPeers { do { - _ = try await sdk.blockPeer(counterparty: peer.counterparty, counterpartyReceiverPath: peer.counterpartyReceiverPath) + _ = try await sdk.blockPeer(counterparty: peer.counterparty) } catch { Logger.error("Failed to restore peer block after contact save failed: \(error)", context: "PaykitSdkService") } @@ -670,15 +655,13 @@ actor PaykitSdkService { throw restorationError } } - return try await sdk.saveContact(update: Paykit.ContactUpdate(publicKey: publicKey, receiverPaths: contactPaths, label: label)) + return try await sdk.saveContact(update: Paykit.ContactUpdate(publicKey: publicKey, label: label)) } } func removeContact(publicKey: String) async throws -> Paykit.ContactRecord? { try await withStateRevisionTracking { sdk in - let record = try await sdk.contactRecord(publicKey: publicKey) let peers = try await sdk.linkedPeers().filter { PubkyPublicKeyFormat.matches($0.counterparty, publicKey) } - let receiverPaths = Set(record?.receiverPaths ?? []).union(peers.map(\.counterpartyReceiverPath)) let now = Date() let activeSubscriptions = try await sdk.paymentRequests().filter { PubkyPublicKeyFormat.matches($0.counterparty, publicKey) && @@ -692,11 +675,10 @@ actor PaykitSdkService { let latestEndDate = endDates.count == activeSubscriptions.count ? endDates.max() : nil throw PubkyServiceError.activeSubscription(endsAt: latestEndDate) } - for peer in peers where peer.state == .linked { + if peers.contains(where: { $0.state == .linked }) { do { let report = try await sdk.clearPrivatePaymentListAndProcessOutbound( - counterparty: publicKey, - counterpartyReceiverPath: peer.counterpartyReceiverPath + counterparty: publicKey ) if !report.failedToQueue.isEmpty || !report.failedToDeliver.isEmpty { Logger.warn("Failed to withdraw private endpoints before contact deletion", context: "PaykitSdkService") @@ -707,118 +689,37 @@ actor PaykitSdkService { Logger.warn("Failed to withdraw private endpoints before contact deletion: \(error)", context: "PaykitSdkService") } } - for receiverPath in receiverPaths.sorted() { - _ = try await sdk.blockPeer(counterparty: publicKey, counterpartyReceiverPath: receiverPath) - } + _ = try await sdk.blockPeer(counterparty: publicKey) return try await sdk.removeContact(publicKey: publicKey) } } - func resolveContactProfile(publicKey: String, allowPubkyProfileFallback: Bool) async throws -> Paykit.ContactProfileResolution? { + func resolveContactProfile(publicKey: String, allowPubkyProfileFallback: Bool) async throws -> Paykit.ProfileResolution? { try await operationLock.withLock { - try await handle().resolveContactProfile( + try await handle().resolveProfile( 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)) - } - } - /// Takes the SDK lock per read and drops out of its queue once cancelled, so an abandoned eligibility check /// holds up a payment for at most the one read already in flight. - func paymentRequestReceiverPaths(publicKey: String) async throws -> [String] { + func canReceivePaymentRequests(publicKey: String) async throws -> Bool { try Task.checkCancellation() - let paths = try await operationLock.withCancellableLock { - try await handle().paykitReceiverPaths(publicKey: publicKey) - } - var capablePaths = Set() - - for path in paths where PaykitReceiverPath.supported.contains(path) { - try Task.checkCancellation() - let marker = try await operationLock.withCancellableLock { - try await handle().paykitReceiverMarker(publicKey: publicKey, receiverPath: path) - } - if marker?.capabilities.paymentRequests == true { - capablePaths.insert(path) - } - } - - return PaykitReceiverPath.supported.filter { capablePaths.contains($0) } - } - - 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 - ) + return try await operationLock.withCancellableLock { + try await handle().paykitAppRegistry(publicKey: publicKey)?.apps.contains { + $0.capabilities.paymentRequests && $0.capabilities.outgoingPayments + } == true } } - func syncLocalReceiverMarker(isDiscoverable: Bool) async throws { + func syncPaykitApp(privatePaymentsEnabled: Bool) async throws { try await withStateRevisionTracking { sdk in - guard isDiscoverable else { - try await sdk.removePaykitReceiverMarker() - return - } - - _ = try await sdk.publishPaykitReceiverMarker(capabilities: receiverCapabilities(using: sdk)) + var capabilities = try await appCapabilities(using: sdk) + capabilities.privatePayments = capabilities.privatePayments && + (privatePaymentsEnabled || UserDefaults.standard.bool(forKey: PrivatePaykitService.cleanupPendingKey)) + _ = try await sdk.publishPaykitApp(displayName: "Bitkit", capabilities: capabilities) } } @@ -842,25 +743,23 @@ actor PaykitSdkService { func ensureLinkWithPeer( _ counterparty: String, - receiverPath: String, maxAdvanceSteps: UInt32 = 8 ) async throws -> LinkedPeerHandshakeReport { try await withStateRevisionTracking { sdk in - try await sdk.ensureLinkWithPeer(counterparty: counterparty, counterpartyReceiverPath: receiverPath, maxAdvanceSteps: maxAdvanceSteps) + try await sdk.ensureLinkWithPeer(counterparty: counterparty, maxAdvanceSteps: maxAdvanceSteps) } } func clearPrivatePaymentList( - to counterparty: String, - receiverPath: String + to counterparty: String ) async throws -> PrivatePaymentListDeliveryReport? { try await withStateRevisionTracking { sdk in if try await sdk.linkedPeers().contains(where: { - $0.state == .blocked && PubkyPublicKeyFormat.matches($0.counterparty, counterparty) && $0.counterpartyReceiverPath == receiverPath + $0.state == .blocked && PubkyPublicKeyFormat.matches($0.counterparty, counterparty) }) { return nil } - return try await sdk.clearPrivatePaymentListAndProcessOutbound(counterparty: counterparty, counterpartyReceiverPath: receiverPath) + return try await sdk.clearPrivatePaymentListAndProcessOutbound(counterparty: counterparty) } } @@ -879,21 +778,41 @@ actor PaykitSdkService { } func paymentRequests() async throws -> [Paykit.PaymentRequestRecord] { - try await operationLock.withLock { - try await handle().paymentRequests() + try await sharedPaymentRequests().filter(Self.isBitkitPaymentRequest) + } + + func sharedPaymentRequests() async throws -> [Paykit.PaymentRequestRecord] { + try await withSdk { sdk in + try await sdk.paymentRequests() + } + } + + nonisolated static func isBitkitPaymentRequest(_ record: Paykit.PaymentRequestRecord) -> Bool { + switch record.localRole { + case .payee: + return record.proposalAppId == "bitkit" + case .payer: + if let appId = record.executionClaimAppId { + return appId == "bitkit" + } + // Acceptance history does not retain execution ownership after a claim is released. + if record.state == .activeRecurring || (record.state == .accepted && record.paymentProofs.isEmpty) { + return true + } + return record.payerAppId == nil || record.payerAppId == "bitkit" + case .none, .unknown: + return false } } func submitPaymentProof( counterparty: String, - counterpartyReceiverPath: String, paymentRequestId: String, proof: Paykit.PaymentProofSubmission ) async throws -> Paykit.PaymentRequestRecord { try await withStateRevisionTracking { sdk in try await sdk.submitPaymentProof( counterparty: counterparty, - counterpartyReceiverPath: counterpartyReceiverPath, paymentRequestId: paymentRequestId, proof: proof ) @@ -902,20 +821,18 @@ actor PaykitSdkService { func proposePaymentRequest( counterparty: String, - counterpartyReceiverPath: String, terms: Paykit.PaymentRequestTerms, expectedIdentity: String ) async throws -> Paykit.PaymentRequestRecord { try await withStateRevisionTracking { sdk in guard let identityStatus = try await sdk.identityStatus(), - identityStatus.liveSessionAvailable, + identityStatus.capability == .privateLinkCapable, PubkyPublicKeyFormat.matches(identityStatus.publicKey, expectedIdentity) else { throw PaykitPaymentRequestError.requestUnavailable } return try await sdk.proposePaymentRequest( counterparty: counterparty, - counterpartyReceiverPath: counterpartyReceiverPath, terms: terms ) } @@ -923,28 +840,31 @@ actor PaykitSdkService { func acceptPaymentRequest( counterparty: String, - counterpartyReceiverPath: String, paymentRequestId: String ) async throws -> Paykit.PaymentRequestRecord { try await withStateRevisionTracking { sdk in - try await sdk.acceptPaymentRequest( + _ = try await sdk.claimPaymentRequestForExecution(counterparty: counterparty, paymentRequestId: paymentRequestId) + return try await sdk.acceptPaymentRequest( counterparty: counterparty, - counterpartyReceiverPath: counterpartyReceiverPath, paymentRequestId: paymentRequestId ) } } + func claimPaymentRequestForExecution(counterparty: String, paymentRequestId: String) async throws -> Paykit.PaymentRequestRecord { + try await withStateRevisionTracking { sdk in + try await sdk.claimPaymentRequestForExecution(counterparty: counterparty, paymentRequestId: paymentRequestId) + } + } + func rejectPaymentRequest( counterparty: String, - counterpartyReceiverPath: String, paymentRequestId: String, reason: String? = nil ) async throws -> Paykit.PaymentRequestRecord { try await withStateRevisionTracking { sdk in try await sdk.rejectPaymentRequest( counterparty: counterparty, - counterpartyReceiverPath: counterpartyReceiverPath, paymentRequestId: paymentRequestId, reason: reason ) @@ -953,14 +873,12 @@ actor PaykitSdkService { func cancelPaymentRequest( counterparty: String, - counterpartyReceiverPath: String, paymentRequestId: String, reason: String? = nil ) async throws -> Paykit.PaymentRequestRecord { try await withStateRevisionTracking { sdk in try await sdk.cancelPaymentRequest( counterparty: counterparty, - counterpartyReceiverPath: counterpartyReceiverPath, paymentRequestId: paymentRequestId, reason: reason ) @@ -968,27 +886,25 @@ actor PaykitSdkService { } func linkedPeers() async throws -> [LinkedPeerRecord] { - try await operationLock.withLock { - try await handle().linkedPeers() + try await withSdk { sdk in + try await sdk.linkedPeers() } } - func pendingOutboundPrivateCounterparties() async throws -> [CounterpartyReceiver] { - try await operationLock.withLock { - try await handle().pendingOutboundPrivateCounterparties() + func pendingOutboundPrivateCounterparties() async throws -> [String] { + try await withSdk { sdk in + try await sdk.pendingOutboundPrivateCounterparties() } } func prepareAndResolvePrivateContactPayment( counterparty: String, - receiverPath: String, amount: PaymentAmountContext? = nil, afterPrivatePaymentListVersion: UInt64? ) async throws -> PreparedPrivateContactPayment { try await withStateRevisionTracking { sdk in try await sdk.prepareAndResolvePrivateContactPayment( counterparty: counterparty, - counterpartyReceiverPath: receiverPath, amount: amount, afterPrivatePaymentListVersion: afterPrivatePaymentListVersion, maxAdvanceSteps: 8 @@ -996,26 +912,31 @@ actor PaykitSdkService { } } - func resolvePublicContactPayment( + func prepareAndResolvePrivatePaymentRequest( counterparty: String, - receiverPath: String - ) async throws -> PublicContactPaymentResolution { - try await operationLock.withLock { - try await handle().resolvePublicContactPayment(counterparty: counterparty, counterpartyReceiverPath: receiverPath, amount: nil) + paymentRequestId: String, + afterPrivatePaymentListVersion: UInt64? + ) async throws -> PreparedPrivateContactPayment { + try await withStateRevisionTracking { sdk in + try await sdk.prepareAndResolvePrivatePaymentRequest( + counterparty: counterparty, + paymentRequestId: paymentRequestId, + afterPrivatePaymentListVersion: afterPrivatePaymentListVersion, + maxAdvanceSteps: 8 + ) } } - func exportBackupState() async throws -> String { + func resolvePublicContactPayment(counterparty: String) async throws -> PublicContactPaymentResolution { try await operationLock.withLock { - try await handle().exportBackupString() + try await handle().resolvePublicContactPayment(counterparty: counterparty, amount: nil) } } - func restoreBackupState(_ backup: String) async throws { - try await withStateRevisionTracking { sdk in - _ = try await sdk.restoreBackupString(backup: backup) + func exportBackupState() async throws -> String { + try await withSdk { sdk in + try await sdk.exportBackupString() } - resetRuntime() } func signOut() async throws { @@ -1026,9 +947,10 @@ actor PaykitSdkService { } func forgetSessionAccess() async throws { - defer { resetRuntime() } - try await withStateRevisionTracking { sdk in - _ = try await sdk.forgetSessionAccess() + try await operationLock.withLock { + defer { resetRuntime() } + _ = try await handle().forgetSessionAccess() + markWalletBackupDataChanged() } } @@ -1046,19 +968,14 @@ actor PaykitSdkService { func clearState() async { do { try await operationLock.withLock { - clearStateLocked() + resetRuntime() + markWalletBackupDataChanged() } } catch { Logger.warn("Skipped Paykit state cleanup during wallet wipe", context: "PaykitSdkService") } } - private func clearStateLocked() { - try? Keychain.delete(key: .paykitSdkState) - resetRuntime() - markWalletBackupDataChanged() - } - nonisolated static func localSecretKey(fromHex secretKeyHex: String) throws -> PubkyLocalSecretKey { let hex = secretKeyHex.trimmingCharacters(in: .whitespacesAndNewlines) guard hex.count.isMultiple(of: 2) else { @@ -1076,7 +993,7 @@ actor PaykitSdkService { } nonisolated static func requiredCapabilities() throws -> String { - try Paykit.requiredSessionCapabilities(config: config()) + Paykit.requiredSessionCapabilities() } private func handle() throws -> PaykitSdk { @@ -1084,8 +1001,7 @@ actor PaykitSdkService { return sdk } - let created = try sdkFactory?() ?? PaykitSdk.withPaymentAdapterAndPubkyClientConfig( - stateStore: stateStore, + let created = try sdkFactory?() ?? PaykitSdk.withPaymentAdapterAndPubkySharedStateAndClientConfig( sessionProvider: sessionProvider, paymentAdapter: paymentAdapter, config: Self.config(), @@ -1095,9 +1011,16 @@ actor PaykitSdkService { return created } - private func withStateRevisionTracking(_ operation: (PaykitSdk) async throws -> T) async throws -> T { + private func withSdk(_ operation: (PaykitSdk) async throws -> T) async throws -> T { try await operationLock.withLock { + try await refreshPaykitKey() let sdk = try handle() + return try await operation(sdk) + } + } + + private func withStateRevisionTracking(_ operation: (PaykitSdk) async throws -> T) async throws -> T { + try await withSdk { sdk in return try await Self.withBackupStateRevisionTracking( readRevision: { try await sdk.backupStateRevision() }, onChange: { self.markWalletBackupDataChanged() }, @@ -1133,12 +1056,37 @@ actor PaykitSdkService { sdk = nil } + private func refreshPaykitKey() async throws { + guard let root = try sessionProvider.loadLocalSecretKey() else { return } + try await sessionProvider.setPaykitIdentitySecretKey(paykitKey(for: root)) + } + + func paykitKeyForAuthorization(secretKeyHex: String) async throws -> PaykitIdentitySecretKey { + try await operationLock.withLock { + try await paykitKey(for: Self.localSecretKey(fromHex: secretKeyHex)) + } + } + + private func paykitKey(for root: PubkyLocalSecretKey) async throws -> PaykitIdentitySecretKey { + let publicKey = try Paykit.pubkyPublicKeyFromSecret(localSecretKey: root) + let registry = try await handle().paykitAppRegistry(publicKey: publicKey) + let generation = registry?.keyGeneration ?? 1 + let saved = try Keychain.load(key: .paykitKeyGeneration(publicKey: publicKey)) + .map { try JSONDecoder().decode(UInt64.self, from: $0) } + guard generation >= (saved ?? 1) else { + throw PaykitError.Identity(code: "stale_paykit_key_generation", context: "The Paykit App Registry has an older key generation") + } + if saved != generation { + try Keychain.upsert(key: .paykitKeyGeneration(publicKey: publicKey), data: JSONEncoder().encode(generation)) + } + return try root.derivePaykitIdentitySecretKey(keyGeneration: generation) + } + private func persistSessionAccess(_ access: PubkySessionAccess) throws { guard let sessionData = access.exportSessionSecret().data(using: .utf8) else { throw KeychainError.failedToSave } try Keychain.upsert(key: .paykitSession, data: sessionData) - try sessionProvider.persistReceiverNoiseSecretKey(access.exportReceiverNoiseSecretKey()) guard AdoptedPubkyReference.current == nil, let localSecret = access.exportLocalSecretKey() else { try Keychain.delete(key: .pubkySecretKey) @@ -1155,21 +1103,18 @@ actor PaykitSdkService { _ result: PubkySessionBootstrapResult, previousPublicKey: String? ) async throws { - let keys: [KeychainEntryType] = [.paykitSession, .pubkySecretKey, .paykitReceiverNoiseSecretKey, .paykitSdkState] + let keys: [KeychainEntryType] = [.paykitSession, .pubkySecretKey] let previousValues = try keys.map { try Keychain.load(key: $0) } let isDifferentIdentity = !Self.publicKeysMatch(previousPublicKey, result.publicKey) let sdk: PaykitSdk do { try persistSessionAccess(result.sessionAccess) sessionProvider.setLiveSessionAccess(result.sessionAccess) - if isDifferentIdentity { - try Keychain.delete(key: .paykitSdkState) - } resetRuntime() + try await refreshPaykitKey() sdk = try handle() _ = try await sdk.initialize() } catch { - // Activation can write SDK state before failing; restore it along with the credentials. var rollbackError: Error? for (key, value) in zip(keys, previousValues) { do { @@ -1192,32 +1137,34 @@ actor PaykitSdkService { if AdoptedPubkyReference.current != nil || result.sessionAccess.exportLocalSecretKey() == nil { SharedPubkyKeychain.removeAllOwn() } - await publishReceiverMarkerIfLiveSessionAvailable(using: sdk) + await publishAppIfLiveSessionAvailable(using: sdk) await republishIdentityIfNeeded(publicKey: result.publicKey) } - private func publishReceiverMarkerIfLiveSessionAvailable(using sdk: PaykitSdk) async { + private func publishAppIfLiveSessionAvailable(using sdk: PaykitSdk) async { do { - let capabilities = try await receiverCapabilities(using: sdk) + var capabilities = try await appCapabilities(using: sdk) guard capabilities.privatePayments else { return } - _ = try await sdk.publishPaykitReceiverMarker(capabilities: capabilities) + capabilities.privatePayments = UserDefaults.standard.bool(forKey: PrivatePaykitService.publishingEnabledKey) || + UserDefaults.standard.bool(forKey: PrivatePaykitService.cleanupPendingKey) + _ = try await sdk.publishPaykitApp(displayName: "Bitkit", capabilities: capabilities) } catch { - Logger.warn("Failed to publish Paykit receiver marker: \(error)", context: "PaykitSdkService") + Logger.warn("Failed to publish Paykit app: \(error)", context: "PaykitSdkService") } } - private func receiverCapabilities(using sdk: PaykitSdk) async throws -> Paykit.PaykitReceiverCapabilities { + private func appCapabilities(using sdk: PaykitSdk) async throws -> Paykit.PaykitAppCapabilities { let status = try await sdk.identityStatus() - return Paykit.PaykitReceiverCapabilities( - privatePayments: status?.liveSessionAvailable == true, - paymentRequests: status?.liveSessionAvailable == true, + return Paykit.PaykitAppCapabilities( + privatePayments: status?.capability == .privateLinkCapable, + paymentRequests: status?.capability == .privateLinkCapable, receipts: false, outgoingPayments: true ) } - private func currentSdkStatePublicKey() async throws -> String? { - // Read the persisted owner without restoring the grant we are about to replace. + private func cachedIdentityPublicKey() async throws -> String? { + // Read cached identity metadata without restoring the grant we are about to replace. sessionProvider.suspendStoredSessionAccess() defer { sessionProvider.resumeStoredSessionAccess() } return try await handle().identityStatus()?.publicKey @@ -1233,17 +1180,6 @@ 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 - } - nonisolated static func shouldDeferStaleSession(error: Error, hasStoredSession: Bool) -> Bool { guard hasStoredSession, case let PaykitError.Identity(_, context) = error @@ -1254,10 +1190,6 @@ actor PaykitSdkService { return context == "restore Pubky grant session from platform provider" } - private nonisolated static func canReceivePrivatePaymentDetails(marker: Paykit.PaykitReceiverMarker?) -> Bool { - marker?.capabilities.privatePayments == true && marker?.capabilities.outgoingPayments == true - } - private func bootstrap() throws -> PubkySessionBootstrap { if let cachedBootstrap { return cachedBootstrap @@ -1285,11 +1217,8 @@ actor PaykitSdkService { } private nonisolated static func config() throws -> PaykitSdkConfig { - var config = try Paykit.defaultConfig(receiverPath: PaykitReceiverPath.wallet) - config.profileNamespace = clientID - config.endpointManagementScope = .managedOnly - config.encryptedLinkRecoveryMarkers = .enabled - config.publicContactSharing = .localOnly + var config = try Paykit.defaultConfig(appId: "bitkit") + config.publicContactSharing = .privateOnly return config } @@ -1471,59 +1400,12 @@ extension PublicPaykitService.Endpoint { } } -final class PaykitSdkStateBlobStore: SdkStateBlobStore, @unchecked Sendable { - private let lock = NSLock() - - private let loadData: () throws -> Data? - private let saveData: (Data) throws -> Void - - init( - loadData: @escaping () throws -> Data? = { try Keychain.load(key: .paykitSdkState) }, - saveData: @escaping (Data) throws -> Void = { try Keychain.upsert(key: .paykitSdkState, data: $0) } - ) { - self.loadData = loadData - self.saveData = saveData - } - - func loadStateBlob() throws -> SdkStateBlobSnapshot? { - try paykitStorageCallback(code: "state_load_failed") { - lock.lock() - defer { lock.unlock() } - - guard let data = try loadData() else { - return nil - } - - return try decodeSdkStateBlobSnapshot(bytes: data) - } - } - - func saveStateBlobAtomically(blob: SdkStateBlob, expectedRevision: String?) throws -> String { - try paykitStorageCallback(code: "state_save_failed") { - lock.lock() - defer { lock.unlock() } - - let currentRevision = try loadData() - .map { try decodeSdkStateBlobSnapshot(bytes: $0).revision } - guard currentRevision == expectedRevision else { - throw PaykitError.Storage(code: "revision_conflict", context: "SDK state revision changed") - } - - let nextRevision = UUID().uuidString - let snapshot = SdkStateBlobSnapshot(blob: blob, revision: nextRevision) - let encoded = try encodeSdkStateBlobSnapshot(snapshot: snapshot) - try saveData(encoded) - return nextRevision - } - } -} - final class PaykitSdkSessionProvider: SdkPubkySessionProvider, @unchecked Sendable { private let lock = NSLock() - private let receiverNoiseKeyStore = PaykitReceiverNoiseKeyStore() private let loadSessionSecret: () throws -> String? private let deleteKeychainValue: (KeychainEntryType) throws -> Void private var liveSessionAccess: PubkySessionAccess? + private var paykitIdentitySecretKey: PaykitIdentitySecretKey? private var isStoredSessionAccessSuspended = false init( @@ -1537,12 +1419,14 @@ final class PaykitSdkSessionProvider: SdkPubkySessionProvider, @unchecked Sendab func setLiveSessionAccess(_ access: PubkySessionAccess) { lock.lock() liveSessionAccess = access + paykitIdentitySecretKey = access.exportPaykitIdentitySecretKey() lock.unlock() } func clearLiveSessionAccess() { lock.lock() liveSessionAccess = nil + paykitIdentitySecretKey = nil lock.unlock() } @@ -1565,12 +1449,14 @@ final class PaykitSdkSessionProvider: SdkPubkySessionProvider, @unchecked Sendab return liveAccess } - return try PubkySessionAccess( + let access = try PubkySessionAccess( clientId: PaykitSdkService.clientID, sessionSecret: sessionSecret, localSecretKey: loadLocalSecretKey(), - receiverNoiseSecretKey: loadOrDeriveReceiverNoiseSecretKey() + paykitIdentitySecretKey: paykitIdentitySecretKey ) + liveSessionAccess = access + return access } } @@ -1611,12 +1497,13 @@ final class PaykitSdkSessionProvider: SdkPubkySessionProvider, @unchecked Sendab return try PaykitSdkService.localSecretKey(fromHex: secretKeyHex) } - func loadOrDeriveReceiverNoiseSecretKey() throws -> ReceiverNoiseSecretKey { - try receiverNoiseKeyStore.loadOrDerive() - } - - func persistReceiverNoiseSecretKey(_ key: ReceiverNoiseSecretKey) throws { - try receiverNoiseKeyStore.persist(key) + func setPaykitIdentitySecretKey(_ key: PaykitIdentitySecretKey) { + lock.lock() + defer { lock.unlock() } + if (paykitIdentitySecretKey?.keyGeneration() ?? 1) != key.keyGeneration() { + liveSessionAccess = nil + } + paykitIdentitySecretKey = key } } @@ -1636,127 +1523,17 @@ enum PubkySessionAccessTeardown { } } -enum PaykitReceiverNoiseKeyDerivation { - private static let domain = "bitkit/paykit/receiver-noise-key" - private static let version = "v1" - - static func deriveFromWalletSeed( - mnemonic: String, - passphrase: String?, - network: String, - receiverPath: String - ) throws -> Data { - var seed = try BitkitCore.mnemonicToSeed( - mnemonicPhrase: mnemonic, - passphrase: passphrase?.isEmpty == true ? nil : passphrase - ) - defer { seed.resetBytes(in: seed.startIndex ..< seed.endIndex) } - return derive(seed: seed, network: network, receiverPath: receiverPath) - } - - static func derive(seed: Data, network: String, receiverPath: String) -> Data { - let domainBytes = Data(domain.utf8) - let salt = Data(SHA256.hash(data: domainBytes)) - var prk = Data(HMAC.authenticationCode(for: seed, using: SymmetricKey(data: salt))) - defer { prk.resetBytes(in: prk.startIndex ..< prk.endIndex) } - - var expandInput = Data("\(version)\0\(network)\0\(receiverPath)".utf8) - expandInput.append(0x01) - return Data(HMAC.authenticationCode(for: expandInput, using: SymmetricKey(data: prk))) - } -} - -final class PaykitReceiverNoiseKeyStore: @unchecked Sendable { - private static let keyLength = 32 - - private let lock = NSLock() - private let loadBytes: () throws -> Data? - private let upsertBytes: (Data) throws -> Void - private let deriveBytes: () throws -> Data - private var validatedBytes: Data? - - init( - loadBytes: @escaping () throws -> Data? = { try Keychain.load(key: .paykitReceiverNoiseSecretKey) }, - upsertBytes: @escaping (Data) throws -> Void = { try Keychain.upsert(key: .paykitReceiverNoiseSecretKey, data: $0) }, - deriveBytes: @escaping () throws -> Data = { - guard let mnemonic = try Keychain.loadString(key: .bip39Mnemonic(index: 0)), !mnemonic.isEmpty else { - throw CustomServiceError.mnemonicNotFound - } - let passphrase = try Keychain.loadString(key: .bip39Passphrase(index: 0)) - return try PaykitReceiverNoiseKeyDerivation.deriveFromWalletSeed( - mnemonic: mnemonic, - passphrase: passphrase, - network: Env.networkName, - receiverPath: PaykitReceiverPath.wallet - ) - } - ) { - self.loadBytes = loadBytes - self.upsertBytes = upsertBytes - self.deriveBytes = deriveBytes - } - - func loadOrDerive() throws -> ReceiverNoiseSecretKey { - lock.lock() - defer { lock.unlock() } - return try ReceiverNoiseSecretKey(bytes: validatedKeyBytes()) - } - - func persist(_ key: ReceiverNoiseSecretKey) throws { - lock.lock() - defer { lock.unlock() } - - let bytes = key.exportBytes() - let expectedBytes = try validatedKeyBytes() - guard bytes == expectedBytes else { - throw invalidKeyError("Paykit receiver Noise key changed unexpectedly") - } - } - - private func validatedKeyBytes() throws -> Data { - let storedBytes = try loadBytes() - if let validatedBytes, storedBytes == validatedBytes { - return validatedBytes - } - validatedBytes = nil - - let derivedBytes = try deriveBytes() - guard derivedBytes.count == Self.keyLength else { - throw invalidKeyError("Derived Paykit receiver Noise key is invalid") - } - - if let storedBytes { - guard storedBytes.count == Self.keyLength else { - throw invalidKeyError("Stored Paykit receiver Noise key is invalid") - } - guard storedBytes == derivedBytes else { - throw invalidKeyError("Stored Paykit receiver Noise key does not match the wallet seed") - } - } else { - try upsertBytes(derivedBytes) - } - - validatedBytes = derivedBytes - return derivedBytes - } - - private func invalidKeyError(_ context: String) -> PaykitError { - PaykitError.Identity(code: "invalid_receiver_noise_secret_key", context: context) - } -} - private final class PaykitSdkPaymentAdapter: SdkPaymentAdapter, @unchecked Sendable { func currentPublicReceivingDetails() throws -> [PublicReceivingDetail] { [] } - func currentPrivateReceivingDetails(counterparty _: String, counterpartyReceiverPath _: String) throws -> [PrivateReceivingDetail] { + func currentPrivateReceivingDetails(counterparty _: String) throws -> [PrivateReceivingDetail] { [] } func reservePrivateReceivingDetails( - counterparty _: String, - counterpartyReceiverPath _: String + counterparty _: String ) throws -> PrivateReceivingDetailReservationResponse { PrivateReceivingDetailReservationResponse(kind: .useCurrentReceivingDetails, reservations: []) } diff --git a/Bitkit/Services/PublicPaykitService.swift b/Bitkit/Services/PublicPaykitService.swift index 1cc8d1116..791b68707 100644 --- a/Bitkit/Services/PublicPaykitService.swift +++ b/Bitkit/Services/PublicPaykitService.swift @@ -27,8 +27,13 @@ enum PublicPaykitError: LocalizedError { } struct PrivatePaykitPaymentContext: Equatable { - let receiverPath: String - let paymentListVersion: UInt64 + let paymentAppsByEndpoint: [String: String] + let paymentListVersion: UInt64? + + func paymentAppId(for endpointIdentifier: String) throws -> String { + guard let appId = paymentAppsByEndpoint[endpointIdentifier] else { throw PaykitPaymentRequestError.requestUnavailable } + return appId + } } enum IncomingPaykitPaymentRequestFailureReason: String, Hashable { @@ -238,6 +243,7 @@ enum PublicPaykitService { let min: String? let max: String? let rawPayload: String + var appId: String? var paymentRequest: String { value @@ -247,20 +253,14 @@ 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, - receiverPath: PaykitReceiverPath.wallet + counterparty: normalizedKey ) - var endpointsByMethodId: [MethodId: Endpoint] = [:] - - for resolvedEndpoint in resolution.payableEndpoints { - guard let endpoint = parseEndpoint(identifier: resolvedEndpoint.identifier, payload: resolvedEndpoint.target.payload) else { - continue - } - - endpointsByMethodId[endpoint.methodId] = endpoint + let endpoints = resolution.payableEndpoints.compactMap { resolvedEndpoint -> Endpoint? in + guard var endpoint = parseEndpoint(identifier: resolvedEndpoint.identifier, payload: resolvedEndpoint.target.payload) else { return nil } + endpoint.appId = resolvedEndpoint.appId + return endpoint } - - return MethodId.payablePreferenceOrder.compactMap { endpointsByMethodId[$0] } + return MethodId.payablePreferenceOrder.flatMap { method in endpoints.filter { $0.methodId == method } } } static func parseEndpoint( @@ -327,7 +327,7 @@ enum PublicPaykitService { firstError = firstError ?? error } do { - try await syncLocalReceiverMarker(publicSharingEnabled: false) + try await syncPaykitApp() } catch { firstError = firstError ?? error } @@ -338,14 +338,14 @@ enum PublicPaykitService { } let desiredEndpoints = try await buildWalletEndpoints(wallet: wallet, refreshIfNeeded: true, requireEndpoint: true) - try await syncLocalReceiverMarker(publicSharingEnabled: true) + try await syncPaykitApp() 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 syncPaykitApp() try await applyPublishedEndpoints(desiredEndpoints) } @@ -353,15 +353,9 @@ enum PublicPaykitService { try await applyPublishedEndpoints([]) } - static func syncLocalReceiverMarker( - publicSharingEnabled: Bool? = nil, - privateSharingEnabled: Bool? = nil - ) async throws { - let publicSharing = publicSharingEnabled ?? UserDefaults.standard.bool(forKey: publishingEnabledKey) + static func syncPaykitApp(privateSharingEnabled: Bool? = nil) async throws { let privateSharing = privateSharingEnabled ?? UserDefaults.standard.bool(forKey: PrivatePaykitService.publishingEnabledKey) - try await PaykitSdkService.shared.syncLocalReceiverMarker( - isDiscoverable: publicSharing || privateSharing - ) + try await PaykitSdkService.shared.syncPaykitApp(privatePaymentsEnabled: privateSharing) } static func hasPayablePublicEndpoint(publicKey: String) async throws -> Bool { diff --git a/Bitkit/Utilities/Keychain.swift b/Bitkit/Utilities/Keychain.swift index a8ecffb3b..2fb94b919 100644 --- a/Bitkit/Utilities/Keychain.swift +++ b/Bitkit/Utilities/Keychain.swift @@ -10,8 +10,8 @@ enum KeychainEntryType { case paykitPendingPaymentProofs case paykitPresentedPaymentRequests case paykitSubscriptionState - case paykitReceiverNoiseSecretKey - case paykitSdkState + case paykitKeyGeneration(publicKey: String) + case paykitRecoveryBackup case paykitPendingBackupRestore case pubkySecretKey @@ -25,8 +25,8 @@ enum KeychainEntryType { case .paykitPendingPaymentProofs: "paykit_pending_payment_proofs" case .paykitPresentedPaymentRequests: "paykit_presented_payment_requests" case .paykitSubscriptionState: "paykit_subscription_state" - case .paykitReceiverNoiseSecretKey: "paykit_receiver_noise_secret_key" - case .paykitSdkState: "paykit_sdk_state" + case let .paykitKeyGeneration(publicKey): "paykit_key_generation_\(publicKey)" + case .paykitRecoveryBackup: "paykit_recovery_backup" case .paykitPendingBackupRestore: "paykit_pending_backup_restore" case .pubkySecretKey: "pubky_secret_key" } diff --git a/Bitkit/Views/Contacts/AddContactView.swift b/Bitkit/Views/Contacts/AddContactView.swift index 969e66011..10258e6e3 100644 --- a/Bitkit/Views/Contacts/AddContactView.swift +++ b/Bitkit/Views/Contacts/AddContactView.swift @@ -216,7 +216,7 @@ struct AddContactView: View { canRetryError = false isLoading = false if let normalizedKey = PubkyPublicKeyFormat.normalized(publicKey) { - await contactsManager.refreshContactReceiverPaths(publicKey: normalizedKey, wallet: wallet) + await contactsManager.refreshContactLink(publicKey: normalizedKey, wallet: wallet) } return case let .valid(normalizedKey): diff --git a/Bitkit/Views/Sheets/PubkyAuthApproval/PubkyAuthApprovalSheet.swift b/Bitkit/Views/Sheets/PubkyAuthApproval/PubkyAuthApprovalSheet.swift index 2673c42e1..53dddfd06 100644 --- a/Bitkit/Views/Sheets/PubkyAuthApproval/PubkyAuthApprovalSheet.swift +++ b/Bitkit/Views/Sheets/PubkyAuthApproval/PubkyAuthApprovalSheet.swift @@ -85,7 +85,7 @@ struct PubkyAuthApprovalSheet: View { } static func initialState(for request: PubkyAuthRequest) -> ApprovalState { - request.bitkitClaim == .watchOnlyAccountV1 ? .watchOnlyConsent : .authorize + request.bitkitClaim?.includesWatchOnlyAccount == true ? .watchOnlyConsent : .authorize } private var headerTitle: String { @@ -259,6 +259,17 @@ struct PubkyAuthApprovalSheet: View { permissionsSection } + if config.request.bitkitClaim?.includesPaykitAccess == true { + VStack(alignment: .leading, spacing: 8) { + CaptionMText(t("pubky_auth__paykit_access_title"), textColor: .white64) + BodySText(t("pubky_auth__paykit_access_description")) + .fixedSize(horizontal: false, vertical: true) + } + .padding(.top, 24) + .accessibilityElement(children: .contain) + .accessibilityIdentifier("PubkyAuthPaykitAccess") + } + Spacer(minLength: 32) trustWarning @@ -490,7 +501,7 @@ struct PubkyAuthApprovalSheet: View { private func onBack() { guard state.canDismiss else { return } - if state == .authorize, config.request.bitkitClaim == .watchOnlyAccountV1 { + if state == .authorize, config.request.bitkitClaim?.includesWatchOnlyAccount == true { state = .watchOnlyConsent } else { sheets.hideSheet() diff --git a/Bitkit/Views/Wallets/Send/LnurlPayConfirm.swift b/Bitkit/Views/Wallets/Send/LnurlPayConfirm.swift index 114e4e073..fa23d9878 100644 --- a/Bitkit/Views/Wallets/Send/LnurlPayConfirm.swift +++ b/Bitkit/Views/Wallets/Send/LnurlPayConfirm.swift @@ -276,8 +276,12 @@ struct LnurlPayConfirm: View { try validateIncomingPaymentRequest(contactPaymentContext, amountMsats: amountMsats) if let incomingPaymentRequest { let endpointIdentifier = PublicPaykitService.MethodId.bitcoinLightningLnurl.rawValue + guard let privateContext = contactPaymentContext?.privatePaymentContext else { + throw PaykitPaymentRequestError.requestUnavailable + } try await PaykitPaymentProofService.shared.prepare( request: incomingPaymentRequest, + paymentAppId: privateContext.paymentAppId(for: endpointIdentifier), paymentEndpointIdentifier: endpointIdentifier, kind: .lightning ) diff --git a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift index fd0886afc..c19e9337f 100644 --- a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift +++ b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift @@ -858,8 +858,12 @@ struct SendConfirmationView: View { try validateIncomingPaymentRequestAmounts(contactPaymentContext) if let incomingPaymentRequest { let proof = try paymentProofPreparation() + guard let privateContext = contactPaymentContext?.privatePaymentContext else { + throw PaykitPaymentRequestError.requestUnavailable + } try await PaykitPaymentProofService.shared.prepare( request: incomingPaymentRequest, + paymentAppId: privateContext.paymentAppId(for: proof.endpointIdentifier), paymentEndpointIdentifier: proof.endpointIdentifier, kind: proof.kind ) @@ -988,9 +992,13 @@ struct SendConfirmationView: View { privatePaymentListOutcome = .succeeded await contactPaymentContext?.resolvePrivatePaymentListConsumption(privatePaymentListOutcome) if let incomingPaymentRequest, let preparedPaymentProof { + guard let paymentAppId = try contactPaymentContext?.privatePaymentContext?.paymentAppId( + for: preparedPaymentProof.endpointIdentifier + ) else { throw PaykitPaymentRequestError.requestUnavailable } await PaykitPaymentProofService.shared.completeOnchainPayment( incomingPaymentRequest, txid: txid, + paymentAppId: paymentAppId, paymentEndpointIdentifier: preparedPaymentProof.endpointIdentifier ) } diff --git a/Bitkit/Views/Wallets/Send/SendSheet.swift b/Bitkit/Views/Wallets/Send/SendSheet.swift index 47f7da4d9..6ae143217 100644 --- a/Bitkit/Views/Wallets/Send/SendSheet.swift +++ b/Bitkit/Views/Wallets/Send/SendSheet.swift @@ -799,8 +799,12 @@ struct SendSheet: View { } let endpointIdentifier = PublicPaykitService.onchainMethodId(for: address).rawValue + guard let privateContext = context?.privatePaymentContext else { + throw PaykitPaymentRequestError.requestUnavailable + } try await PaykitPaymentProofService.shared.prepare( request: request, + paymentAppId: privateContext.paymentAppId(for: endpointIdentifier), paymentEndpointIdentifier: endpointIdentifier, kind: .onchain ) @@ -822,13 +826,17 @@ struct SendSheet: View { private func completeHardwareContactPayment(_ context: ContactPaymentContext?, txid: String) async { guard let request = context?.incomingPaymentRequest, - let address = app.scannedOnchainInvoice?.address + let address = app.scannedOnchainInvoice?.address, + let paymentAppId = try? context?.privatePaymentContext?.paymentAppId( + for: PublicPaykitService.onchainMethodId(for: address).rawValue + ) else { return } await context?.resolvePrivatePaymentListConsumption(.succeeded) await PaykitPaymentProofService.shared.completeOnchainPayment( request, txid: txid, + paymentAppId: paymentAppId, paymentEndpointIdentifier: PublicPaykitService.onchainMethodId(for: address).rawValue ) } diff --git a/BitkitTests/ContactPaymentsServiceTests.swift b/BitkitTests/ContactPaymentsServiceTests.swift index a3ece80a3..719c65202 100644 --- a/BitkitTests/ContactPaymentsServiceTests.swift +++ b/BitkitTests/ContactPaymentsServiceTests.swift @@ -57,7 +57,7 @@ final class ContactPaymentsServiceTests: XCTestCase { XCTAssertEqual(operations.privatePublications.count, 1) XCTAssertEqual(operations.privatePublications[0].contactPublicKeys, ["contact-a", "contact-b"]) XCTAssertTrue(operations.privatePublications[0].requiresImmediatePublication) - XCTAssertEqual(operations.calls, ["private:publish", "public:true"]) + XCTAssertEqual(operations.calls, ["app:true", "private:publish", "public:true"]) XCTAssertEqual(operations.privateRemovalCount, 0) XCTAssertEqual(operations.publicCleanupValues, [false]) XCTAssertEqual(operations.privateCleanupValues, [false]) @@ -273,6 +273,9 @@ final class ContactPaymentsServiceTests: XCTestCase { func makeOperations() -> ContactPaymentsService.Operations { ContactPaymentsService.Operations( + syncPaykitApp: { enabled in + self.calls.append("app:\(enabled)") + }, syncPublicEndpoints: { publish in self.calls.append("public:\(publish)") self.publicPublicationValues.append(publish) diff --git a/BitkitTests/ContactsManagerTests.swift b/BitkitTests/ContactsManagerTests.swift index 29d661152..87a9c34ac 100644 --- a/BitkitTests/ContactsManagerTests.swift +++ b/BitkitTests/ContactsManagerTests.swift @@ -190,10 +190,10 @@ final class ContactsManagerTests: XCTestCase { private func contactRecord(key: String, name: String) -> ContactRecord { ContactRecord( - publicKey: key, receiverPaths: [PaykitReceiverPath.wallet], label: name, + publicKey: key, label: name, profile: PaykitProfile(displayName: name, imageUri: nil, extraJson: nil), profileFetchedAt: nil, createdAt: "2026-09-29T00:00:00Z", updatedAt: "2026-09-29T00:00:00Z", - publicContactMarkerStatus: .notPublished, publicContactMarkerReceiverPath: nil, + publicContactMarkerStatus: .notPublished, publicContactPublishedAt: nil, publicContactRemovedAt: nil, publicContactLastError: nil ) } diff --git a/BitkitTests/PaykitContactLifecycleTests.swift b/BitkitTests/PaykitContactLifecycleTests.swift index 474f9ee26..aa8d196ef 100644 --- a/BitkitTests/PaykitContactLifecycleTests.swift +++ b/BitkitTests/PaykitContactLifecycleTests.swift @@ -4,24 +4,42 @@ import XCTest @MainActor final class PaykitContactLifecycleTests: XCTestCase { - func testDeletionBlocksEveryKnownReceiverBeforeRemovingContact() async throws { + override func setUpWithError() throws { + try super.setUpWithError() + let savedSecret = try Keychain.load(key: .pubkySecretKey) + let savedReference = AdoptedPubkyReference.current + addTeardownBlock { + AdoptedPubkyReference.current = savedReference + if let savedSecret { + try Keychain.upsert(key: .pubkySecretKey, data: savedSecret) + } else { + try Keychain.delete(key: .pubkySecretKey) + } + } + AdoptedPubkyReference.current = nil + try Keychain.delete(key: .pubkySecretKey) + } + + func testDeletionBlocksPeerBeforeRemovingContact() async throws { for failWithdrawal in [false, true] { let sdk = ContactLifecycleSdk(noPointer: .init()) sdk.failWithdrawal = failWithdrawal let service = PaykitSdkService(sdkFactory: { sdk }) _ = try await service.removeContact(publicKey: sdk.publicKey) XCTAssertNil(sdk.record) - XCTAssertEqual(sdk.events, ["clear:bitkit/wallet", "clear:bitkit/server", "block:bitkit/server", "block:bitkit/wallet", "remove"]) + XCTAssertEqual(sdk.events, ["clear", "block", "remove"]) XCTAssertTrue(sdk.peers.allSatisfy { $0.state == .blocked }) } } func testActiveSubscriptionPreventsDeletionUntilItEnds() async throws { let fixedEndTimestamp = "2100-02-01T00:00:00Z" - let cases: [(role: PaymentRequestLocalRole, endsAt: String?)] = [ - (.payer, nil), - (.payer, fixedEndTimestamp), - (.payee, nil), + let cases: [(role: PaymentRequestLocalRole, endsAt: String?, appId: String)] = [ + (.payer, nil, "bitkit"), + (.payer, fixedEndTimestamp, "bitkit"), + (.payee, nil, "bitkit"), + (.payer, nil, "paykit-server"), + (.payee, nil, "paykit-server"), ] for testCase in cases { let sdk = ContactLifecycleSdk(noPointer: .init()) @@ -30,14 +48,17 @@ final class PaykitContactLifecycleTests: XCTestCase { paymentReference: PaymentReference(text: "subscription"), proposalExpiresAt: nil, recurrence: PaymentRequestRecurrence(every: 1, unit: "month", startsAt: "2026-01-01T00:00:00Z", anchor: "2026-01-01T00:00:00Z", endsAt: testCase.endsAt), - acceptedPaymentEndpointIdentifiers: ["lightning:bolt11"], conversion: nil, paymentDeadline: nil, + acceptedPaymentEndpointIdentifiers: ["btc-lightning-bolt11"], paymentEndpoints: nil, requiredAppId: testCase.appId, + conversion: nil, paymentDeadline: nil, metadata: PrivateJsonObject(text: "{}") ) sdk.requests = [PaymentRequestRecord( - counterparty: sdk.publicKey, counterpartyReceiverPath: PaykitReceiverPath.server, + counterparty: sdk.publicKey, paymentRequestId: "550e8400-e29b-41d4-a716-446655440000", localRole: testCase.role, state: .activeRecurring, proposalStreamItemId: nil, proposalOutboundMessageId: nil, proposalOutboundStatus: nil, - proposalEventId: nil, terms: terms, acceptedEventId: nil, acceptedOutboundStatus: nil, + proposalEventId: nil, proposalAppId: testCase.appId, + payerAppId: testCase.role == .payer ? testCase.appId : nil, executionClaimAppId: nil, + terms: terms, acceptedEventId: nil, acceptedOutboundStatus: nil, rejectedEventId: nil, rejectedOutboundStatus: nil, canceledEventId: nil, canceledOutboundStatus: nil, conversionQuotes: [], paymentProofs: [], lastStreamItemId: nil, lastOutboundMessageId: nil, lastOutboundStatus: nil, @@ -72,14 +93,16 @@ final class PaykitContactLifecycleTests: XCTestCase { paymentReference: PaymentReference(text: "subscription"), proposalExpiresAt: nil, recurrence: PaymentRequestRecurrence(every: 1, unit: "month", startsAt: "2026-01-01T00:00:00Z", anchor: "2026-01-01T00:00:00Z", endsAt: endsAt), - acceptedPaymentEndpointIdentifiers: ["lightning:bolt11"], conversion: nil, paymentDeadline: nil, + acceptedPaymentEndpointIdentifiers: ["btc-lightning-bolt11"], paymentEndpoints: nil, requiredAppId: "paykit-server", + conversion: nil, paymentDeadline: nil, metadata: PrivateJsonObject(text: "{}") ) return PaymentRequestRecord( - counterparty: publicKey, counterpartyReceiverPath: PaykitReceiverPath.server, + counterparty: publicKey, paymentRequestId: UUID().uuidString, localRole: .payer, state: .activeRecurring, proposalStreamItemId: nil, proposalOutboundMessageId: nil, proposalOutboundStatus: nil, - proposalEventId: nil, terms: terms, acceptedEventId: nil, acceptedOutboundStatus: nil, + proposalEventId: nil, proposalAppId: "paykit-server", payerAppId: "paykit-server", executionClaimAppId: nil, + terms: terms, acceptedEventId: nil, acceptedOutboundStatus: nil, rejectedEventId: nil, rejectedOutboundStatus: nil, canceledEventId: nil, canceledOutboundStatus: nil, conversionQuotes: [], paymentProofs: [], lastStreamItemId: nil, lastOutboundMessageId: nil, lastOutboundStatus: nil, lastEventAt: nil, invalidReason: nil @@ -123,7 +146,7 @@ final class PaykitContactLifecycleTests: XCTestCase { XCTAssertFalse(sdk.events.contains("remove")) } - func testOnlyExplicitReaddRestoresAllPrivateConnections() async throws { + func testOnlyExplicitReaddRestoresPrivateConnection() async throws { let sdk = ContactLifecycleSdk(noPointer: .init()) let service = PaykitSdkService(sdkFactory: { sdk }) _ = try await service.removeContact(publicKey: sdk.publicKey) @@ -139,17 +162,17 @@ final class PaykitContactLifecycleTests: XCTestCase { } func testFailedPrivateConnectionRestoreCanBeRetriedWithoutASavedContact() async throws { - let failures: [(peerLookup: Bool, unblockPath: String?, saveContact: Bool)] = [ - (true, nil, false), - (false, PaykitReceiverPath.server, false), - (false, nil, true), + let failures: [(peerLookup: Bool, unblock: Bool, saveContact: Bool)] = [ + (true, false, false), + (false, true, false), + (false, false, true), ] for failure in failures { let sdk = ContactLifecycleSdk(noPointer: .init()) let service = PaykitSdkService(sdkFactory: { sdk }) _ = try await service.removeContact(publicKey: sdk.publicKey) sdk.failLinkedPeers = failure.peerLookup - sdk.failUnblockPath = failure.unblockPath + sdk.failUnblock = failure.unblock sdk.failSaveContact = failure.saveContact do { _ = try await service.saveContact(publicKey: sdk.publicKey, label: "Contact", restorePrivateConnection: true) @@ -158,7 +181,7 @@ final class PaykitContactLifecycleTests: XCTestCase { XCTAssertNil(sdk.record) XCTAssertTrue(sdk.peers.allSatisfy { $0.state == .blocked }) sdk.failLinkedPeers = false - sdk.failUnblockPath = nil + sdk.failUnblock = false sdk.failSaveContact = false _ = try await service.saveContact(publicKey: sdk.publicKey, label: "Contact", restorePrivateConnection: true) XCTAssertNotNil(sdk.record) @@ -170,8 +193,10 @@ final class PaykitContactLifecycleTests: XCTestCase { let sdk = ContactLifecycleSdk(noPointer: .init()) let service = PaykitSdkService(sdkFactory: { sdk }) _ = try await service.removeContact(publicKey: sdk.publicKey) - let report = try await service.clearPrivatePaymentList(to: sdk.publicKey, receiverPath: PaykitReceiverPath.server) + let eventsBeforeCleanup = sdk.events + let report = try await service.clearPrivatePaymentList(to: sdk.publicKey) XCTAssertNil(report) + XCTAssertEqual(sdk.events, eventsBeforeCleanup) } } @@ -182,19 +207,23 @@ private final class ContactLifecycleSdk: PaykitSdk, @unchecked Sendable { var failWithdrawal = false var failBlock = false var failLinkedPeers = false - var failUnblockPath: String? + var failUnblock = false var failSaveContact = false lazy var record: ContactRecord? = ContactRecord( - publicKey: publicKey, receiverPaths: [PaykitReceiverPath.wallet], label: "Contact", profile: nil, + publicKey: publicKey, label: "Contact", profile: nil, profileFetchedAt: nil, createdAt: "2026-09-29T00:00:00Z", updatedAt: "2026-09-29T00:00:00Z", - publicContactMarkerStatus: .notPublished, publicContactMarkerReceiverPath: nil, + publicContactMarkerStatus: .notPublished, publicContactPublishedAt: nil, publicContactRemovedAt: nil, publicContactLastError: nil ) - lazy var peers: [LinkedPeerRecord] = [PaykitReceiverPath.wallet, PaykitReceiverPath.server].map { - LinkedPeerRecord(counterparty: publicKey, counterpartyReceiverPath: $0, state: .linked, + lazy var peers: [LinkedPeerRecord] = [ + LinkedPeerRecord(counterparty: publicKey, state: .linked, lastSyncAt: nil, lastPrivateReceiveAt: nil, failureCount: 0, localRecoveryAttemptId: nil, localRecoveryMarkerCreatedAt: nil, localRecoveryMarkerLastError: nil, - remoteRecoveryAttemptId: nil, remoteRecoveryMarkerObservedAt: nil) + remoteRecoveryAttemptId: nil, remoteRecoveryMarkerObservedAt: nil), + ] + + override func paykitAppRegistry(publicKey _: String) async throws -> PaykitAppRegistry? { + nil } override func backupStateRevision() async throws -> String { @@ -215,25 +244,25 @@ private final class ContactLifecycleSdk: PaykitSdk, @unchecked Sendable { } override func clearPrivatePaymentListAndProcessOutbound( - counterparty: String, - counterpartyReceiverPath: String + counterparty: String ) async throws -> PrivatePaymentListDeliveryReport { - events.append("clear:\(counterpartyReceiverPath)") + XCTAssertEqual(counterparty, publicKey) + events.append("clear") if failWithdrawal { throw PubkyServiceError.sessionNotActive } return PrivatePaymentListDeliveryReport(queued: [], cleared: [], failedToQueue: [], failedToDeliver: []) } - override func blockPeer(counterparty: String, counterpartyReceiverPath: String) async throws -> LinkedPeerRecord { + override func blockPeer(counterparty: String) async throws -> LinkedPeerRecord { if failBlock { throw PubkyServiceError.profileNotFound } - events.append("block:\(counterpartyReceiverPath)") - let index = try XCTUnwrap(peers.firstIndex { $0.counterpartyReceiverPath == counterpartyReceiverPath }) + events.append("block") + let index = try XCTUnwrap(peers.firstIndex { PubkyPublicKeyFormat.matches($0.counterparty, counterparty) }) peers[index].state = .blocked return peers[index] } - override func unblockPeer(counterparty: String, counterpartyReceiverPath: String) async throws -> LinkedPeerRecord { - if failUnblockPath == counterpartyReceiverPath { throw PubkyServiceError.profileNotFound } - let index = try XCTUnwrap(peers.firstIndex { $0.counterpartyReceiverPath == counterpartyReceiverPath }) + override func unblockPeer(counterparty: String) async throws -> LinkedPeerRecord { + if failUnblock { throw PubkyServiceError.profileNotFound } + let index = try XCTUnwrap(peers.firstIndex { PubkyPublicKeyFormat.matches($0.counterparty, counterparty) }) peers[index].state = .notLinked return peers[index] } @@ -249,9 +278,9 @@ private final class ContactLifecycleSdk: PaykitSdk, @unchecked Sendable { throw PubkyServiceError.profileNotFound } let saved = ContactRecord( - publicKey: update.publicKey, receiverPaths: update.receiverPaths, label: update.label, profile: nil, + publicKey: update.publicKey, label: update.label, profile: nil, profileFetchedAt: nil, createdAt: "2026-09-29T00:00:00Z", updatedAt: "2026-09-29T00:00:00Z", - publicContactMarkerStatus: .notPublished, publicContactMarkerReceiverPath: nil, + publicContactMarkerStatus: .notPublished, publicContactPublishedAt: nil, publicContactRemovedAt: nil, publicContactLastError: nil ) record = saved diff --git a/BitkitTests/PaykitIssuerInteropTests.swift b/BitkitTests/PaykitIssuerInteropTests.swift index f04ed035e..afd6c7f99 100644 --- a/BitkitTests/PaykitIssuerInteropTests.swift +++ b/BitkitTests/PaykitIssuerInteropTests.swift @@ -88,7 +88,6 @@ final class PaykitIssuerInteropTests: XCTestCase { private func paymentRequestRecord(asset: String, endpointIdentifiers: [String]) throws -> PaymentRequestRecord { try PaymentRequestRecord( counterparty: "pubkyissuerfixture", - counterpartyReceiverPath: PaykitReceiverPath.server, paymentRequestId: "71300000-0000-4000-8000-000000000001", localRole: .payer, state: .proposed, @@ -96,12 +95,17 @@ final class PaykitIssuerInteropTests: XCTestCase { proposalOutboundMessageId: nil, proposalOutboundStatus: nil, proposalEventId: "71300000-0000-4000-8000-000000000002", + proposalAppId: "bitkit", + payerAppId: nil, + executionClaimAppId: nil, terms: PaymentRequestTerms( amount: PaymentRequestAmount(value: "0.001", asset: asset), paymentReference: PaymentReference(text: "marketplace-order-713"), proposalExpiresAt: nil, recurrence: nil, acceptedPaymentEndpointIdentifiers: endpointIdentifiers, + paymentEndpoints: nil, + requiredAppId: "bitkit", conversion: nil, paymentDeadline: nil, metadata: PrivateJsonObject(text: #"{"order":"713"}"#) diff --git a/BitkitTests/PaykitPaymentProofServiceTests.swift b/BitkitTests/PaykitPaymentProofServiceTests.swift index b7b7ddd4a..5df5e4a05 100644 --- a/BitkitTests/PaykitPaymentProofServiceTests.swift +++ b/BitkitTests/PaykitPaymentProofServiceTests.swift @@ -63,6 +63,7 @@ final class PaykitPaymentProofServiceTests: XCTestCase { let service = paymentProofService(sdk: sdk, store: store) try await service.prepare( request: request, + paymentAppId: "bitkit", paymentEndpointIdentifier: PublicPaykitService.MethodId.bitcoinLightningBolt11.rawValue, kind: .lightning ) @@ -107,6 +108,7 @@ final class PaykitPaymentProofServiceTests: XCTestCase { try await service.prepare( request: request, + paymentAppId: "bitkit", paymentEndpointIdentifier: PublicPaykitService.MethodId.bitcoinLightningBolt11.rawValue, kind: .lightning ) @@ -133,6 +135,7 @@ final class PaykitPaymentProofServiceTests: XCTestCase { try await service.prepare( request: request, + paymentAppId: "bitkit", paymentEndpointIdentifier: PublicPaykitService.MethodId.bitcoinLightningBolt11.rawValue, kind: .lightning ) @@ -154,6 +157,7 @@ final class PaykitPaymentProofServiceTests: XCTestCase { try await service.prepare( request: request, + paymentAppId: "bitkit", paymentEndpointIdentifier: PublicPaykitService.MethodId.bitcoinLightningBolt11.rawValue, kind: .lightning ) @@ -175,6 +179,7 @@ final class PaykitPaymentProofServiceTests: XCTestCase { try await service.prepare( request: request, + paymentAppId: "bitkit", paymentEndpointIdentifier: PublicPaykitService.MethodId.bitcoinLightningBolt11.rawValue, kind: .lightning ) @@ -207,6 +212,7 @@ final class PaykitPaymentProofServiceTests: XCTestCase { let service = paymentProofService(sdk: sdk, store: store) try await service.prepare( request: request, + paymentAppId: "bitkit", paymentEndpointIdentifier: PublicPaykitService.MethodId.bitcoinLightningBolt11.rawValue, kind: .lightning ) @@ -248,6 +254,7 @@ final class PaykitPaymentProofServiceTests: XCTestCase { let service = paymentProofService(sdk: sdk, store: store) try await service.prepare( request: request, + paymentAppId: "bitkit", paymentEndpointIdentifier: PublicPaykitService.MethodId.bitcoinLightningBolt11.rawValue, kind: .lightning ) @@ -281,6 +288,7 @@ final class PaykitPaymentProofServiceTests: XCTestCase { try await service.prepare( request: request, + paymentAppId: "bitkit", paymentEndpointIdentifier: PublicPaykitService.MethodId.bitcoinLightningBolt11.rawValue, kind: .lightning ) @@ -312,7 +320,7 @@ final class PaykitPaymentProofServiceTests: XCTestCase { sdk: PaymentProofSdkMock(identity: identity, records: [record]), store: store ) - try await service.prepare(request: request, paymentEndpointIdentifier: endpoint, kind: .onchain) + try await service.prepare(request: request, paymentAppId: "bitkit", paymentEndpointIdentifier: endpoint, kind: .onchain) try await service.markOnchainPaymentStarted(request, address: onchainAddress) let protectedRequestIds = try await service.protectedRequestIdsForSubscriptionCancellation( @@ -332,6 +340,7 @@ final class PaykitPaymentProofServiceTests: XCTestCase { let otherIdentityProof = PendingPaykitPaymentProof( identity: "pubky\(String(repeating: "x", count: 52))", requestId: request.id, + paymentAppId: "bitkit", paymentEndpointIdentifier: PublicPaykitService.MethodId.bitcoinLightningBolt11.rawValue, kind: .lightning, paymentIdentifier: nil, @@ -345,6 +354,7 @@ final class PaykitPaymentProofServiceTests: XCTestCase { try await service.prepare( request: request, + paymentAppId: "bitkit", paymentEndpointIdentifier: PublicPaykitService.MethodId.bitcoinLightningBolt11.rawValue, kind: .lightning ) @@ -368,16 +378,17 @@ final class PaykitPaymentProofServiceTests: XCTestCase { let service = paymentProofService(sdk: sdk, store: store) let txid = String(repeating: "ab", count: 32) - try await service.prepare(request: request, paymentEndpointIdentifier: endpoint, kind: .onchain) + try await service.prepare(request: request, paymentAppId: "paykit-server", paymentEndpointIdentifier: endpoint, kind: .onchain) try await service.markOnchainPaymentStarted(request, address: onchainAddress) let inFlightRequestIds = await service.inFlightRequestIds(identity: identity) XCTAssertEqual(inFlightRequestIds, [request.id]) - await service.completeOnchainPayment(request, txid: txid, paymentEndpointIdentifier: endpoint) + await service.completeOnchainPayment(request, txid: txid, paymentAppId: "paykit-server", paymentEndpointIdentifier: endpoint) await sdk.waitForSubmissionStart() await fulfillment(of: [proofRemoved], timeout: 1) let submittedProof = await sdk.lastSubmission() let submission = try XCTUnwrap(submittedProof) + XCTAssertEqual(submission.paymentAppId, "paykit-server") XCTAssertEqual(submission.paymentEndpointIdentifier, endpoint) XCTAssertEqual( try proofValues(submission.proof.exportText()), @@ -397,11 +408,11 @@ final class PaykitPaymentProofServiceTests: XCTestCase { let txid = String(repeating: "ab", count: 32) let completion = expectation(description: "On-chain proof state persisted") - try await service.prepare(request: request, paymentEndpointIdentifier: endpoint, kind: .onchain) + try await service.prepare(request: request, paymentAppId: "bitkit", paymentEndpointIdentifier: endpoint, kind: .onchain) try await service.markOnchainPaymentStarted(request, address: onchainAddress) await sdk.suspendSubmission() let completionTask = Task { - await service.completeOnchainPayment(request, txid: txid, paymentEndpointIdentifier: endpoint) + await service.completeOnchainPayment(request, txid: txid, paymentAppId: "bitkit", paymentEndpointIdentifier: endpoint) completion.fulfill() } @@ -423,7 +434,7 @@ final class PaykitPaymentProofServiceTests: XCTestCase { let store = PaymentProofMemoryStore() let service = paymentProofService(sdk: PaymentProofSdkMock(identity: identity, records: [record]), store: store) - try await service.prepare(request: request, paymentEndpointIdentifier: endpoint, kind: .onchain) + try await service.prepare(request: request, paymentAppId: "bitkit", paymentEndpointIdentifier: endpoint, kind: .onchain) try await service.markOnchainPaymentStarted(request, address: onchainAddress) await service.cancelPreparation(request) @@ -458,7 +469,7 @@ final class PaykitPaymentProofServiceTests: XCTestCase { resolutionExpectation.fulfill() } - try await service.prepare(request: request, paymentEndpointIdentifier: endpoint, kind: .onchain) + try await service.prepare(request: request, paymentAppId: "bitkit", paymentEndpointIdentifier: endpoint, kind: .onchain) try await service.markOnchainPaymentStarted(request, address: onchainAddress) await service.reconcile() await sdk.waitForSubmissionStart() @@ -490,7 +501,7 @@ final class PaykitPaymentProofServiceTests: XCTestCase { existingOnchainTxids: [oldTransactionId] ) - try await service.prepare(request: request, paymentEndpointIdentifier: endpoint, kind: .onchain) + try await service.prepare(request: request, paymentAppId: "bitkit", paymentEndpointIdentifier: endpoint, kind: .onchain) try await service.markOnchainPaymentStarted(request, address: onchainAddress) await service.reconcile() @@ -509,6 +520,7 @@ final class PaykitPaymentProofServiceTests: XCTestCase { let proof = PendingPaykitPaymentProof( identity: identity, requestId: request.id, + paymentAppId: "bitkit", paymentEndpointIdentifier: endpoint, kind: .onchain, paymentStarted: true, @@ -532,12 +544,13 @@ final class PaykitPaymentProofServiceTests: XCTestCase { await service.completeOnchainPayment( request, txid: String(repeating: "cd", count: 32), + paymentAppId: "bitkit", paymentEndpointIdentifier: endpoint ) await service.failOnchainPayment(request) do { - try await service.prepare(request: request, paymentEndpointIdentifier: endpoint, kind: .onchain) + try await service.prepare(request: request, paymentAppId: "bitkit", paymentEndpointIdentifier: endpoint, kind: .onchain) XCTFail("A foreign-wallet proof must keep duplicate-payment protection") } catch let error as PaykitPaymentRequestError { XCTAssertEqual(error, .operationInProgress) @@ -565,6 +578,7 @@ final class PaykitPaymentProofServiceTests: XCTestCase { let proof = PendingPaykitPaymentProof( identity: identity, requestId: request.id, + paymentAppId: "bitkit", paymentEndpointIdentifier: endpoint, kind: .onchain, paymentStarted: true, @@ -608,6 +622,7 @@ final class PaykitPaymentProofServiceTests: XCTestCase { let proof = PendingPaykitPaymentProof( identity: identity, requestId: request.id, + paymentAppId: "bitkit", paymentEndpointIdentifier: endpoint, kind: .onchain, paymentStarted: true, @@ -659,6 +674,7 @@ final class PaykitPaymentProofServiceTests: XCTestCase { let proof = PendingPaykitPaymentProof( identity: identity, requestId: request.id, + paymentAppId: "bitkit", paymentEndpointIdentifier: endpoint, kind: .onchain, billingPeriod: request.billingPeriod, @@ -694,6 +710,7 @@ final class PaykitPaymentProofServiceTests: XCTestCase { let originalProof = PendingPaykitPaymentProof( identity: identity, requestId: request.id, + paymentAppId: "bitkit", paymentEndpointIdentifier: endpoint, kind: .onchain, paymentStarted: true, @@ -704,6 +721,7 @@ final class PaykitPaymentProofServiceTests: XCTestCase { let replacementProof = PendingPaykitPaymentProof( identity: identity, requestId: request.id, + paymentAppId: "bitkit", paymentEndpointIdentifier: endpoint, kind: .onchain, paymentStarted: true, @@ -735,6 +753,7 @@ final class PaykitPaymentProofServiceTests: XCTestCase { let proof = PendingPaykitPaymentProof( identity: identity, requestId: request.id, + paymentAppId: "bitkit", paymentEndpointIdentifier: endpoint, kind: .onchain, paymentStarted: true, @@ -776,6 +795,7 @@ final class PaykitPaymentProofServiceTests: XCTestCase { let proof = PendingPaykitPaymentProof( identity: identity, requestId: request.id, + paymentAppId: "bitkit", paymentEndpointIdentifier: endpoint, kind: .onchain, billingPeriod: request.billingPeriod, @@ -809,6 +829,7 @@ final class PaykitPaymentProofServiceTests: XCTestCase { let foreignProof = PendingPaykitPaymentProof( identity: identity, requestId: request.id, + paymentAppId: "bitkit", paymentEndpointIdentifier: endpoint, kind: .onchain, paymentStarted: false, @@ -826,11 +847,12 @@ final class PaykitPaymentProofServiceTests: XCTestCase { let sdk = PaymentProofSdkMock(identity: identity, records: [record]) let service = paymentProofService(sdk: sdk, store: store) - try await service.prepare(request: request, paymentEndpointIdentifier: endpoint, kind: .onchain) + try await service.prepare(request: request, paymentAppId: "bitkit", paymentEndpointIdentifier: endpoint, kind: .onchain) try await service.markOnchainPaymentStarted(request, address: onchainAddress) await service.completeOnchainPayment( request, txid: String(repeating: "ab", count: 32), + paymentAppId: "bitkit", paymentEndpointIdentifier: endpoint ) await sdk.waitForSubmissionStart() @@ -856,6 +878,7 @@ final class PaykitPaymentProofServiceTests: XCTestCase { let onchainProof = PendingPaykitPaymentProof( identity: identity, requestId: onchainRequest.id, + paymentAppId: "bitkit", paymentEndpointIdentifier: onchainEndpoint, kind: .onchain, paymentStarted: true, @@ -868,6 +891,7 @@ final class PaykitPaymentProofServiceTests: XCTestCase { let lightningProof = PendingPaykitPaymentProof( identity: identity, requestId: lightningRequest.id, + paymentAppId: "bitkit", paymentEndpointIdentifier: lightningEndpoint, kind: .lightning, paymentStarted: true, @@ -910,7 +934,7 @@ final class PaykitPaymentProofServiceTests: XCTestCase { let store = PaymentProofMemoryStore() let service = paymentProofService(sdk: PaymentProofSdkMock(identity: identity, records: [record]), store: store) - try await service.prepare(request: request, paymentEndpointIdentifier: endpoint, kind: .onchain) + try await service.prepare(request: request, paymentAppId: "bitkit", paymentEndpointIdentifier: endpoint, kind: .onchain) try await service.markOnchainPaymentStarted(request, address: onchainAddress) await service.failOnchainPayment(request) @@ -925,7 +949,7 @@ final class PaykitPaymentProofServiceTests: XCTestCase { let store = PaymentProofMemoryStore() let sdk = PaymentProofSdkMock(identity: identity, records: [record]) let service = paymentProofService(sdk: sdk, store: store) - try await service.prepare(request: request, paymentEndpointIdentifier: endpoint, kind: .onchain) + try await service.prepare(request: request, paymentAppId: "bitkit", paymentEndpointIdentifier: endpoint, kind: .onchain) try await service.markOnchainPaymentStarted(request, address: onchainAddress) await sdk.setIdentityAvailable(false) @@ -942,7 +966,7 @@ final class PaykitPaymentProofServiceTests: XCTestCase { let store = PaymentProofMemoryStore() let sdk = PaymentProofSdkMock(identity: identity, records: [record]) let service = paymentProofService(sdk: sdk, store: store) - try await service.prepare(request: request, paymentEndpointIdentifier: endpoint, kind: .lightning) + try await service.prepare(request: request, paymentAppId: "bitkit", paymentEndpointIdentifier: endpoint, kind: .lightning) await sdk.setIdentityAvailable(false) await service.cancelPreparation(request) @@ -971,6 +995,7 @@ final class PaykitPaymentProofServiceTests: XCTestCase { try await service.prepare( request: request, + paymentAppId: "bitkit", paymentEndpointIdentifier: PublicPaykitService.MethodId.bitcoinLightningBolt11.rawValue, kind: .lightning ) @@ -1011,6 +1036,7 @@ final class PaykitPaymentProofServiceTests: XCTestCase { try await service.prepare( request: request, + paymentAppId: "bitkit", paymentEndpointIdentifier: PublicPaykitService.MethodId.bitcoinLightningBolt11.rawValue, kind: .lightning ) @@ -1032,6 +1058,7 @@ final class PaykitPaymentProofServiceTests: XCTestCase { try await service.prepare( request: request, + paymentAppId: "bitkit", paymentEndpointIdentifier: PublicPaykitService.MethodId.bitcoinLightningBolt11.rawValue, kind: .lightning ) @@ -1039,6 +1066,7 @@ final class PaykitPaymentProofServiceTests: XCTestCase { do { try await service.prepare( request: request, + paymentAppId: "bitkit", paymentEndpointIdentifier: PublicPaykitService.MethodId.bitcoinLightningBolt11.rawValue, kind: .lightning ) @@ -1054,6 +1082,7 @@ final class PaykitPaymentProofServiceTests: XCTestCase { await service.failLightningPayment(paymentHash: paymentHash) try await service.prepare( request: request, + paymentAppId: "bitkit", paymentEndpointIdentifier: PublicPaykitService.MethodId.bitcoinLightningBolt11.rawValue, kind: .lightning ) @@ -1073,12 +1102,14 @@ final class PaykitPaymentProofServiceTests: XCTestCase { try await service.prepare( request: firstRequest, + paymentAppId: "bitkit", paymentEndpointIdentifier: PublicPaykitService.MethodId.bitcoinLightningBolt11.rawValue, kind: .lightning ) await store.clear() try await service.prepare( request: secondRequest, + paymentAppId: "bitkit", paymentEndpointIdentifier: PublicPaykitService.MethodId.bitcoinLightningBolt11.rawValue, kind: .lightning ) @@ -1101,12 +1132,13 @@ final class PaykitPaymentProofServiceTests: XCTestCase { let sdk = PaymentProofSdkMock(identity: identity, records: [record]) let service = paymentProofService(sdk: sdk, store: store) - try await service.prepare(request: request, paymentEndpointIdentifier: endpoint, kind: .onchain) + try await service.prepare(request: request, paymentAppId: "bitkit", paymentEndpointIdentifier: endpoint, kind: .onchain) try await service.markOnchainPaymentStarted(request, address: onchainAddress) await store.failNextSave() await service.completeOnchainPayment( request, txid: String(repeating: "ab", count: 32), + paymentAppId: "bitkit", paymentEndpointIdentifier: endpoint ) await sdk.waitForSubmissionStart() @@ -1127,11 +1159,11 @@ final class PaykitPaymentProofServiceTests: XCTestCase { let service = paymentProofService(sdk: sdk, store: store) let txid = String(repeating: "ab", count: 32) - try await service.prepare(request: request, paymentEndpointIdentifier: endpoint, kind: .onchain) + try await service.prepare(request: request, paymentAppId: "bitkit", paymentEndpointIdentifier: endpoint, kind: .onchain) try await service.markOnchainPaymentStarted(request, address: onchainAddress) await store.failNextSave() await sdk.setSubmissionFailure(true) - await service.completeOnchainPayment(request, txid: txid, paymentEndpointIdentifier: endpoint) + await service.completeOnchainPayment(request, txid: txid, paymentAppId: "bitkit", paymentEndpointIdentifier: endpoint) await sdk.waitForSubmissionStart() let storedProofs = await store.snapshot() @@ -1155,10 +1187,10 @@ final class PaykitPaymentProofServiceTests: XCTestCase { let service = paymentProofService(sdk: sdk, store: store) let txid = String(repeating: "ab", count: 32) - try await service.prepare(request: request, paymentEndpointIdentifier: endpoint, kind: .onchain) + try await service.prepare(request: request, paymentAppId: "bitkit", paymentEndpointIdentifier: endpoint, kind: .onchain) try await service.markOnchainPaymentStarted(request, address: onchainAddress) await store.failNextLoad() - await service.completeOnchainPayment(request, txid: txid, paymentEndpointIdentifier: endpoint) + await service.completeOnchainPayment(request, txid: txid, paymentAppId: "bitkit", paymentEndpointIdentifier: endpoint) await sdk.waitForSubmissionStart() await fulfillment(of: [proofRemoved], timeout: 1) @@ -1215,7 +1247,6 @@ final class PaykitPaymentProofServiceTests: XCTestCase { ) throws -> PaymentRequestRecord { try PaymentRequestRecord( counterparty: counterparty, - counterpartyReceiverPath: PaykitReceiverPath.wallet, paymentRequestId: paymentRequestId, localRole: .payer, state: state, @@ -1223,12 +1254,17 @@ final class PaykitPaymentProofServiceTests: XCTestCase { proposalOutboundMessageId: nil, proposalOutboundStatus: nil, proposalEventId: "650e8400-e29b-41d4-a716-446655440000", + proposalAppId: "bitkit", + payerAppId: nil, + executionClaimAppId: nil, terms: PaymentRequestTerms( amount: PaymentRequestAmount(value: "0.00001", asset: "btc"), paymentReference: PaymentReference(text: "invoice-123"), proposalExpiresAt: nil, recurrence: recurrence, acceptedPaymentEndpointIdentifiers: endpoints, + paymentEndpoints: nil, + requiredAppId: "bitkit", conversion: nil, paymentDeadline: nil, metadata: PrivateJsonObject(text: "{}") @@ -1262,6 +1298,7 @@ final class PaykitPaymentProofServiceTests: XCTestCase { streamItemId: 2, paymentReference: PaymentReference(text: "invoice-123"), billingPeriod: billingPeriod, + paymentAppId: "bitkit", paymentEndpointIdentifier: endpoint, allowanceId: nil, conversionQuoteId: nil, @@ -1373,7 +1410,7 @@ private actor PaymentProofSdkMock: PaykitPaymentProofSdkHandling { func identityStatus() -> IdentityStatus? { identityStatusCalls += 1 guard isIdentityAvailable else { return nil } - return IdentityStatus(publicKey: identity, liveSessionAvailable: true) + return IdentityStatus(publicKey: identity, capability: .privateLinkCapable) } func setIdentityAvailable(_ isAvailable: Bool) { @@ -1416,7 +1453,6 @@ private actor PaymentProofSdkMock: PaykitPaymentProofSdkHandling { func submitPaymentProof( counterparty: String, - counterpartyReceiverPath: String, paymentRequestId: String, proof: PaymentProofSubmission ) async throws -> PaymentRequestRecord { @@ -1434,7 +1470,6 @@ private actor PaymentProofSdkMock: PaykitPaymentProofSdkHandling { guard let index = records.firstIndex(where: { $0.counterparty == counterparty && - $0.counterpartyReceiverPath == counterpartyReceiverPath && $0.paymentRequestId == paymentRequestId }), let paymentReference = records[index].terms?.paymentReference else { throw PaymentProofSdkMockError.requestMissing @@ -1446,6 +1481,7 @@ private actor PaymentProofSdkMock: PaykitPaymentProofSdkHandling { streamItemId: nil, paymentReference: paymentReference, billingPeriod: proof.billingPeriod, + paymentAppId: proof.paymentAppId, paymentEndpointIdentifier: proof.paymentEndpointIdentifier, allowanceId: nil, conversionQuoteId: nil, diff --git a/BitkitTests/PaykitPaymentRequestServiceTests.swift b/BitkitTests/PaykitPaymentRequestServiceTests.swift index 020f678ba..3422217e6 100644 --- a/BitkitTests/PaykitPaymentRequestServiceTests.swift +++ b/BitkitTests/PaykitPaymentRequestServiceTests.swift @@ -1,6 +1,7 @@ @testable import Bitkit import BitkitCore import Foundation +import LDKNode import Paykit import UIKit import UserNotifications @@ -8,6 +9,307 @@ import XCTest @MainActor final class PaykitPaymentRequestServiceTests: XCTestCase { + func testReceivedPaymentContactsIncludeSharedServerRequestsAndLatePayments() throws { + let payer = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" + var record = try receivedPaymentRecord(counterparty: payer) + record.proposalAppId = "paykit-server" + record.state = .canceled + let contacts = PaykitReceivedPaymentContacts(records: [record], network: .regtest) + + XCTAssertEqual(contacts.contact(onchainAddresses: ["bcrt1qfn50lqawrce0evh66qrnlt8j447lwmeyqp5gmd"]), payer) + XCTAssertNil(contacts.contact(onchainAddresses: ["bcrt1qnewlist"])) + } + + func testReceivedPaymentContactsRejectUnusableRequests() throws { + let valid = try receivedPaymentRecord() + var payer = valid + payer.localRole = .payer + var invalid = valid + invalid.state = .invalidConflict + var invalidReason = valid + invalidReason.invalidReason = "conflicting terms" + var unknownRole = valid + unknownRole.localRole = nil + var noTerms = valid + noTerms.terms = nil + var malformed = valid + malformed.terms?.paymentEndpoints = ["btc-regtest-p2wpkh": "not JSON"] + var wrongAsset = valid + wrongAsset.terms?.amount.asset = "usd" + var wrongNetwork = valid + wrongNetwork.terms?.acceptedPaymentEndpointIdentifiers = ["btc-bitcoin-p2wpkh"] + wrongNetwork.terms?.paymentEndpoints = ["btc-bitcoin-p2wpkh": #"{"value":"bcrt1qfn50lqawrce0evh66qrnlt8j447lwmeyqp5gmd"}"#] + var badKey = valid + badKey.counterparty = "invalid" + var unaccepted = valid + unaccepted.terms?.acceptedPaymentEndpointIdentifiers = [] + + let contacts = PaykitReceivedPaymentContacts( + records: [payer, invalid, invalidReason, unknownRole, noTerms, malformed, wrongAsset, wrongNetwork, badKey, unaccepted], + network: .regtest + ) + XCTAssertTrue(contacts.isEmpty) + } + + func testReceivedPaymentContactsRejectAmbiguousAddressesAndTransactions() throws { + let first = try receivedPaymentRecord() + var sameContact = first + sameContact.counterparty = String(first.counterparty.dropFirst(5)) + var other = try receivedPaymentRecord(counterparty: "pubky7don8zi885feihpjsyx7t53srod6z1n4xjiyaaxucpqarm6sh85o") + let repeated = PaykitReceivedPaymentContacts(records: [first, sameContact], network: .regtest) + XCTAssertEqual(repeated.contact(onchainAddresses: ["bcrt1qfn50lqawrce0evh66qrnlt8j447lwmeyqp5gmd"]), first.counterparty) + let reused = PaykitReceivedPaymentContacts(records: [first, other], network: .regtest) + XCTAssertNil(reused.contact(onchainAddresses: ["bcrt1qfn50lqawrce0evh66qrnlt8j447lwmeyqp5gmd"])) + + other.terms?.paymentEndpoints = ["btc-regtest-p2wpkh": #"{"value":"bcrt1qpsps9chsjnnd3veems9phzlvw42em682rsj8hh"}"#] + let batched = PaykitReceivedPaymentContacts(records: [first, other], network: .regtest) + XCTAssertNil(batched.contact(onchainAddresses: [ + "bcrt1qfn50lqawrce0evh66qrnlt8j447lwmeyqp5gmd", + "bcrt1qpsps9chsjnnd3veems9phzlvw42em682rsj8hh", + ])) + } + + func testReceivedPaymentContactsMatchExactLightningInvoiceOnCorrectNetwork() throws { + let invoice = "lnbcrt200n1p5hn4c8dqqnp4qwrgh4a03djj2sl34465uwnxhva0gtpjm4u8kvzgc5jergrkm9syypp55lwcgfpkdwuknmekjgted72n0ddl5qtaha7knk7c9n7yrjr4auassp5jgqw0a9w33e2ta4j7gyjrvsvu0lv844w895305nd8spnknq3f2hq9qyysgqcqzp2xqyz5vqrzjq29gjy9sqjrrp48tz7hj2e5vm4l2dukc4csf2mn6qm32u3hted5leapyqqqqqqqtcsqqqqlgqqqqqqgq2qd2gk64eg2kfxtdaryrlh98hvu97jdaxz2ma7aeyuy2uy9vkn9x5qft47p9taju297xnrehva20xcfml7wacuv737xv3xjjzyrtplcxqpfpu9dt" + var record = try receivedPaymentRecord() + record.terms?.acceptedPaymentEndpointIdentifiers = ["btc-lightning-bolt11"] + record.terms?.paymentEndpoints = try ["btc-lightning-bolt11": PublicPaykitService.serializePayload(value: invoice)] + let contacts = PaykitReceivedPaymentContacts(records: [record], network: .regtest) + let paymentHash = try Bolt11Invoice.fromStr(invoiceStr: invoice).paymentHash() + XCTAssertEqual(contacts.contact(paymentHash: paymentHash.uppercased()), record.counterparty) + XCTAssertNil(contacts.contact(paymentHash: String(repeating: "0", count: 64))) + let payment = LightningActivity( + walletId: WalletScope.default, id: paymentHash, txType: .received, status: .succeeded, + value: 20, fee: nil, invoice: "No invoice", message: "", timestamp: 123, preimage: nil, + contact: nil, createdAt: nil, updatedAt: nil, seenAt: nil + ) + guard case let .lightning(updated) = contacts.attributing(.lightning(payment)) else { + return XCTFail("Expected contact from the exact payment hash") + } + XCTAssertEqual(updated.contact, record.counterparty) + XCTAssertTrue(PaykitReceivedPaymentContacts(records: [record], network: .bitcoin).isEmpty) + record.terms?.paymentEndpoints = ["btc-lightning-bolt11": #"{"value":"lnbcrt1invalid"}"#] + XCTAssertTrue(PaykitReceivedPaymentContacts(records: [record], network: .regtest).isEmpty) + } + + func testReceivedPaymentContactsUseEndpointNetworkForSignetAddresses() throws { + let address = "tb1qw508d6qejxtdg4y5r3zarvary0c5xw7kxpjzsx" + var record = try receivedPaymentRecord() + record.terms?.acceptedPaymentEndpointIdentifiers = ["btc-signet-p2wpkh"] + record.terms?.paymentEndpoints = try ["btc-signet-p2wpkh": PublicPaykitService.serializePayload(value: address)] + XCTAssertEqual( + PaykitReceivedPaymentContacts(records: [record], network: .signet).contact(onchainAddresses: [address]), record.counterparty + ) + XCTAssertTrue(PaykitReceivedPaymentContacts(records: [record], network: .bitcoin).isEmpty) + XCTAssertTrue(PaykitReceivedPaymentContacts(records: [record], network: .testnet).isEmpty) + } + + func testReceivedPaymentAttributionPreservesExistingContactsAndMetadata() throws { + let record = try receivedPaymentRecord() + let contacts = PaykitReceivedPaymentContacts(records: [record], network: .regtest) + var payment = OnchainActivity( + walletId: WalletScope.default, id: "received", txType: .received, txId: "tx", value: 15000, + fee: 0, feeRate: 0, address: "bcrt1qfn50lqawrce0evh66qrnlt8j447lwmeyqp5gmd", confirmed: true, timestamp: 123, + isBoosted: false, boostTxIds: [], isTransfer: false, doesExist: true, confirmTimestamp: 124, + channelId: nil, transferTxId: nil, contact: nil, createdAt: 123, updatedAt: 124, seenAt: 125 + ) + XCTAssertNil(contacts.attributing(.onchain(payment)), "Wait for complete transaction outputs") + guard case let .onchain(updated) = contacts.attributing(.onchain(payment), outputAddresses: [payment.address]) else { + return XCTFail("Expected received contact attribution") + } + XCTAssertEqual(updated.contact, record.counterparty) + XCTAssertEqual(updated.value, payment.value) + XCTAssertEqual(updated.seenAt, payment.seenAt) + XCTAssertEqual(updated.confirmTimestamp, payment.confirmTimestamp) + payment.contact = "already assigned" + XCTAssertNil(contacts.attributing(.onchain(payment), outputAddresses: [payment.address])) + payment.contact = nil + payment.txType = .sent + XCTAssertNil(contacts.attributing(.onchain(payment), outputAddresses: [payment.address])) + } + + func testReceivedPaymentContactsRefreshAndClearWithIdentity() async throws { + var record = try receivedPaymentRecord() + record.proposalAppId = "paykit-server" + let sdk = PaymentRequestSdkMock(records: []) + let manager = paymentRequestManager(sdk: sdk) + manager.activate(identity: "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg") + await manager.refresh() + XCTAssertTrue(manager.receivedPaymentContacts.isEmpty) + await sdk.setRecords([record]) + let visibleRequests = await sdk.paymentRequests() + XCTAssertTrue(visibleRequests.isEmpty) + await manager.refresh() + XCTAssertEqual( + manager.receivedPaymentContacts.contact(onchainAddresses: ["bcrt1qfn50lqawrce0evh66qrnlt8j447lwmeyqp5gmd"]), + record.counterparty + ) + XCTAssertTrue(manager.historyRequests.isEmpty) + manager.activate(identity: "pubky7don8zi885feihpjsyx7t53srod6z1n4xjiyaaxucpqarm6sh85o") + XCTAssertTrue(manager.receivedPaymentContacts.isEmpty) + } + + private func receivedPaymentRecord( + counterparty: String = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" + ) throws -> PaymentRequestRecord { + try paymentRequestRecord( + counterparty: counterparty, role: .payee, + endpoints: ["btc-regtest-p2wpkh"], paymentEndpoints: ["btc-regtest-p2wpkh": #"{"value":"bcrt1qfn50lqawrce0evh66qrnlt8j447lwmeyqp5gmd"}"#] + ) + } + + func testRequestEndpointsKeepSeparateAddressesWithoutChangingContactListState() async throws { + snapshotAppDefaultsDomain() + UserDefaults.standard.removeObject(forKey: PrivatePaykitService.cacheStateKey) + let service = PrivatePaykitService() + let publicKey = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" + let method = PublicPaykitService.onchainMethodId(for: "bcrt1qinvoice") + let addresses = ["invoice-a": "bcrt1qinvoicea", "invoice-b": "bcrt1qinvoiceb"] + try await service.consumePrivatePaymentList( + publicKey: publicKey, + context: PrivatePaykitPaymentContext(paymentAppsByEndpoint: [:], paymentListVersion: 7), + attemptId: UUID() + ) + + for (index, id) in ["invoice-a", "invoice-b", "invoice-a"].enumerated() { + let address = try XCTUnwrap(addresses[id]) + let endpointData = try PublicPaykitService.serializePayload(value: address) + let record = try paymentRequestRecord( + id: id, + counterparty: publicKey, + endpoints: [method.rawValue], + paymentEndpoints: [method.rawValue: endpointData] + ) + let request = try XCTUnwrap(PaykitPaymentRequest(record: record, now: Date())) + let latest = try PublicPaykitService.Endpoint( + methodId: method, + value: "current-list-target-\(index)", + min: nil, + max: nil, + rawPayload: PublicPaykitService.serializePayload(value: "current-list-target-\(index)") + ) + await service.cacheResolvedEndpoints([latest], publicKey: publicKey) + let resolution = try boundResolution(publicKey: publicKey, method: method, payload: endpointData) + let result = await service.privatePaymentResult( + publicKey: publicKey, + paymentRequest: request, + resolution: resolution, + validateEndpoints: { endpoints in + XCTAssertEqual(endpoints.map(\.value), [address]) + return endpoints + } + ) + guard case let .opened(target, context) = result else { return XCTFail("Expected the bound invoice") } + XCTAssertEqual(target, address) + let paymentContext = try XCTUnwrap(context) + XCTAssertNil(paymentContext.paymentListVersion) + XCTAssertEqual(try paymentContext.paymentAppId(for: method.rawValue), "bitkit") + try await service.consumePrivatePaymentList(publicKey: publicKey, context: paymentContext, attemptId: UUID()) + let contact = await service.state.contacts[publicKey] + XCTAssertEqual(contact?.consumedPrivatePaymentListVersion, 7) + XCTAssertEqual(contact?.cachedResolvedEndpoints.map(\.endpointData), [latest.rawPayload]) + } + } + + func testRequestEndpointUsesOnlyPayableResolvedCandidates() async throws { + snapshotAppDefaultsDomain() + UserDefaults.standard.removeObject(forKey: PrivatePaykitService.cacheStateKey) + let service = PrivatePaykitService() + let method = PublicPaykitService.onchainMethodId(for: "bcrt1qinvoice") + let payload = try PublicPaykitService.serializePayload(value: "bcrt1qinvoice") + let record = try paymentRequestRecord(endpoints: [method.rawValue], paymentEndpoints: [method.rawValue: payload]) + let request = try XCTUnwrap(PaykitPaymentRequest(record: record, now: Date())) + var resolution = try boundResolution(publicKey: request.counterparty, method: method, payload: payload) + let rejected = await service.privatePaymentResult( + publicKey: request.counterparty, + paymentRequest: request, + resolution: resolution, + validateEndpoints: { _ in [] } + ) + guard case .notOpened = rejected else { return XCTFail("Wallet validation must reject the target") } + let generic = await service.privatePaymentResult( + publicKey: request.counterparty, + paymentRequest: nil, + resolution: resolution, + validateEndpoints: { $0 } + ) + guard case .notOpened = generic else { return XCTFail("Contact payments require a list version") } + resolution.payableEndpoints = [] + let missing = await service.privatePaymentResult( + publicKey: request.counterparty, + paymentRequest: request, + resolution: resolution, + validateEndpoints: { $0 } + ) + guard case .noEndpoint = missing else { return XCTFail("Missing bound endpoints must not fall back") } + } + + func testRequestEndpointCannotBypassExecutionClaimRejection() async throws { + let method = PublicPaykitService.onchainMethodId(for: "bcrt1qinvoice") + var record = try paymentRequestRecord( + endpoints: [method.rawValue], + paymentEndpoints: [method.rawValue: PublicPaykitService.serializePayload(value: "bcrt1qinvoice")] + ) + let sdk = PaymentRequestSdkMock(records: [record]) + let manager = paymentRequestManager(sdk: sdk) + await manager.refresh() + let request = try XCTUnwrap(manager.pendingRequests.first) + record.executionClaimAppId = "other-app" + await sdk.setRecords([record]) + var consumed = false + + do { + try await manager.prepareForPayment(request) { consumed = true } + XCTFail("Expected execution claim rejection") + } catch { + XCTAssertEqual(error as? PaymentRequestSdkMockError, .requestMissing) + } + + XCTAssertFalse(consumed) + XCTAssertFalse(manager.isApprovedForPayment(request)) + let snapshot = await sdk.snapshot() + XCTAssertTrue(snapshot.acceptedRequests.isEmpty) + } + + func testReleasedRequestCanBeHandledWithoutTakingAnotherAppsClaim() throws { + var record = try paymentRequestRecord() + record.payerAppId = "other-app" + for state in [Paykit.PaymentRequestLifecycleState.accepted, .activeRecurring] { + record.state = state + record.executionClaimAppId = nil + XCTAssertTrue(PaykitSdkService.isBitkitPaymentRequest(record)) + record.executionClaimAppId = "other-app" + XCTAssertFalse(PaykitSdkService.isBitkitPaymentRequest(record)) + record.executionClaimAppId = "bitkit" + XCTAssertTrue(PaykitSdkService.isBitkitPaymentRequest(record)) + } + record.executionClaimAppId = nil + record.state = .rejected + XCTAssertFalse(PaykitSdkService.isBitkitPaymentRequest(record)) + record.payerAppId = "bitkit" + XCTAssertTrue(PaykitSdkService.isBitkitPaymentRequest(record)) + } + + private func boundResolution( + publicKey: String, + method: PublicPaykitService.MethodId, + payload: String + ) throws -> PrivateContactPaymentResolution { + let payload = PaymentPayload(text: payload) + return PrivateContactPaymentResolution( + status: .payable, + state: .available, + privatePaymentListVersion: nil, + payableEndpoints: [ResolvedPrivatePaymentEndpoint( + counterparty: publicKey, + appId: "bitkit", + identifier: method.rawValue, + payload: payload, + target: PaymentTarget(payload: payload) + )] + ) + } + func testPaymentRequestErrorsHaveUserFacingDescriptions() { let errors: [PaykitPaymentRequestError] = [ .requestUnavailable, @@ -30,7 +332,6 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { "payer_identity": payerIdentity, "payment_request_id": "subscription-id", "counterparty": counterparty, - "counterparty_receiver_path": "bitkit/server", "billing_period_starts_at": "2026-08-25T12:00:00Z", ] @@ -69,7 +370,6 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { let requestId = PaykitPaymentRequest.ID( paymentRequestId: "subscription", counterparty: "pubkypayee", - counterpartyReceiverPath: PaykitReceiverPath.server, billingPeriodStartsAt: startsAt ) @@ -204,8 +504,8 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { await manager.refresh() XCTAssertEqual(manager.pendingRequests.count, 1) await sdk.configureRecipients( - peers: [linkedPeer(counterparty: record.counterparty, path: record.counterpartyReceiverPath, state: .blocked)], - receiverPathsByPublicKey: [:] + peers: [linkedPeer(counterparty: record.counterparty, state: .blocked)], + requestCapabilitiesByPublicKey: [:] ) await manager.refresh() XCTAssertTrue(manager.pendingRequests.isEmpty) @@ -225,8 +525,8 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { await manager.refresh() let request = try XCTUnwrap(manager.pendingRequests.first) await sdk.configureRecipients( - peers: [linkedPeer(counterparty: record.counterparty, path: record.counterpartyReceiverPath, state: .blocked)], - receiverPathsByPublicKey: [:] + peers: [linkedPeer(counterparty: record.counterparty, state: .blocked)], + requestCapabilitiesByPublicKey: [:] ) var consumed = false do { @@ -252,8 +552,8 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { let accepted = await sdk.snapshot().acceptedRequests XCTAssertEqual(accepted.count, 1) await sdk.configureRecipients( - peers: [linkedPeer(counterparty: record.counterparty, path: record.counterpartyReceiverPath, state: .blocked)], - receiverPathsByPublicKey: [:] + peers: [linkedPeer(counterparty: record.counterparty, state: .blocked)], + requestCapabilitiesByPublicKey: [:] ) var lightningSendCalls = 0 var lnurlSendCalls = 0 @@ -485,9 +785,9 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { XCTAssertEqual(recorder.messages, firstMessages) XCTAssertTrue(output.contains("category=parse reason=unsupported_asset")) XCTAssertTrue(output.contains("category=parse reason=no_supported_endpoint")) - XCTAssertTrue(output.contains("category=parse reason=unsupported_local_role")) + XCTAssertFalse(output.contains("category=parse reason=unsupported_local_role")) XCTAssertTrue(output.contains("counterparty=\(PaykitPaymentRequestDiagnostics.redactedCounterparty(counterparty))")) - XCTAssertTrue(output.contains("counterparty=\(PaykitPaymentRequestDiagnostics.redactedCounterparty(unknownRoleCounterparty))")) + XCTAssertFalse(output.contains("counterparty=\(PaykitPaymentRequestDiagnostics.redactedCounterparty(unknownRoleCounterparty))")) XCTAssertFalse(output.contains(PaykitPaymentRequestDiagnostics.redactedCounterparty(outgoingCounterparty))) XCTAssertTrue(output.contains("counterparty=")) XCTAssertFalse(output.contains(counterparty)) @@ -757,13 +1057,12 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { let publicKey = "pubky\(String(repeating: "y", count: 52))" let sdk = PaymentRequestSdkMock(records: []) await sdk.configureRecipients( - peers: [linkedPeer(counterparty: publicKey, path: PaykitReceiverPath.wallet, state: .linked)], - receiverPathsByPublicKey: [publicKey: [PaykitReceiverPath.wallet]] + peers: [linkedPeer(counterparty: publicKey, state: .linked)], + requestCapabilitiesByPublicKey: [publicKey: true] ) try await sdk.setProposalResult(paymentRequestRecord( id: "creator-proposal", counterparty: publicKey, - counterpartyReceiverPath: PaykitReceiverPath.wallet, role: .payee )) let manager = paymentRequestManager(sdk: sdk, clock: PaymentRequestTestClock(now)) @@ -809,8 +1108,8 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { for change in ["reordered", "removed", "added", "cleared", "identity"] { let sdk = PaymentRequestSdkMock(records: []) await sdk.configureRecipients( - peers: [linkedPeer(counterparty: publicKey, path: PaykitReceiverPath.wallet, state: .linked)], - receiverPathsByPublicKey: [publicKey: [PaykitReceiverPath.wallet]] + peers: [linkedPeer(counterparty: publicKey, state: .linked)], + requestCapabilitiesByPublicKey: [publicKey: true] ) try await sdk.setProposalResult(paymentRequestRecord(role: .payee)) let manager = paymentRequestManager(sdk: sdk, clock: PaymentRequestTestClock(now)) @@ -860,8 +1159,8 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { let clock = PaymentRequestTestClock(now) let sdk = PaymentRequestSdkMock(records: []) await sdk.configureRecipients( - peers: [linkedPeer(counterparty: publicKey, path: PaykitReceiverPath.wallet, state: .linked)], - receiverPathsByPublicKey: [publicKey: [PaykitReceiverPath.wallet]] + peers: [linkedPeer(counterparty: publicKey, state: .linked)], + requestCapabilitiesByPublicKey: [publicKey: true] ) try await sdk.setProposalResult(paymentRequestRecord(role: .payee)) let manager = paymentRequestManager(sdk: sdk, clock: clock) @@ -881,11 +1180,11 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { switch change { case "expired": clock.advance(by: 60) case "removed": await manager.refreshEligibleTargets(savedPublicKeys: []) - case "unlinked": await sdk.configureRecipients(peers: [], receiverPathsByPublicKey: [publicKey: [PaykitReceiverPath.wallet]]) + case "unlinked": await sdk.configureRecipients(peers: [], requestCapabilitiesByPublicKey: [publicKey: true]) case "unsupported": await sdk.configureRecipients( - peers: [linkedPeer(counterparty: publicKey, path: PaykitReceiverPath.wallet, state: .linked)], - receiverPathsByPublicKey: [:] + peers: [linkedPeer(counterparty: publicKey, state: .linked)], + requestCapabilitiesByPublicKey: [:] ) case "endpoint": UserDefaults.standard.set(false, forKey: optionKey) case "cleared": manager.clear() @@ -912,8 +1211,8 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { let publicKey = "pubky\(String(repeating: "y", count: 52))" let sdk = PaymentRequestSdkMock(records: []) await sdk.configureRecipients( - peers: [linkedPeer(counterparty: publicKey, path: PaykitReceiverPath.wallet, state: .linked)], - receiverPathsByPublicKey: [publicKey: [PaykitReceiverPath.wallet]] + peers: [linkedPeer(counterparty: publicKey, state: .linked)], + requestCapabilitiesByPublicKey: [publicKey: true] ) let manager = paymentRequestManager(sdk: sdk, clock: PaymentRequestTestClock(now)) await manager.refreshEligibleTargets(savedPublicKeys: [publicKey]) @@ -1081,7 +1380,6 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { let second = try paymentRequestRecord( id: "shared", counterparty: "second", - counterpartyReceiverPath: PaykitReceiverPath.wallet, recurrence: recurrence ) let manager = paymentRequestManager( @@ -1095,7 +1393,6 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { let dueRequest = try XCTUnwrap(acceptedRequest) XCTAssertEqual(dueRequest.counterparty, "second") - XCTAssertEqual(dueRequest.counterpartyReceiverPath, PaykitReceiverPath.wallet) } func testAcceptingSubscriptionRejectsTermsChangedAfterReview() async throws { @@ -2027,7 +2324,6 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { await sdk.setReceiveReports([ PrivateStreamCounterpartyIntakeReport( counterparty: record.counterparty, - counterpartyReceiverPath: record.counterpartyReceiverPath, report: nil, error: PaymentRequestIntakeError(noPointer: .init()) ), @@ -2818,7 +3114,7 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { await manager.refresh() let request = try XCTUnwrap(manager.pendingRequests.first) let app = AppViewModel() - let privateContext = PrivatePaykitPaymentContext(receiverPath: request.counterpartyReceiverPath, paymentListVersion: 7) + let privateContext = PrivatePaykitPaymentContext(paymentAppsByEndpoint: [endpoint: "bitkit"], paymentListVersion: 7) let context = ContactPaymentContext( publicKey: counterparty, privatePaymentContext: privateContext, @@ -2834,7 +3130,7 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { logInfo: { _ in }, logWarning: { _ in } ) - try await proofService.prepare(request: request, paymentEndpointIdentifier: endpoint, kind: .onchain) + try await proofService.prepare(request: request, paymentAppId: "bitkit", paymentEndpointIdentifier: endpoint, kind: .onchain) try await manager.prepareForPayment(request) { try await privateService.consumePrivatePaymentList(publicKey: counterparty, context: privateContext, attemptId: context.id) } @@ -2874,7 +3170,7 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { ) let persistedVersion = await PrivatePaykitService().state.contacts[counterparty]? - .consumedPrivatePaymentListVersionsByReceiverPath[privateContext.receiverPath] + .consumedPrivatePaymentListVersion let remainingProofs = try await proofStore.load() if testCase.outcome == .uncertain { XCTAssertEqual(persistedVersion, privateContext.paymentListVersion) @@ -2886,7 +3182,7 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { } catch PrivatePaykitError.paymentListAlreadyConsumed {} let restartedProofService = PaykitPaymentProofService(sdk: sdk, store: proofStore, logInfo: { _ in }, logWarning: { _ in }) do { - try await restartedProofService.prepare(request: request, paymentEndpointIdentifier: endpoint, kind: .onchain) + try await restartedProofService.prepare(request: request, paymentAppId: "bitkit", paymentEndpointIdentifier: endpoint, kind: .onchain) XCTFail("An uncertain started proof must prevent a new preparation") } catch PaykitPaymentRequestError.operationInProgress {} } else { @@ -2900,7 +3196,7 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { XCTAssertEqual(retriedContext.privatePaymentContext, context.privatePaymentContext) XCTAssertEqual(retriedContext.isInitialSubscriptionPayment, context.isInitialSubscriptionPayment) XCTAssertEqual(retriedRequest.lifecycleState, .accepted) - try await proofService.prepare(request: retriedRequest, paymentEndpointIdentifier: endpoint, kind: .onchain) + try await proofService.prepare(request: retriedRequest, paymentAppId: "bitkit", paymentEndpointIdentifier: endpoint, kind: .onchain) try await manager.prepareForPayment(retriedRequest) { try await privateService.consumePrivatePaymentList( publicKey: counterparty, context: privateContext, attemptId: retriedContext.id @@ -3206,26 +3502,20 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { func testAcceptQueuesResponseAndRemovesRequest() async throws { let sharedId = "550e8400-e29b-41d4-a716-446655440000" let firstRecord = try paymentRequestRecord(id: sharedId) - let secondRecord = try paymentRequestRecord( - id: sharedId, - counterpartyReceiverPath: PaykitReceiverPath.wallet - ) let thirdRecord = try paymentRequestRecord(id: sharedId, counterparty: "pubkyother") let fourthRecord = try paymentRequestRecord(id: "650e8400-e29b-41d4-a716-446655440000") - let remainingIds = [secondRecord, thirdRecord, fourthRecord].map { + let remainingIds = [thirdRecord, fourthRecord].map { PaykitPaymentRequest.ID( paymentRequestId: $0.paymentRequestId, - counterparty: $0.counterparty, - counterpartyReceiverPath: $0.counterpartyReceiverPath + counterparty: $0.counterparty ) } - let sdk = PaymentRequestSdkMock(records: [firstRecord, secondRecord, thirdRecord, fourthRecord]) + let sdk = PaymentRequestSdkMock(records: [firstRecord, thirdRecord, fourthRecord]) let manager = paymentRequestManager(sdk: sdk) await manager.refresh() let request = try XCTUnwrap(manager.pendingRequests.first(where: { $0.paymentRequestId == firstRecord.paymentRequestId && - $0.counterparty == firstRecord.counterparty && - $0.counterpartyReceiverPath == firstRecord.counterpartyReceiverPath + $0.counterparty == firstRecord.counterparty })) try await manager.prepareForPayment(request) @@ -3240,7 +3530,6 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { snapshot.acceptedRequests, [PaymentRequestInvocation( counterparty: request.counterparty, - counterpartyReceiverPath: request.counterpartyReceiverPath, paymentRequestId: request.paymentRequestId )] ) @@ -3419,19 +3708,19 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { XCTAssertEqual(snapshot.processCallCount, 2) } - func testEligibleTargetsRequireSavedLinkedPaymentRequestCapablePath() async { + func testEligibleTargetsRequireSavedLinkedPaymentRequestCapableContact() async { let savedKey = "pubky\(String(repeating: "y", count: 52))" let unsavedKey = "pubky\(String(repeating: "b", count: 52))" let sdk = PaymentRequestSdkMock(records: []) await sdk.configureRecipients( peers: [ - linkedPeer(counterparty: savedKey, path: PaykitReceiverPath.wallet, state: .linking), - linkedPeer(counterparty: savedKey, path: PaykitReceiverPath.server, state: .linked), - linkedPeer(counterparty: unsavedKey, path: PaykitReceiverPath.wallet, state: .linked), + linkedPeer(counterparty: savedKey, state: .linking), + linkedPeer(counterparty: savedKey, state: .linked), + linkedPeer(counterparty: unsavedKey, state: .linked), ], - receiverPathsByPublicKey: [ - savedKey: [PaykitReceiverPath.wallet, PaykitReceiverPath.server], - unsavedKey: [PaykitReceiverPath.wallet], + requestCapabilitiesByPublicKey: [ + savedKey: true, + unsavedKey: true, ] ) let manager = paymentRequestManager(sdk: sdk) @@ -3440,7 +3729,7 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { XCTAssertEqual( manager.eligibleTargets, - [PaykitPaymentRequestTarget(publicKey: savedKey, receiverPath: PaykitReceiverPath.server)] + [PaykitPaymentRequestTarget(publicKey: savedKey)] ) } @@ -3448,8 +3737,8 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { let savedKey = "pubky\(String(repeating: "y", count: 52))" let sdk = PaymentRequestSdkMock(records: []) await sdk.configureRecipients( - peers: [linkedPeer(counterparty: savedKey, path: PaykitReceiverPath.wallet, state: .linked)], - receiverPathsByPublicKey: [savedKey: [PaykitReceiverPath.wallet]] + peers: [linkedPeer(counterparty: savedKey, state: .linked)], + requestCapabilitiesByPublicKey: [savedKey: true] ) await sdk.setLiveSessionAvailable(false) let manager = paymentRequestManager(sdk: sdk) @@ -3463,8 +3752,8 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { let savedKey = "pubky\(String(repeating: "y", count: 52))" let sdk = PaymentRequestSdkMock(records: []) await sdk.configureRecipients( - peers: [linkedPeer(counterparty: savedKey, path: PaykitReceiverPath.wallet, state: .linked)], - receiverPathsByPublicKey: [savedKey: [PaykitReceiverPath.wallet]] + peers: [linkedPeer(counterparty: savedKey, state: .linked)], + requestCapabilitiesByPublicKey: [savedKey: true] ) await sdk.setActiveIdentity("pubky\(String(repeating: "a", count: 52))") let manager = paymentRequestManager(sdk: sdk) @@ -3478,8 +3767,8 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { let savedKey = "pubky\(String(repeating: "y", count: 52))" let sdk = PaymentRequestSdkMock(records: []) await sdk.configureRecipients( - peers: [linkedPeer(counterparty: savedKey, path: PaykitReceiverPath.wallet, state: .linked)], - receiverPathsByPublicKey: [savedKey: [PaykitReceiverPath.wallet]] + peers: [linkedPeer(counterparty: savedKey, state: .linked)], + requestCapabilitiesByPublicKey: [savedKey: true] ) let manager = paymentRequestManager(sdk: sdk, isPrivatePaymentPublishingEnabled: false) @@ -3490,16 +3779,16 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { func testEligibleTargetsKeepPreviousTargetWhenCapabilityLookupFails() async { let savedKey = "pubky\(String(repeating: "y", count: 52))" - let target = PaykitPaymentRequestTarget(publicKey: savedKey, receiverPath: PaykitReceiverPath.wallet) + let target = PaykitPaymentRequestTarget(publicKey: savedKey) let sdk = PaymentRequestSdkMock(records: []) await sdk.configureRecipients( - peers: [linkedPeer(counterparty: savedKey, path: PaykitReceiverPath.wallet, state: .linked)], - receiverPathsByPublicKey: [savedKey: [PaykitReceiverPath.wallet]] + peers: [linkedPeer(counterparty: savedKey, state: .linked)], + requestCapabilitiesByPublicKey: [savedKey: true] ) let manager = paymentRequestManager(sdk: sdk) await manager.refreshEligibleTargets(savedPublicKeys: [savedKey]) - await sdk.setReceiverPathLookupFailing(true, for: savedKey) + await sdk.setCapabilityLookupFailing(true, for: savedKey) await manager.refreshEligibleTargets(savedPublicKeys: [savedKey]) XCTAssertEqual(manager.eligibleTargets, [target]) @@ -3507,11 +3796,11 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { func testEligibleTargetsSurviveLinkedPeerLookupFailure() async { let savedKey = "pubky\(String(repeating: "y", count: 52))" - let target = PaykitPaymentRequestTarget(publicKey: savedKey, receiverPath: PaykitReceiverPath.wallet) + let target = PaykitPaymentRequestTarget(publicKey: savedKey) let sdk = PaymentRequestSdkMock(records: []) await sdk.configureRecipients( - peers: [linkedPeer(counterparty: savedKey, path: PaykitReceiverPath.wallet, state: .linked)], - receiverPathsByPublicKey: [savedKey: [PaykitReceiverPath.wallet]] + peers: [linkedPeer(counterparty: savedKey, state: .linked)], + requestCapabilitiesByPublicKey: [savedKey: true] ) let manager = paymentRequestManager(sdk: sdk) await manager.refreshEligibleTargets(savedPublicKeys: [savedKey]) @@ -3527,29 +3816,29 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { let secondKey = "pubky\(String(repeating: "b", count: 52))" let sdk = PaymentRequestSdkMock(records: []) await sdk.configureRecipients( - peers: [linkedPeer(counterparty: firstKey, path: PaykitReceiverPath.wallet, state: .linked)], - receiverPathsByPublicKey: [firstKey: [PaykitReceiverPath.wallet]] + peers: [linkedPeer(counterparty: firstKey, state: .linked)], + requestCapabilitiesByPublicKey: [firstKey: true] ) let manager = paymentRequestManager(sdk: sdk) await manager.refreshEligibleTargets(savedPublicKeys: [firstKey, secondKey]) await sdk.configureRecipients( peers: [ - linkedPeer(counterparty: firstKey, path: PaykitReceiverPath.wallet, state: .linked), - linkedPeer(counterparty: secondKey, path: PaykitReceiverPath.server, state: .linked), + linkedPeer(counterparty: firstKey, state: .linked), + linkedPeer(counterparty: secondKey, state: .linked), ], - receiverPathsByPublicKey: [ - firstKey: [PaykitReceiverPath.wallet], - secondKey: [PaykitReceiverPath.server], + requestCapabilitiesByPublicKey: [ + firstKey: true, + secondKey: true, ] ) let target = await manager.refreshEligibleTarget(publicKey: secondKey) - let expected = PaykitPaymentRequestTarget(publicKey: secondKey, receiverPath: PaykitReceiverPath.server) + let expected = PaykitPaymentRequestTarget(publicKey: secondKey) XCTAssertEqual(target, expected) XCTAssertEqual( manager.eligibleTargets, - [PaykitPaymentRequestTarget(publicKey: firstKey, receiverPath: PaykitReceiverPath.wallet), expected] + [PaykitPaymentRequestTarget(publicKey: firstKey), expected] ) } @@ -3557,12 +3846,12 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { let savedKey = "pubky\(String(repeating: "y", count: 52))" let sdk = PaymentRequestSdkMock(records: []) await sdk.configureRecipients( - peers: [linkedPeer(counterparty: savedKey, path: PaykitReceiverPath.wallet, state: .linked)], - receiverPathsByPublicKey: [savedKey: [PaykitReceiverPath.wallet]] + peers: [linkedPeer(counterparty: savedKey, state: .linked)], + requestCapabilitiesByPublicKey: [savedKey: true] ) let manager = paymentRequestManager(sdk: sdk) await manager.refreshEligibleTargets(savedPublicKeys: [savedKey]) - await sdk.configureRecipients(peers: [], receiverPathsByPublicKey: [savedKey: [PaykitReceiverPath.wallet]]) + await sdk.configureRecipients(peers: [], requestCapabilitiesByPublicKey: [savedKey: true]) let target = await manager.refreshEligibleTarget(publicKey: savedKey) @@ -3574,8 +3863,8 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { let unsavedKey = "pubky\(String(repeating: "b", count: 52))" let sdk = PaymentRequestSdkMock(records: []) await sdk.configureRecipients( - peers: [linkedPeer(counterparty: unsavedKey, path: PaykitReceiverPath.wallet, state: .linked)], - receiverPathsByPublicKey: [unsavedKey: [PaykitReceiverPath.wallet]] + peers: [linkedPeer(counterparty: unsavedKey, state: .linked)], + requestCapabilitiesByPublicKey: [unsavedKey: true] ) let manager = paymentRequestManager(sdk: sdk) @@ -3589,8 +3878,8 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { let savedKey = "pubky\(String(repeating: "y", count: 52))" let sdk = PaymentRequestSdkMock(records: []) await sdk.configureRecipients( - peers: [linkedPeer(counterparty: savedKey, path: PaykitReceiverPath.wallet, state: .linked)], - receiverPathsByPublicKey: [savedKey: [PaykitReceiverPath.wallet]] + peers: [linkedPeer(counterparty: savedKey, state: .linked)], + requestCapabilitiesByPublicKey: [savedKey: true] ) let manager = paymentRequestManager(sdk: sdk) await sdk.setLinkedPeersError(.linkedPeers) @@ -3606,26 +3895,26 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { await sdk.resumeLinkedPeers() let abandonedTarget = await abandonedRefresh.value XCTAssertNil(abandonedTarget) - let lookups = await sdk.receiverPathLookups() + let lookups = await sdk.capabilityLookups() XCTAssertEqual(lookups, 0) XCTAssertTrue(manager.eligibleTargets.isEmpty) let refreshed = await manager.startEligibleTargetRefresh(publicKey: savedKey).value - XCTAssertEqual(refreshed, PaykitPaymentRequestTarget(publicKey: savedKey, receiverPath: PaykitReceiverPath.wallet)) + XCTAssertEqual(refreshed, PaykitPaymentRequestTarget(publicKey: savedKey)) } func testSingleEligibilityRefreshRemovesContactThatStoppedAcceptingRequests() async { let savedKey = "pubky\(String(repeating: "y", count: 52))" let sdk = PaymentRequestSdkMock(records: []) await sdk.configureRecipients( - peers: [linkedPeer(counterparty: savedKey, path: PaykitReceiverPath.wallet, state: .linked)], - receiverPathsByPublicKey: [savedKey: [PaykitReceiverPath.wallet]] + peers: [linkedPeer(counterparty: savedKey, state: .linked)], + requestCapabilitiesByPublicKey: [savedKey: true] ) let manager = paymentRequestManager(sdk: sdk) await manager.refreshEligibleTargets(savedPublicKeys: [savedKey]) await sdk.configureRecipients( - peers: [linkedPeer(counterparty: savedKey, path: PaykitReceiverPath.wallet, state: .linked)], - receiverPathsByPublicKey: [savedKey: []] + peers: [linkedPeer(counterparty: savedKey, state: .linked)], + requestCapabilitiesByPublicKey: [savedKey: false] ) let target = await manager.refreshEligibleTarget(publicKey: savedKey) @@ -3640,12 +3929,12 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { let sdk = PaymentRequestSdkMock(records: []) await sdk.configureRecipients( peers: [ - linkedPeer(counterparty: keptKey, path: PaykitReceiverPath.wallet, state: .linked), - linkedPeer(counterparty: deletedKey, path: PaykitReceiverPath.wallet, state: .linked), + linkedPeer(counterparty: keptKey, state: .linked), + linkedPeer(counterparty: deletedKey, state: .linked), ], - receiverPathsByPublicKey: [ - keptKey: [PaykitReceiverPath.wallet], - deletedKey: [PaykitReceiverPath.wallet], + requestCapabilitiesByPublicKey: [ + keptKey: true, + deletedKey: true, ] ) let manager = paymentRequestManager(sdk: sdk) @@ -3656,7 +3945,7 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { XCTAssertEqual( manager.eligibleTargets, - [PaykitPaymentRequestTarget(publicKey: keptKey, receiverPath: PaykitReceiverPath.wallet)] + [PaykitPaymentRequestTarget(publicKey: keptKey)] ) } @@ -3664,8 +3953,8 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { let savedKey = "pubky\(String(repeating: "y", count: 52))" let sdk = PaymentRequestSdkMock(records: []) await sdk.configureRecipients( - peers: [linkedPeer(counterparty: savedKey, path: PaykitReceiverPath.wallet, state: .linked)], - receiverPathsByPublicKey: [savedKey: [PaykitReceiverPath.wallet]] + peers: [linkedPeer(counterparty: savedKey, state: .linked)], + requestCapabilitiesByPublicKey: [savedKey: true] ) let manager = paymentRequestManager(sdk: sdk) await manager.refreshEligibleTargets(savedPublicKeys: [savedKey]) @@ -3675,7 +3964,7 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { await manager.refreshEligibleTarget(publicKey: savedKey) } try await waitUntil { await sdk.linkedPeersIsPaused() } - await sdk.configureRecipients(peers: [], receiverPathsByPublicKey: [savedKey: [PaykitReceiverPath.wallet]]) + await sdk.configureRecipients(peers: [], requestCapabilitiesByPublicKey: [savedKey: true]) await manager.refreshEligibleTargets(savedPublicKeys: [savedKey]) await sdk.resumeLinkedPeers() let target = await singleRefresh.value @@ -3688,8 +3977,8 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { let savedKey = "pubky\(String(repeating: "y", count: 52))" let sdk = PaymentRequestSdkMock(records: []) await sdk.configureRecipients( - peers: [linkedPeer(counterparty: savedKey, path: PaykitReceiverPath.wallet, state: .linked)], - receiverPathsByPublicKey: [savedKey: [PaykitReceiverPath.wallet]] + peers: [linkedPeer(counterparty: savedKey, state: .linked)], + requestCapabilitiesByPublicKey: [savedKey: true] ) let manager = paymentRequestManager(sdk: sdk) await manager.refreshEligibleTargets(savedPublicKeys: [savedKey]) @@ -3699,7 +3988,7 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { await manager.refreshEligibleTargets(savedPublicKeys: [savedKey]) } try await waitUntil { await sdk.linkedPeersIsPaused() } - await sdk.configureRecipients(peers: [], receiverPathsByPublicKey: [savedKey: [PaykitReceiverPath.wallet]]) + await sdk.configureRecipients(peers: [], requestCapabilitiesByPublicKey: [savedKey: true]) let target = await manager.refreshEligibleTarget(publicKey: savedKey) await sdk.resumeLinkedPeers() await fullRefresh.value @@ -3713,14 +4002,14 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { let otherKey = "pubky\(String(repeating: "b", count: 52))" let sdk = PaymentRequestSdkMock(records: []) await sdk.configureRecipients( - peers: [linkedPeer(counterparty: refreshedKey, path: PaykitReceiverPath.wallet, state: .linked)], - receiverPathsByPublicKey: [refreshedKey: [PaykitReceiverPath.wallet], otherKey: [PaykitReceiverPath.wallet]] + peers: [linkedPeer(counterparty: refreshedKey, state: .linked)], + requestCapabilitiesByPublicKey: [refreshedKey: true, otherKey: true] ) let manager = paymentRequestManager(sdk: sdk) await manager.refreshEligibleTargets(savedPublicKeys: [refreshedKey, otherKey]) await sdk.configureRecipients( - peers: [linkedPeer(counterparty: otherKey, path: PaykitReceiverPath.wallet, state: .linked)], - receiverPathsByPublicKey: [refreshedKey: [PaykitReceiverPath.wallet], otherKey: [PaykitReceiverPath.wallet]] + peers: [linkedPeer(counterparty: otherKey, state: .linked)], + requestCapabilitiesByPublicKey: [refreshedKey: true, otherKey: true] ) await sdk.pauseNextLinkedPeers() @@ -3730,10 +4019,10 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { try await waitUntil { await sdk.linkedPeersIsPaused() } await sdk.configureRecipients( peers: [ - linkedPeer(counterparty: refreshedKey, path: PaykitReceiverPath.server, state: .linked), - linkedPeer(counterparty: otherKey, path: PaykitReceiverPath.wallet, state: .linked), + linkedPeer(counterparty: refreshedKey, state: .linked), + linkedPeer(counterparty: otherKey, state: .linked), ], - receiverPathsByPublicKey: [refreshedKey: [PaykitReceiverPath.server], otherKey: [PaykitReceiverPath.wallet]] + requestCapabilitiesByPublicKey: [refreshedKey: true, otherKey: true] ) _ = await manager.refreshEligibleTarget(publicKey: refreshedKey) await sdk.resumeLinkedPeers() @@ -3742,8 +4031,8 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { XCTAssertEqual( Set(manager.eligibleTargets), [ - PaykitPaymentRequestTarget(publicKey: refreshedKey, receiverPath: PaykitReceiverPath.server), - PaykitPaymentRequestTarget(publicKey: otherKey, receiverPath: PaykitReceiverPath.wallet), + PaykitPaymentRequestTarget(publicKey: refreshedKey), + PaykitPaymentRequestTarget(publicKey: otherKey), ] ) } @@ -3752,8 +4041,8 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { let savedKey = "pubky\(String(repeating: "y", count: 52))" let sdk = PaymentRequestSdkMock(records: []) await sdk.configureRecipients( - peers: [linkedPeer(counterparty: savedKey, path: PaykitReceiverPath.wallet, state: .linked)], - receiverPathsByPublicKey: [savedKey: [PaykitReceiverPath.wallet]] + peers: [linkedPeer(counterparty: savedKey, state: .linked)], + requestCapabilitiesByPublicKey: [savedKey: true] ) let manager = paymentRequestManager(sdk: sdk) await manager.refreshEligibleTargets(savedPublicKeys: [savedKey]) @@ -3763,17 +4052,17 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { await manager.refreshEligibleTargets(savedPublicKeys: [savedKey]) } try await waitUntil { await sdk.linkedPeersIsPaused() } - await sdk.setReceiverPathLookupFailing(true, for: savedKey) + await sdk.setCapabilityLookupFailing(true, for: savedKey) let target = await manager.refreshEligibleTarget(publicKey: savedKey) - await sdk.setReceiverPathLookupFailing(false, for: savedKey) + await sdk.setCapabilityLookupFailing(false, for: savedKey) await sdk.configureRecipients( - peers: [linkedPeer(counterparty: savedKey, path: PaykitReceiverPath.wallet, state: .linked)], - receiverPathsByPublicKey: [savedKey: []] + peers: [linkedPeer(counterparty: savedKey, state: .linked)], + requestCapabilitiesByPublicKey: [savedKey: false] ) await sdk.resumeLinkedPeers() await fullRefresh.value - XCTAssertEqual(target, PaykitPaymentRequestTarget(publicKey: savedKey, receiverPath: PaykitReceiverPath.wallet)) + XCTAssertEqual(target, PaykitPaymentRequestTarget(publicKey: savedKey)) XCTAssertTrue(manager.eligibleTargets.isEmpty) } @@ -3782,8 +4071,8 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { let clock = PaymentRequestTestClock(Date()) let sdk = PaymentRequestSdkMock(records: []) await sdk.configureRecipients( - peers: [linkedPeer(counterparty: savedKey, path: PaykitReceiverPath.wallet, state: .linked)], - receiverPathsByPublicKey: [savedKey: []] + peers: [linkedPeer(counterparty: savedKey, state: .linked)], + requestCapabilitiesByPublicKey: [savedKey: false] ) let manager = paymentRequestManager(sdk: sdk, clock: clock) await manager.refreshEligibleTargets(savedPublicKeys: [savedKey]) @@ -3801,20 +4090,20 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { let clock = PaymentRequestTestClock(Date()) let sdk = PaymentRequestSdkMock(records: []) await sdk.configureRecipients( - peers: [linkedPeer(counterparty: savedKey, path: PaykitReceiverPath.wallet, state: .linked)], - receiverPathsByPublicKey: [savedKey: []] + peers: [linkedPeer(counterparty: savedKey, state: .linked)], + requestCapabilitiesByPublicKey: [savedKey: false] ) let manager = paymentRequestManager(sdk: sdk, clock: clock) await manager.refreshEligibleTargets(savedPublicKeys: [savedKey]) await sdk.configureRecipients( - peers: [linkedPeer(counterparty: savedKey, path: PaykitReceiverPath.wallet, state: .linked)], - receiverPathsByPublicKey: [savedKey: [PaykitReceiverPath.wallet]] + peers: [linkedPeer(counterparty: savedKey, state: .linked)], + requestCapabilitiesByPublicKey: [savedKey: true] ) clock.advance(by: 31) let target = await manager.eligibleTarget(publicKey: savedKey, waitingAtMost: .seconds(2)) - XCTAssertEqual(target, PaykitPaymentRequestTarget(publicKey: savedKey, receiverPath: PaykitReceiverPath.wallet)) + XCTAssertEqual(target, PaykitPaymentRequestTarget(publicKey: savedKey)) } func testOlderEligibilityRefreshCannotOverwriteNewerContacts() async throws { @@ -3822,8 +4111,8 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { let secondKey = "pubky\(String(repeating: "b", count: 52))" let sdk = PaymentRequestSdkMock(records: []) await sdk.configureRecipients( - peers: [linkedPeer(counterparty: firstKey, path: PaykitReceiverPath.wallet, state: .linked)], - receiverPathsByPublicKey: [firstKey: [PaykitReceiverPath.wallet]] + peers: [linkedPeer(counterparty: firstKey, state: .linked)], + requestCapabilitiesByPublicKey: [firstKey: true] ) await sdk.pauseNextLinkedPeers() let manager = paymentRequestManager(sdk: sdk) @@ -3833,8 +4122,8 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { } try await waitUntil { await sdk.linkedPeersIsPaused() } await sdk.configureRecipients( - peers: [linkedPeer(counterparty: secondKey, path: PaykitReceiverPath.wallet, state: .linked)], - receiverPathsByPublicKey: [secondKey: [PaykitReceiverPath.wallet]] + peers: [linkedPeer(counterparty: secondKey, state: .linked)], + requestCapabilitiesByPublicKey: [secondKey: true] ) await manager.refreshEligibleTargets(savedPublicKeys: [secondKey]) await sdk.resumeLinkedPeers() @@ -3842,7 +4131,7 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { XCTAssertEqual( manager.eligibleTargets, - [PaykitPaymentRequestTarget(publicKey: secondKey, receiverPath: PaykitReceiverPath.wallet)] + [PaykitPaymentRequestTarget(publicKey: secondKey)] ) } @@ -3851,13 +4140,12 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { let sdk = PaymentRequestSdkMock(records: []) let expiresAt = Date(timeIntervalSince1970: 1_900_000_000) await sdk.configureRecipients( - peers: [linkedPeer(counterparty: publicKey, path: PaykitReceiverPath.wallet, state: .linked)], - receiverPathsByPublicKey: [publicKey: [PaykitReceiverPath.wallet]] + peers: [linkedPeer(counterparty: publicKey, state: .linked)], + requestCapabilitiesByPublicKey: [publicKey: true] ) try await sdk.setProposalResult(paymentRequestRecord( id: "outgoing", counterparty: publicKey, - counterpartyReceiverPath: PaykitReceiverPath.wallet, role: .payee, expiresAt: timestamp(expiresAt) )) @@ -3875,7 +4163,6 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { let snapshot = await sdk.snapshot() XCTAssertEqual(snapshot.proposedRequests.count, 1) XCTAssertEqual(snapshot.proposedRequests.first?.counterparty, publicKey) - XCTAssertEqual(snapshot.proposedRequests.first?.counterpartyReceiverPath, PaykitReceiverPath.wallet) XCTAssertEqual(snapshot.proposedRequests.first?.amount, "0.00000001") XCTAssertEqual(snapshot.proposedRequests.first?.asset, "btc") XCTAssertNil(snapshot.proposedRequests.first?.recurrence) @@ -3894,13 +4181,12 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { let expiresAt = Date(timeIntervalSince1970: 1_900_000_000) let sdk = PaymentRequestSdkMock(records: []) await sdk.configureRecipients( - peers: [linkedPeer(counterparty: publicKey, path: PaykitReceiverPath.wallet, state: .linked)], - receiverPathsByPublicKey: [publicKey: [PaykitReceiverPath.wallet]] + peers: [linkedPeer(counterparty: publicKey, state: .linked)], + requestCapabilitiesByPublicKey: [publicKey: true] ) try await sdk.setProposalResult(paymentRequestRecord( id: "outgoing", counterparty: publicKey, - counterpartyReceiverPath: PaykitReceiverPath.wallet, role: .payee, expiresAt: timestamp(expiresAt) )) @@ -3924,13 +4210,12 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { let expiresAt = Date(timeIntervalSince1970: 1_900_000_000) let sdk = PaymentRequestSdkMock(records: []) await sdk.configureRecipients( - peers: [linkedPeer(counterparty: publicKey, path: PaykitReceiverPath.wallet, state: .linked)], - receiverPathsByPublicKey: [publicKey: [PaykitReceiverPath.wallet]] + peers: [linkedPeer(counterparty: publicKey, state: .linked)], + requestCapabilitiesByPublicKey: [publicKey: true] ) try await sdk.setProposalResult(paymentRequestRecord( id: "outgoing", counterparty: publicKey, - counterpartyReceiverPath: PaykitReceiverPath.wallet, role: .payee, expiresAt: timestamp(expiresAt) )) @@ -3938,7 +4223,7 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { await manager.refreshEligibleTargets(savedPublicKeys: [publicKey]) let target = try XCTUnwrap(manager.eligibleTargets.first) - await sdk.configureRecipients(peers: [], receiverPathsByPublicKey: [publicKey: [PaykitReceiverPath.wallet]]) + await sdk.configureRecipients(peers: [], requestCapabilitiesByPublicKey: [publicKey: true]) do { _ = try await manager.propose( @@ -3958,8 +4243,8 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { let publicKey = "pubky\(String(repeating: "y", count: 52))" let sdk = PaymentRequestSdkMock(records: []) await sdk.configureRecipients( - peers: [linkedPeer(counterparty: publicKey, path: PaykitReceiverPath.wallet, state: .linked)], - receiverPathsByPublicKey: [publicKey: [PaykitReceiverPath.wallet]] + peers: [linkedPeer(counterparty: publicKey, state: .linked)], + requestCapabilitiesByPublicKey: [publicKey: true] ) let manager = paymentRequestManager(sdk: sdk, clock: PaymentRequestTestClock(now)) await manager.refreshEligibleTargets(savedPublicKeys: [publicKey]) @@ -3983,13 +4268,12 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { let expiresAt = Date(timeIntervalSince1970: 1_900_000_000) let sdk = PaymentRequestSdkMock(records: []) await sdk.configureRecipients( - peers: [linkedPeer(counterparty: publicKey, path: PaykitReceiverPath.wallet, state: .linked)], - receiverPathsByPublicKey: [publicKey: [PaykitReceiverPath.wallet]] + peers: [linkedPeer(counterparty: publicKey, state: .linked)], + requestCapabilitiesByPublicKey: [publicKey: true] ) try await sdk.setProposalResult(paymentRequestRecord( id: "outgoing", counterparty: publicKey, - counterpartyReceiverPath: PaykitReceiverPath.wallet, role: .payee, expiresAt: timestamp(expiresAt) )) @@ -4019,13 +4303,12 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { let expiresAt = Date(timeIntervalSince1970: 1_900_000_000) let sdk = PaymentRequestSdkMock(records: []) await sdk.configureRecipients( - peers: [linkedPeer(counterparty: publicKey, path: PaykitReceiverPath.wallet, state: .linked)], - receiverPathsByPublicKey: [publicKey: [PaykitReceiverPath.wallet]] + peers: [linkedPeer(counterparty: publicKey, state: .linked)], + requestCapabilitiesByPublicKey: [publicKey: true] ) try await sdk.setProposalResult(paymentRequestRecord( id: "outgoing", counterparty: publicKey, - counterpartyReceiverPath: PaykitReceiverPath.wallet, role: .payee, expiresAt: timestamp(expiresAt) )) @@ -4061,13 +4344,12 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { let messageId: UInt64 = 7 let sdk = PaymentRequestSdkMock(records: []) await sdk.configureRecipients( - peers: [linkedPeer(counterparty: publicKey, path: PaykitReceiverPath.wallet, state: .linked)], - receiverPathsByPublicKey: [publicKey: [PaykitReceiverPath.wallet]] + peers: [linkedPeer(counterparty: publicKey, state: .linked)], + requestCapabilitiesByPublicKey: [publicKey: true] ) try await sdk.setProposalResult(paymentRequestRecord( id: "outgoing", counterparty: publicKey, - counterpartyReceiverPath: PaykitReceiverPath.wallet, role: .payee, expiresAt: timestamp(expiresAt), proposalOutboundMessageId: messageId @@ -4075,7 +4357,6 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { await sdk.setProcessReports([ OutboundPrivateCounterpartySendReport( counterparty: publicKey, - counterpartyReceiverPath: PaykitReceiverPath.wallet, report: OutboundPrivateSendReport( attempted: [messageId], sent: [messageId], @@ -4174,7 +4455,6 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { private func paymentRequestRecord( id: String = "550e8400-e29b-41d4-a716-446655440000", counterparty: String = "pubkypayee", - counterpartyReceiverPath: String = PaykitReceiverPath.server, state: PaymentRequestLifecycleState = .proposed, role: PaymentRequestLocalRole? = .payer, amount: String = "0.001", @@ -4183,6 +4463,7 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { paymentDeadline: PaymentDeadline? = nil, recurrence: PaymentRequestRecurrence? = nil, endpoints: [String] = [PublicPaykitService.MethodId.bitcoinLightningBolt11.rawValue], + paymentEndpoints: [String: String]? = nil, metadata: String = "{}", lastEventAt: String = "2027-01-15T08:00:00Z", proposalOutboundMessageId: UInt64? = nil, @@ -4194,7 +4475,6 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { ) throws -> PaymentRequestRecord { try PaymentRequestRecord( counterparty: counterparty, - counterpartyReceiverPath: counterpartyReceiverPath, paymentRequestId: id, localRole: role, state: state, @@ -4202,12 +4482,17 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { proposalOutboundMessageId: proposalOutboundMessageId, proposalOutboundStatus: proposalOutboundStatus, proposalEventId: "650e8400-e29b-41d4-a716-446655440000", + proposalAppId: "bitkit", + payerAppId: nil, + executionClaimAppId: nil, terms: PaymentRequestTerms( amount: PaymentRequestAmount(value: amount, asset: asset), paymentReference: PaymentReference(text: "invoice-123"), proposalExpiresAt: expiresAt, recurrence: recurrence, acceptedPaymentEndpointIdentifiers: endpoints, + paymentEndpoints: paymentEndpoints, + requiredAppId: "bitkit", conversion: nil, paymentDeadline: paymentDeadline, metadata: PrivateJsonObject(text: metadata) @@ -4240,6 +4525,7 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { streamItemId: 2, paymentReference: PaymentReference(text: "invoice-123"), billingPeriod: billingPeriod, + paymentAppId: "bitkit", paymentEndpointIdentifier: endpoint, allowanceId: nil, conversionQuoteId: nil, @@ -4254,10 +4540,9 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { return formatter.string(from: date) } - private func linkedPeer(counterparty: String, path: String, state: LinkedPeerState) -> LinkedPeerRecord { + private func linkedPeer(counterparty: String, state: LinkedPeerState) -> LinkedPeerRecord { LinkedPeerRecord( counterparty: counterparty, - counterpartyReceiverPath: path, state: state, lastSyncAt: nil, lastPrivateReceiveAt: nil, @@ -4322,12 +4607,12 @@ private actor PaymentRequestSdkMock: PaykitPaymentRequestSdkHandling, PaykitPaym private var activeIdentity = "pubky\(String(repeating: "z", count: 52))" private var records: [PaymentRequestRecord] private var peerRecords: [LinkedPeerRecord] = [] - private var receiverPathsByPublicKey: [String: [String]] = [:] + private var requestCapabilitiesByPublicKey: [String: Bool] = [:] private var liveSessionAvailable = true private var linkedPeersError: PaymentRequestSdkMockError? private var linkedPeersCallCount = 0 - private var receiverPathLookupCount = 0 - private var failingReceiverPathKeys: Set = [] + private var capabilityLookupCount = 0 + private var failingCapabilityKeys: Set = [] private var proposalResult: PaymentRequestRecord? private var uploadCount = 0 private var shouldPauseNextUpload = false @@ -4391,6 +4676,10 @@ private actor PaymentRequestSdkMock: PaykitPaymentRequestSdkHandling, PaykitPaym } func paymentRequests() async -> [PaymentRequestRecord] { + await sharedPaymentRequests().filter(PaykitSdkService.isBitkitPaymentRequest) + } + + func sharedPaymentRequests() async -> [PaymentRequestRecord] { let snapshot = records guard shouldPauseNextPaymentRequestList else { return snapshot } @@ -4402,7 +4691,7 @@ private actor PaymentRequestSdkMock: PaykitPaymentRequestSdkHandling, PaykitPaym } func identityStatus() -> IdentityStatus? { - IdentityStatus(publicKey: activeIdentity, liveSessionAvailable: liveSessionAvailable) + IdentityStatus(publicKey: activeIdentity, capability: liveSessionAvailable ? .privateLinkCapable : .signedOut) } func submitPaymentProof( @@ -4430,12 +4719,12 @@ private actor PaymentRequestSdkMock: PaykitPaymentRequestSdkHandling, PaykitPaym return snapshot } - func paymentRequestReceiverPaths(publicKey: String) throws -> [String] { - receiverPathLookupCount += 1 - if failingReceiverPathKeys.contains(publicKey) { + func canReceivePaymentRequests(publicKey: String) throws -> Bool { + capabilityLookupCount += 1 + if failingCapabilityKeys.contains(publicKey) { throw PaymentRequestSdkMockError.receive } - return receiverPathsByPublicKey[publicKey] ?? [] + return requestCapabilitiesByPublicKey[publicKey] ?? false } func pauseNextUpload() { @@ -4467,7 +4756,6 @@ private actor PaymentRequestSdkMock: PaykitPaymentRequestSdkHandling, PaykitPaym func proposePaymentRequest( counterparty: String, - counterpartyReceiverPath: String, terms: PaymentRequestTerms, expectedIdentity: String ) async throws -> PaymentRequestRecord { @@ -4484,12 +4772,10 @@ private actor PaymentRequestSdkMock: PaykitPaymentRequestSdkHandling, PaykitPaym throw PaymentRequestSdkMockError.requestMissing } result.counterparty = counterparty - result.counterpartyReceiverPath = counterpartyReceiverPath result.terms = terms records.append(result) proposedRequests.append(ProposedPaymentRequestInvocation( counterparty: counterparty, - counterpartyReceiverPath: counterpartyReceiverPath, amount: terms.amount.value, asset: terms.amount.asset, expiresAt: terms.proposalExpiresAt, @@ -4500,9 +4786,18 @@ private actor PaymentRequestSdkMock: PaykitPaymentRequestSdkHandling, PaykitPaym return result } + func claimPaymentRequestForExecution(counterparty: String, paymentRequestId: String) throws -> PaymentRequestRecord { + guard let index = records.firstIndex(where: { + $0.counterparty == counterparty && $0.paymentRequestId == paymentRequestId + }), records[index].executionClaimAppId == nil || records[index].executionClaimAppId == "bitkit" else { + throw PaymentRequestSdkMockError.requestMissing + } + records[index].executionClaimAppId = "bitkit" + return records[index] + } + func acceptPaymentRequest( counterparty: String, - counterpartyReceiverPath: String, paymentRequestId: String ) async throws -> PaymentRequestRecord { if shouldPauseNextAccept { @@ -4515,7 +4810,6 @@ private actor PaymentRequestSdkMock: PaykitPaymentRequestSdkHandling, PaykitPaym let record: PaymentRequestRecord if let index = records.firstIndex(where: { $0.counterparty == counterparty && - $0.counterpartyReceiverPath == counterpartyReceiverPath && $0.paymentRequestId == paymentRequestId && $0.terms?.recurrence != nil }) { @@ -4524,7 +4818,6 @@ private actor PaymentRequestSdkMock: PaykitPaymentRequestSdkHandling, PaykitPaym } else { record = try removeRecord( counterparty: counterparty, - counterpartyReceiverPath: counterpartyReceiverPath, id: paymentRequestId ) } @@ -4534,7 +4827,6 @@ private actor PaymentRequestSdkMock: PaykitPaymentRequestSdkHandling, PaykitPaym } acceptedRequests.append(PaymentRequestInvocation( counterparty: counterparty, - counterpartyReceiverPath: counterpartyReceiverPath, paymentRequestId: paymentRequestId )) return record @@ -4542,13 +4834,11 @@ private actor PaymentRequestSdkMock: PaykitPaymentRequestSdkHandling, PaykitPaym func rejectPaymentRequest( counterparty: String, - counterpartyReceiverPath: String, paymentRequestId: String, reason _: String? ) throws -> PaymentRequestRecord { let record = try removeRecord( counterparty: counterparty, - counterpartyReceiverPath: counterpartyReceiverPath, id: paymentRequestId ) if rejectFailuresAfterRemoval > 0 { @@ -4557,7 +4847,6 @@ private actor PaymentRequestSdkMock: PaykitPaymentRequestSdkHandling, PaykitPaym } rejectedRequests.append(PaymentRequestInvocation( counterparty: counterparty, - counterpartyReceiverPath: counterpartyReceiverPath, paymentRequestId: paymentRequestId )) return record @@ -4565,13 +4854,11 @@ private actor PaymentRequestSdkMock: PaykitPaymentRequestSdkHandling, PaykitPaym func cancelPaymentRequest( counterparty: String, - counterpartyReceiverPath: String, paymentRequestId: String, reason _: String? ) throws -> PaymentRequestRecord { try removeRecord( counterparty: counterparty, - counterpartyReceiverPath: counterpartyReceiverPath, id: paymentRequestId ) } @@ -4637,10 +4924,10 @@ private actor PaymentRequestSdkMock: PaykitPaymentRequestSdkHandling, PaykitPaym func configureRecipients( peers: [LinkedPeerRecord], - receiverPathsByPublicKey: [String: [String]] + requestCapabilitiesByPublicKey: [String: Bool] ) { peerRecords = peers - self.receiverPathsByPublicKey = receiverPathsByPublicKey + self.requestCapabilitiesByPublicKey = requestCapabilitiesByPublicKey } func setLiveSessionAvailable(_ value: Bool) { @@ -4651,19 +4938,19 @@ private actor PaymentRequestSdkMock: PaykitPaymentRequestSdkHandling, PaykitPaym linkedPeersCallCount } - func receiverPathLookups() -> Int { - receiverPathLookupCount + func capabilityLookups() -> Int { + capabilityLookupCount } func setLinkedPeersError(_ error: PaymentRequestSdkMockError?) { linkedPeersError = error } - func setReceiverPathLookupFailing(_ isFailing: Bool, for publicKey: String) { + func setCapabilityLookupFailing(_ isFailing: Bool, for publicKey: String) { if isFailing { - failingReceiverPathKeys.insert(publicKey) + failingCapabilityKeys.insert(publicKey) } else { - failingReceiverPathKeys.remove(publicKey) + failingCapabilityKeys.remove(publicKey) } } @@ -4726,12 +5013,10 @@ private actor PaymentRequestSdkMock: PaykitPaymentRequestSdkHandling, PaykitPaym private func removeRecord( counterparty: String, - counterpartyReceiverPath: String, id: String ) throws -> PaymentRequestRecord { guard let index = records.firstIndex(where: { $0.counterparty == counterparty && - $0.counterpartyReceiverPath == counterpartyReceiverPath && $0.paymentRequestId == id }) else { throw PaymentRequestSdkMockError.requestMissing @@ -4751,7 +5036,6 @@ private struct PaymentRequestSdkSnapshot { private struct ProposedPaymentRequestInvocation { let counterparty: String - let counterpartyReceiverPath: String let amount: String let asset: String let expiresAt: String? @@ -4762,7 +5046,6 @@ private struct ProposedPaymentRequestInvocation { private struct PaymentRequestInvocation: Equatable { let counterparty: String - let counterpartyReceiverPath: String let paymentRequestId: String } diff --git a/BitkitTests/PaykitPaymentStateBackupTests.swift b/BitkitTests/PaykitPaymentStateBackupTests.swift index 584761f45..f5a9f9fc0 100644 --- a/BitkitTests/PaykitPaymentStateBackupTests.swift +++ b/BitkitTests/PaykitPaymentStateBackupTests.swift @@ -27,7 +27,7 @@ final class PaykitPaymentStateBackupTests: XCTestCase { } func testPaymentStateBackupRoundTrip() async throws { - let id = PaykitSubscription.ID(paymentRequestId: "subscription", counterparty: identity, counterpartyReceiverPath: "bitkit/server") + let id = PaykitSubscription.ID(paymentRequestId: "subscription", counterparty: identity) let period = try XCTUnwrap(PaykitBillingPeriod(sdkPeriod: BillingPeriod( startsAt: "2026-09-24T10:00:00.100Z", endsAt: "2026-09-25T10:00:00.100Z" ))) @@ -35,7 +35,6 @@ final class PaykitPaymentStateBackupTests: XCTestCase { let requestId = PaykitPaymentRequest.ID( paymentRequestId: id.paymentRequestId, counterparty: id.counterparty, - counterpartyReceiverPath: id.counterpartyReceiverPath, billingPeriodStartsAt: startedAt ) let subscriptions = PaykitSubscriptionState( @@ -45,6 +44,7 @@ final class PaykitPaymentStateBackupTests: XCTestCase { let proof = PendingPaykitPaymentProof( identity: identity, requestId: requestId, + paymentAppId: "bitkit", paymentEndpointIdentifier: "bitcoin-onchain", kind: .onchain, billingPeriod: period, @@ -198,7 +198,28 @@ final class PaykitPaymentStateBackupTests: XCTestCase { func testAndroidPaymentStatePreservesPreciseAcceptanceBillingBoundaries() throws { let data = Data(""" - {"subscriptions":{"\(identity)":{"acceptances":[{"id":{"paymentRequestId":"millisecond","counterparty":"bob","counterpartyReceiverPath":"bitkit/server"},"acceptedAt":"2026-09-24T10:00:00.123Z"},{"id":{"paymentRequestId":"nanosecond","counterparty":"bob","counterpartyReceiverPath":"bitkit/server"},"acceptedAt":"2026-09-24T10:00:00.123456789Z"}],"presentedProposalIds":[]}},"pendingProofs":[{"identity":"\(identity)","requestId":{"paymentRequestId":"request","counterparty":"bob","counterpartyReceiverPath":"bitkit/server","billingPeriodStartsAt":"2026-09-24T10:00:00.100Z"},"paymentEndpointIdentifier":"bitcoin-onchain","kind":"bitcoin-onchain-txid","paymentStarted":true,"billingPeriod":{"startsAt":"2026-09-24T10:00:00.100Z","endsAt":"2026-09-25T10:00:00.100Z"},"onchainWalletId":"trezor:android","onchainMatchingTransactionIdsBeforeAttempt":[]}]} + { + "subscriptions": { + "\(identity)": { + "acceptances": [ + {"id":{"paymentRequestId":"millisecond","counterparty":"bob"},"acceptedAt":"2026-09-24T10:00:00.123Z"}, + {"id":{"paymentRequestId":"nanosecond","counterparty":"bob"},"acceptedAt":"2026-09-24T10:00:00.123456789Z"} + ], + "presentedProposalIds": [] + } + }, + "pendingProofs": [{ + "identity": "\(identity)", + "requestId": {"paymentRequestId":"request","counterparty":"bob","billingPeriodStartsAt":"2026-09-24T10:00:00.100Z"}, + "paymentAppId": "bitkit", + "paymentEndpointIdentifier": "bitcoin-onchain", + "kind": "bitcoin-onchain-txid", + "paymentStarted": true, + "billingPeriod": {"startsAt":"2026-09-24T10:00:00.100Z","endsAt":"2026-09-25T10:00:00.100Z"}, + "onchainWalletId": "trezor:android", + "onchainMatchingTransactionIdsBeforeAttempt": [] + }] + } """.utf8) let backup = try JSONDecoder().decode(PaykitPaymentStateBackup.self, from: data) let store = PaykitSubscriptionStateStore() @@ -216,13 +237,11 @@ final class PaykitPaymentStateBackupTests: XCTestCase { let restoredAcceptedAt = try store.load(identity: identity).acceptedAt let millisecondId = PaykitSubscription.ID( paymentRequestId: "millisecond", - counterparty: "bob", - counterpartyReceiverPath: "bitkit/server" + counterparty: "bob" ) let nanosecondId = PaykitSubscription.ID( paymentRequestId: "nanosecond", - counterparty: "bob", - counterpartyReceiverPath: "bitkit/server" + counterparty: "bob" ) XCTAssertEqual(restoredAcceptedAt[millisecondId]?.timestamp, "2026-09-24T10:00:00.123Z") XCTAssertEqual(restoredAcceptedAt[nanosecondId]?.timestamp, "2026-09-24T10:00:00.123456789Z") @@ -271,8 +290,7 @@ final class PaykitPaymentStateBackupTests: XCTestCase { func testLegacyStoredAcceptanceDateDecodesAndMigratesToPreciseTimestamp() throws { let id = PaykitSubscription.ID( paymentRequestId: "subscription", - counterparty: identity, - counterpartyReceiverPath: "bitkit/server" + counterparty: identity ) let acceptedAt = try XCTUnwrap(ISO8601DateFormatter().date(from: "2026-09-24T10:00:00Z")) let legacyState = LegacyState(subscriptionsByIdentity: [ diff --git a/BitkitTests/PaykitReceiverNoiseKeyStoreTests.swift b/BitkitTests/PaykitReceiverNoiseKeyStoreTests.swift deleted file mode 100644 index 04327f9ca..000000000 --- a/BitkitTests/PaykitReceiverNoiseKeyStoreTests.swift +++ /dev/null @@ -1,147 +0,0 @@ -@testable import Bitkit -import Foundation -import Paykit -import XCTest - -final class PaykitReceiverNoiseKeyStoreTests: XCTestCase { - func testDerivationMatchesVersionedCrossPlatformVector() { - let seed = "c55257c360c07c72029aebc1b53c05ed0362ada38ead3e3e9efa3708e53495531f09a6987599d18264c1e1c92f2cf141630c7a3c4ab7c81b2f001698e7463b04" - .hexaData - - let key = PaykitReceiverNoiseKeyDerivation.derive( - seed: seed, - network: "bitcoin", - receiverPath: "bitkit/wallet" - ) - - XCTAssertEqual(key.hex, "500f4799bbb2d02103e3b74b365ddb478a3187333c053fa9eb62f4052ba6a327") - } - - func testDerivesPersistsAndReusesReceiverNoiseKey() throws { - var persistedBytes: Data? - let derivedBytes = Data(repeating: 7, count: 32) - let store = PaykitReceiverNoiseKeyStore( - loadBytes: { persistedBytes }, - upsertBytes: { persistedBytes = $0 }, - deriveBytes: { derivedBytes } - ) - - let first = try store.loadOrDerive().exportBytes() - let second = try store.loadOrDerive().exportBytes() - let restoredStore = PaykitReceiverNoiseKeyStore( - loadBytes: { persistedBytes }, - upsertBytes: { persistedBytes = $0 }, - deriveBytes: { derivedBytes } - ) - let restored = try restoredStore.loadOrDerive().exportBytes() - - XCTAssertEqual(first.count, 32) - XCTAssertEqual(persistedBytes, first) - XCTAssertEqual(second, first) - XCTAssertEqual(restored, first) - XCTAssertEqual(KeychainEntryType.paykitReceiverNoiseSecretKey.storageKey, "paykit_receiver_noise_secret_key") - } - - func testRejectsUnexpectedReceiverNoiseKeyReplacement() throws { - var persistedBytes: Data? = Data(repeating: 1, count: 32) - let store = PaykitReceiverNoiseKeyStore( - loadBytes: { persistedBytes }, - upsertBytes: { persistedBytes = $0 }, - deriveBytes: { Data(repeating: 1, count: 32) } - ) - - XCTAssertThrowsError(try store.persist(ReceiverNoiseSecretKey(bytes: Data(repeating: 2, count: 32)))) - XCTAssertEqual(persistedBytes, Data(repeating: 1, count: 32)) - } - - func testReceiverNoiseKeyFollowsWalletReplacementAfterKeychainWipe() throws { - var persistedBytes: Data? - var derivedBytes = Data(repeating: 1, count: 32) - let store = PaykitReceiverNoiseKeyStore( - loadBytes: { persistedBytes }, - upsertBytes: { persistedBytes = $0 }, - deriveBytes: { derivedBytes } - ) - let previousKey = try store.loadOrDerive() - - persistedBytes = nil - derivedBytes = Data(repeating: 2, count: 32) - - let replacementKey = try store.loadOrDerive().exportBytes() - XCTAssertEqual(replacementKey, derivedBytes) - XCTAssertEqual(persistedBytes, derivedBytes) - XCTAssertThrowsError(try store.persist(previousKey)) - - let restoredStore = PaykitReceiverNoiseKeyStore( - loadBytes: { persistedBytes }, - upsertBytes: { persistedBytes = $0 }, - deriveBytes: { derivedBytes } - ) - XCTAssertEqual(try restoredStore.loadOrDerive().exportBytes(), replacementKey) - } - - func testRejectsChangedPersistedKeyAfterCaching() throws { - let derivedBytes = Data(repeating: 1, count: 32) - var persistedBytes: Data? = derivedBytes - let store = PaykitReceiverNoiseKeyStore( - loadBytes: { persistedBytes }, - upsertBytes: { _ in XCTFail("Invalid bytes must not be overwritten") }, - deriveBytes: { derivedBytes } - ) - _ = try store.loadOrDerive() - - for invalidBytes in [Data(repeating: 1, count: 31), Data(repeating: 2, count: 32)] { - persistedBytes = invalidBytes - XCTAssertThrowsError(try store.loadOrDerive()) - XCTAssertEqual(persistedBytes, invalidBytes) - } - } - - func testCachedKeyDoesNotBypassKeychainReadFailure() throws { - enum LoadError: Error { - case inaccessible - } - - let derivedBytes = Data(repeating: 1, count: 32) - var shouldFailLoad = false - var upsertCount = 0 - let store = PaykitReceiverNoiseKeyStore( - loadBytes: { - if shouldFailLoad { - throw LoadError.inaccessible - } - return derivedBytes - }, - upsertBytes: { _ in upsertCount += 1 }, - deriveBytes: { derivedBytes } - ) - _ = try store.loadOrDerive() - - shouldFailLoad = true - - XCTAssertThrowsError(try store.loadOrDerive()) { error in - XCTAssertTrue(error is LoadError) - } - XCTAssertEqual(upsertCount, 0) - } - - func testRejectsInvalidPersistedReceiverNoiseKey() { - let store = PaykitReceiverNoiseKeyStore( - loadBytes: { Data(repeating: 0, count: 31) }, - upsertBytes: { _ in XCTFail("Invalid bytes must not be overwritten") }, - deriveBytes: { Data(repeating: 0, count: 32) } - ) - - XCTAssertThrowsError(try store.loadOrDerive()) - } - - func testRejectsCachedKeyFromAnotherWalletSeed() { - let store = PaykitReceiverNoiseKeyStore( - loadBytes: { Data(repeating: 1, count: 32) }, - upsertBytes: { _ in XCTFail("Mismatched bytes must not be overwritten") }, - deriveBytes: { Data(repeating: 2, count: 32) } - ) - - XCTAssertThrowsError(try store.loadOrDerive()) - } -} diff --git a/BitkitTests/PaykitSdkClientConfigTests.swift b/BitkitTests/PaykitSdkClientConfigTests.swift index d88d8886e..2e288f551 100644 --- a/BitkitTests/PaykitSdkClientConfigTests.swift +++ b/BitkitTests/PaykitSdkClientConfigTests.swift @@ -9,10 +9,7 @@ final class PaykitSdkClientConfigTests: XCTestCase { "&cid=paykit.test&cpk=5jsjx1o6fzu6aeeo697r3i5rx15zq41kikcye8wtwdqm4nb4tryo" func testRegisteredIdentityCannotActivateAfterWalletWipe() async throws { - let keys: [KeychainEntryType] = [ - .paykitSession, .paykitReceiverNoiseSecretKey, - .bip39Mnemonic(index: 0), .bip39Passphrase(index: 0), - ] + let keys: [KeychainEntryType] = [.paykitSession] let saved = try keys.map { try Keychain.load(key: $0) } defer { for (key, data) in zip(keys, saved) { @@ -20,10 +17,6 @@ final class PaykitSdkClientConfigTests: XCTestCase { else { try? Keychain.delete(key: key) } } } - let mnemonic = Array(repeating: "abandon", count: 11).joined(separator: " ") + " about" - try Keychain.upsert(key: .bip39Mnemonic(index: 0), data: Data(mnemonic.utf8)) - try Keychain.delete(key: .bip39Passphrase(index: 0)) - try Keychain.delete(key: .paykitReceiverNoiseSecretKey) let bootstrap = CacheActivationBootstrap(noPointer: .init()) let service = PaykitSdkService(sdkFactory: { CacheActivationSdk(noPointer: .init()) }) { _, _ in bootstrap } let identity = try await service.registerIdentity( @@ -44,11 +37,89 @@ final class PaykitSdkClientConfigTests: XCTestCase { XCTAssertNotEqual(identity.walletGeneration, freshIdentity.walletGeneration) } - func testIdentityReadFailurePreservesSavedStateAndSession() async throws { - let savedState = try Keychain.load(key: .paykitSdkState) - let savedSession = try Keychain.load(key: .paykitSession) + func testPaykitKeyForAuthorizationPreservesPerIdentityGenerationHighWater() async throws { + let secrets = [String(repeating: "11", count: 32), String(repeating: "12", count: 32)] + let roots = try secrets.map { try PaykitSdkService.localSecretKey(fromHex: $0) } + let publicKeys = try roots.map { try Paykit.pubkyPublicKeyFromSecret(localSecretKey: $0) } + let keys = publicKeys.map { KeychainEntryType.paykitKeyGeneration(publicKey: $0) } + let savedValues = try keys.map { try Keychain.load(key: $0) } + addTeardownBlock { + for (key, value) in zip(keys, savedValues) { + if let value { + try Keychain.upsert(key: key, data: value) + } else { + try Keychain.delete(key: key) + } + } + } + for key in keys { + try Keychain.delete(key: key) + } + + let steps: [(identity: Int, generation: UInt64?, readFails: Bool, expectedGeneration: UInt64?, highWater: [UInt64?])] = [ + (0, nil, false, 1, [1, nil]), + (0, 3, false, 3, [3, nil]), + (0, 3, false, 3, [3, nil]), + (0, 2, false, nil, [3, nil]), + (0, nil, false, nil, [3, nil]), + (0, nil, true, nil, [3, nil]), + (1, nil, false, 1, [3, 1]), + (0, 4, false, 4, [4, 1]), + (0, 3, false, nil, [4, 1]), + ] + for (index, step) in steps.enumerated() { + let sdk = CacheActivationSdk(noPointer: .init()) + sdk.registry = step.generation.map { + PaykitAppRegistry(keyGeneration: $0, noisePublicKey: nil, apps: [], defaultAppId: nil, defaultAppsByEndpoint: [:]) + } + sdk.registryError = step.readFails ? PaykitError.Storage(code: "registry_unavailable", context: "registry read failed") : nil + let service = PaykitSdkService(sdkFactory: { sdk }) + let previousValues = try keys.map { try Keychain.load(key: $0) } + + do { + let key = try await service.paykitKeyForAuthorization(secretKeyHex: secrets[step.identity]) + if let generation = step.expectedGeneration { + XCTAssertEqual(key.keyGeneration(), generation, "Step \(index)") + XCTAssertEqual( + key.exportBytes(), + try roots[step.identity].derivePaykitIdentitySecretKey(keyGeneration: generation).exportBytes(), + "Step \(index)" + ) + } else { + XCTFail("Step \(index) must reject an unavailable or stale registry") + } + } catch { + XCTAssertNil(step.expectedGeneration, "Step \(index): \(error)") + switch error { + case let PaykitError.Storage(code, _): + XCTAssertTrue(step.readFails, "Step \(index)") + XCTAssertEqual(code, "registry_unavailable") + case let PaykitError.Identity(code, _): + XCTAssertFalse(step.readFails, "Step \(index)") + XCTAssertEqual(code, "stale_paykit_key_generation") + default: + XCTFail("Unexpected error at step \(index): \(error)") + } + } + + XCTAssertEqual(sdk.registryPublicKeys, [publicKeys[step.identity]], "Step \(index)") + let persistedValues = try keys.map { try Keychain.load(key: $0) } + XCTAssertEqual( + try persistedValues.map { try $0.map { try JSONDecoder().decode(UInt64.self, from: $0) } }, + step.highWater, + "Step \(index)" + ) + if step.expectedGeneration == nil { + XCTAssertEqual(persistedValues, previousValues, "Rejected authorization must preserve the high-water entries") + } + } + } + + func testIdentityReadFailurePreservesSavedCredentials() async throws { + let keys: [KeychainEntryType] = [.paykitSession, .pubkySecretKey] + let saved = try keys.map { try Keychain.load(key: $0) } defer { - for (key, data) in [(KeychainEntryType.paykitSdkState, savedState), (.paykitSession, savedSession)] { + for (key, data) in zip(keys, saved) { if let data { try? Keychain.upsert(key: key, data: data) } else { @@ -56,13 +127,13 @@ final class PaykitSdkClientConfigTests: XCTestCase { } } } - let state = Data("saved contacts and identity".utf8) + let secret = Data(String(repeating: "01", count: 32).utf8) let session = Data("saved grant".utf8) for failure in [ PaykitError.Identity(code: "identity_error", context: "restore Pubky grant session from platform provider"), PaykitError.Storage(code: "storage_error", context: "unavailable"), ] { - try Keychain.upsert(key: .paykitSdkState, data: state) + try Keychain.upsert(key: .pubkySecretKey, data: secret) try Keychain.upsert(key: .paykitSession, data: session) let sdk = IdentityReadFailureSdk(noPointer: .init()) sdk.failure = failure @@ -74,7 +145,7 @@ final class PaykitSdkClientConfigTests: XCTestCase { } catch { XCTAssertEqual(String(describing: error), String(describing: failure)) } - XCTAssertEqual(try Keychain.load(key: .paykitSdkState), state) + XCTAssertEqual(try Keychain.load(key: .pubkySecretKey), secret) XCTAssertEqual(try Keychain.load(key: .paykitSession), session) } } @@ -85,12 +156,15 @@ final class PaykitSdkClientConfigTests: XCTestCase { let metadataKeys = ["pubky_profile_name", "pubky_profile_image_uri"] let savedMetadata = metadataKeys.map { defaults.object(forKey: $0) } let savedOverrides = ContactsManager.backupContactProfileOverrides() + let savedReference = AdoptedPubkyReference.current + let secret = String(repeating: "01", count: 32) + let publicKey = try PubkyProfileManager.publicKeyFromSecretKey(secret) let credentialKeys: [KeychainEntryType] = [ - .paykitSdkState, .paykitSession, .pubkySecretKey, .paykitReceiverNoiseSecretKey, - .bip39Mnemonic(index: 0), .bip39Passphrase(index: 0), + .paykitSession, .pubkySecretKey, .paykitKeyGeneration(publicKey: publicKey), ] let savedCredentials = try credentialKeys.map { try Keychain.load(key: $0) } defer { + AdoptedPubkyReference.current = savedReference for (key, value) in zip(metadataKeys, savedMetadata) { defaults.set(value, forKey: key) } @@ -103,15 +177,10 @@ final class PaykitSdkClientConfigTests: XCTestCase { } } } - let mnemonic = Array(repeating: "abandon", count: 11).joined(separator: " ") + " about" - try Keychain.upsert(key: .bip39Mnemonic(index: 0), data: Data(mnemonic.utf8)) - try Keychain.delete(key: .bip39Passphrase(index: 0)) - let noiseBytes = try PaykitReceiverNoiseKeyDerivation.deriveFromWalletSeed( - mnemonic: mnemonic, passphrase: nil, network: Env.networkName, receiverPath: PaykitReceiverPath.wallet - ) - try Keychain.upsert(key: .paykitReceiverNoiseSecretKey, data: noiseBytes) - let originalKey = "3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" - let differentKey = "5" + String(originalKey.dropFirst()) + AdoptedPubkyReference.current = nil + try Keychain.delete(key: .paykitKeyGeneration(publicKey: publicKey)) + let originalKey = String(publicKey.dropFirst("pubky".count)) + let differentKey = try PubkyProfileManager.publicKeyFromSecretKey(String(repeating: "02", count: 32)) let overrides = [originalKey: PubkyProfileData(name: "Private label", bio: "", image: nil, links: [], tags: [])] for previousKey in [originalKey, "pubky\(originalKey)", differentKey, nil] { defaults.set("Original profile", forKey: metadataKeys[0]) @@ -123,13 +192,16 @@ final class PaykitSdkClientConfigTests: XCTestCase { sdk.previousKey = previousKey let service = PaykitSdkService(sdkFactory: { sdk }) { _, _ in CacheActivationBootstrap(noPointer: .init()) } let session = CacheActivationSession(noPointer: .init()) - session.noiseBytes = noiseBytes - let result = PubkySessionBootstrapResult(sessionAccess: session, publicKey: "pubky\(originalKey)") + session.localSecretKey = try PaykitSdkService.localSecretKey(fromHex: secret) + let result = PubkySessionBootstrapResult(sessionAccess: session, publicKey: publicKey, capability: .privateLinkCapable) try await service.activateRegisteredIdentity(.init(result: result, walletGeneration: 0)) await manager.initialize { .restored(publicKey: "pubky\(originalKey)") } XCTAssertEqual(manager.publicKey, "pubky\(originalKey)") + XCTAssertEqual(try Keychain.loadString(key: .paykitSession), "new-session") + XCTAssertEqual(try Keychain.loadString(key: .pubkySecretKey), secret) + XCTAssertEqual(sdk.activationEvents, ["registry", "initialize"]) if previousKey == differentKey { XCTAssertNil(manager.displayName) XCTAssertNil(manager.displayImageUri) @@ -150,12 +222,18 @@ final class PaykitSdkClientConfigTests: XCTestCase { let metadataKeys = ["pubky_profile_name", "pubky_profile_image_uri"] let savedMetadata = metadataKeys.map { defaults.object(forKey: $0) } let savedOverrides = ContactsManager.backupContactProfileOverrides() + let savedReference = AdoptedPubkyReference.current + let oldSecret = String(repeating: "01", count: 32) + let differentSecret = String(repeating: "02", count: 32) + let previousKey = try PubkyProfileManager.publicKeyFromSecretKey(oldSecret) + let differentKey = try PubkyProfileManager.publicKeyFromSecretKey(differentSecret) let keys: [KeychainEntryType] = [ - .paykitSdkState, .paykitSession, .pubkySecretKey, .paykitReceiverNoiseSecretKey, - .bip39Mnemonic(index: 0), .bip39Passphrase(index: 0), + .paykitSession, .pubkySecretKey, + .paykitKeyGeneration(publicKey: previousKey), .paykitKeyGeneration(publicKey: differentKey), ] let saved = try keys.map { try Keychain.load(key: $0) } defer { + AdoptedPubkyReference.current = savedReference for (key, value) in zip(metadataKeys, savedMetadata) { defaults.set(value, forKey: key) } @@ -168,14 +246,9 @@ final class PaykitSdkClientConfigTests: XCTestCase { } } } - let mnemonic = Array(repeating: "abandon", count: 11).joined(separator: " ") + " about" - try Keychain.upsert(key: .bip39Mnemonic(index: 0), data: Data(mnemonic.utf8)) - try Keychain.delete(key: .bip39Passphrase(index: 0)) - let noise = try PaykitReceiverNoiseKeyDerivation.deriveFromWalletSeed( - mnemonic: mnemonic, passphrase: nil, network: Env.networkName, receiverPath: PaykitReceiverPath.wallet - ) - let previousKey = "3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" - let oldSecret = String(repeating: "01", count: 32) + AdoptedPubkyReference.current = nil + try Keychain.delete(key: .paykitKeyGeneration(publicKey: previousKey)) + try Keychain.delete(key: .paykitKeyGeneration(publicKey: differentKey)) let sharedPubky = try SharedPubkyKeychain.derivedPubky(fromSecretKeyHex: oldSecret) let savedSharedSecret = SharedPubkyKeychain.loadSecret(sourceApp: SharedPubkyKeychain.ownSourceApp, pubky: sharedPubky) defer { @@ -186,50 +259,54 @@ final class PaykitSdkClientConfigTests: XCTestCase { } } SharedPubkyKeychain.publishOwn(pubky: sharedPubky, secretKeyHex: oldSecret) - let state = Data("previous SDK identity and contacts".utf8) let overrides = [previousKey: PubkyProfileData(name: "Private label", bio: "", image: nil, links: [], tags: [])] - for newKey in [previousKey, "5" + String(previousKey.dropFirst())] { - defaults.set("Original profile", forKey: metadataKeys[0]) - defaults.set("pubky://original/avatar", forKey: metadataKeys[1]) - ContactsManager.restoreContactProfileOverrides(overrides) - try Keychain.upsert(key: .paykitSdkState, data: state) - try Keychain.upsert(key: .paykitSession, data: Data("previous-session".utf8)) - try Keychain.upsert(key: .pubkySecretKey, data: Data(oldSecret.utf8)) - try Keychain.upsert(key: .paykitReceiverNoiseSecretKey, data: noise) - let sdk = CacheActivationSdk(noPointer: .init()) - sdk.previousKey = previousKey - sdk.initializationError = PubkyServiceError.authFailed("initialization unavailable") - let service = PaykitSdkService(sdkFactory: { sdk }) { _, _ in CacheActivationBootstrap(noPointer: .init()) } - let session = CacheActivationSession(noPointer: .init()) - session.noiseBytes = noise - - do { - try await service.activateRegisteredIdentity(.init( - result: .init(sessionAccess: session, publicKey: newKey), walletGeneration: 0 - )) - XCTFail("Expected initialization to fail after staging the new session") - } catch { - XCTAssertEqual(error.localizedDescription, sdk.initializationError?.localizedDescription) + for newSecret in [oldSecret, differentSecret] { + for failingStep in ["registry", "initialize"] { + defaults.set("Original profile", forKey: metadataKeys[0]) + defaults.set("pubky://original/avatar", forKey: metadataKeys[1]) + ContactsManager.restoreContactProfileOverrides(overrides) + try Keychain.upsert(key: .paykitSession, data: Data("previous-session".utf8)) + try Keychain.upsert(key: .pubkySecretKey, data: Data(oldSecret.utf8)) + let sdk = CacheActivationSdk(noPointer: .init()) + sdk.previousKey = previousKey + let failure = PubkyServiceError.authFailed("\(failingStep) unavailable") + sdk.registryError = failingStep == "registry" ? failure : nil + sdk.initializationError = failingStep == "initialize" ? failure : nil + let service = PaykitSdkService(sdkFactory: { sdk }) { _, _ in CacheActivationBootstrap(noPointer: .init()) } + let session = CacheActivationSession(noPointer: .init()) + session.localSecretKey = try PaykitSdkService.localSecretKey(fromHex: newSecret) + let newKey = try PubkyProfileManager.publicKeyFromSecretKey(newSecret) + + do { + try await service.activateRegisteredIdentity(.init( + result: .init(sessionAccess: session, publicKey: newKey, capability: .privateLinkCapable), walletGeneration: 0 + )) + XCTFail("Expected activation to fail after staging the new session") + } catch { + XCTAssertEqual(error.localizedDescription, failure.localizedDescription) + } + XCTAssertEqual(SharedPubkyKeychain.loadSecret(sourceApp: SharedPubkyKeychain.ownSourceApp, pubky: sharedPubky), oldSecret) + XCTAssertEqual(try Keychain.loadString(key: .paykitSession), "previous-session") + XCTAssertEqual(try Keychain.loadString(key: .pubkySecretKey), oldSecret) + XCTAssertEqual(sdk.activationEvents, failingStep == "registry" ? ["registry"] : ["registry", "initialize"]) + let relaunched = UnavailableProfileManager() + XCTAssertEqual(relaunched.displayName, "Original profile") + XCTAssertEqual(relaunched.displayImageUri, "pubky://original/avatar") + XCTAssertEqual(ContactsManager.backupContactProfileOverrides(), overrides) } - XCTAssertEqual(SharedPubkyKeychain.loadSecret(sourceApp: SharedPubkyKeychain.ownSourceApp, pubky: sharedPubky), oldSecret) - XCTAssertEqual(try Keychain.loadString(key: .paykitSession), "previous-session") - XCTAssertEqual(try Keychain.loadString(key: .pubkySecretKey), oldSecret) - XCTAssertEqual(try Keychain.load(key: .paykitSdkState), state) - XCTAssertEqual(try Keychain.load(key: .paykitReceiverNoiseSecretKey), noise) - let relaunched = UnavailableProfileManager() - XCTAssertEqual(relaunched.displayName, "Original profile") - XCTAssertEqual(relaunched.displayImageUri, "pubky://original/avatar") - XCTAssertEqual(ContactsManager.backupContactProfileOverrides(), overrides) } } func testSessionRecoveryCannotReactivateCredentialsAfterForget() async throws { + let savedReference = AdoptedPubkyReference.current + let secret = String(repeating: "01", count: 32) + let publicKey = try PubkyProfileManager.publicKeyFromSecretKey(secret) let keys: [KeychainEntryType] = [ - .paykitSdkState, .paykitSession, .pubkySecretKey, .paykitReceiverNoiseSecretKey, - .bip39Mnemonic(index: 0), .bip39Passphrase(index: 0), + .paykitSession, .pubkySecretKey, .paykitKeyGeneration(publicKey: publicKey), ] let saved = try keys.map { try Keychain.load(key: $0) } defer { + AdoptedPubkyReference.current = savedReference for (key, data) in zip(keys, saved) { if let data { try? Keychain.upsert(key: key, data: data) @@ -238,18 +315,13 @@ final class PaykitSdkClientConfigTests: XCTestCase { } } } - let secret = String(repeating: "01", count: 32) - let mnemonic = Array(repeating: "abandon", count: 11).joined(separator: " ") + " about" - try Keychain.upsert(key: .bip39Mnemonic(index: 0), data: Data(mnemonic.utf8)) - try Keychain.delete(key: .bip39Passphrase(index: 0)) - let noise = try PaykitReceiverNoiseKeyDerivation.deriveFromWalletSeed( - mnemonic: mnemonic, passphrase: nil, network: Env.networkName, receiverPath: PaykitReceiverPath.wallet - ) + AdoptedPubkyReference.current = nil + try Keychain.delete(key: .paykitKeyGeneration(publicKey: publicKey)) try Keychain.upsert(key: .paykitSession, data: Data("saved-session".utf8)) try Keychain.upsert(key: .pubkySecretKey, data: Data(secret.utf8)) - try Keychain.upsert(key: .paykitReceiverNoiseSecretKey, data: noise) let started = expectation(description: "import started") let (stream, continuation) = AsyncStream.makeStream() + defer { continuation.finish() } let bootstrap = RecoveryBootstrap(noPointer: .init()) bootstrap.importSessionOperation = { started.fulfill() @@ -257,9 +329,9 @@ final class PaykitSdkClientConfigTests: XCTestCase { throw PubkyServiceError.authFailed("expired session") } let session = CacheActivationSession(noPointer: .init()) - session.noiseBytes = noise - bootstrap.result = try PubkySessionBootstrapResult( - sessionAccess: session, publicKey: PubkyProfileManager.publicKeyFromSecretKey(secret) + session.localSecretKey = try PaykitSdkService.localSecretKey(fromHex: secret) + bootstrap.result = PubkySessionBootstrapResult( + sessionAccess: session, publicKey: publicKey, capability: .privateLinkCapable ) let sdk = RecoverySdk(noPointer: .init()) let forgotEarly = expectation(description: "forget cannot interleave with recovery") @@ -380,29 +452,46 @@ private final class UnavailableProfileManager: PubkyProfileManager { private final class CacheActivationSdk: PaykitSdk, @unchecked Sendable { var previousKey: String? + var registry: PaykitAppRegistry? + var registryPublicKeys: [String] = [] + var registryError: Error? var initializationError: Error? + var activationEvents: [String] = [] override func identityStatus() async throws -> IdentityStatus? { - IdentityStatus(publicKey: previousKey, liveSessionAvailable: false) + IdentityStatus(publicKey: previousKey, capability: .signedOut) + } + + override func paykitAppRegistry(publicKey: String) async throws -> PaykitAppRegistry? { + activationEvents.append("registry") + registryPublicKeys.append(publicKey) + if let registryError { + throw registryError + } + return registry } - override func initialize() async throws -> InitializationReport { + override func initialize() async throws -> IdentityStatus { + activationEvents.append("initialize") if let initializationError { throw initializationError } - return InitializationReport(identity: IdentityStatus(publicKey: previousKey, liveSessionAvailable: false)) + return IdentityStatus(publicKey: previousKey, capability: .signedOut) } } private final class CacheActivationBootstrap: PubkySessionBootstrap, @unchecked Sendable { override func signUp( localSecretKey _: PubkyLocalSecretKey, - receiverNoiseSecretKey _: ReceiverNoiseSecretKey, homeserverPublicKey _: String, signupCode _: String?, requiredCapabilities _: String ) async throws -> PubkySessionBootstrapResult { - PubkySessionBootstrapResult(sessionAccess: CacheActivationSession(noPointer: .init()), publicKey: "pubky_test") + PubkySessionBootstrapResult( + sessionAccess: CacheActivationSession(noPointer: .init()), + publicKey: "pubky_test", + capability: .privateLinkCapable + ) } override func republishIdentity(publicKey _: String) async throws -> Bool { @@ -411,28 +500,18 @@ private final class CacheActivationBootstrap: PubkySessionBootstrap, @unchecked } private final class CacheActivationSession: PubkySessionAccess, @unchecked Sendable { - var noiseBytes = Data() + var localSecretKey: PubkyLocalSecretKey? override func exportSessionSecret() -> String { "new-session" } override func exportLocalSecretKey() -> PubkyLocalSecretKey? { - nil + localSecretKey } - override func exportReceiverNoiseSecretKey() -> ReceiverNoiseSecretKey { - let key = CacheActivationNoiseKey(noPointer: .init()) - key.bytes = noiseBytes - return key - } -} - -private final class CacheActivationNoiseKey: ReceiverNoiseSecretKey, @unchecked Sendable { - var bytes = Data() - - override func exportBytes() -> Data { - bytes + override func exportPaykitIdentitySecretKey() -> PaykitIdentitySecretKey? { + nil } } @@ -440,11 +519,15 @@ private final class RecoverySdk: PaykitSdk, @unchecked Sendable { var onForget: () -> Void = {} override func identityStatus() async throws -> IdentityStatus? { - IdentityStatus(publicKey: nil, liveSessionAvailable: false) + IdentityStatus(publicKey: nil, capability: .signedOut) } - override func initialize() async throws -> InitializationReport { - InitializationReport(identity: IdentityStatus(publicKey: nil, liveSessionAvailable: false)) + override func paykitAppRegistry(publicKey _: String) async throws -> PaykitAppRegistry? { + nil + } + + override func initialize() async throws -> IdentityStatus { + IdentityStatus(publicKey: nil, capability: .signedOut) } override func backupStateRevision() async throws -> String { @@ -455,7 +538,7 @@ private final class RecoverySdk: PaykitSdk, @unchecked Sendable { onForget() try Keychain.delete(key: .paykitSession) try Keychain.delete(key: .pubkySecretKey) - return IdentityStatus(publicKey: nil, liveSessionAvailable: false) + return IdentityStatus(publicKey: nil, capability: .signedOut) } } @@ -465,14 +548,14 @@ private final class RecoveryBootstrap: PubkySessionBootstrap, @unchecked Sendabl override func importSession( sessionSecret _: String, localSecretKey _: PubkyLocalSecretKey?, - receiverNoiseSecretKey _: ReceiverNoiseSecretKey, requiredCapabilities _: String + requiredCapabilities _: String ) async throws -> PubkySessionBootstrapResult { try await importSessionOperation() return result } override func signIn( - localSecretKey _: PubkyLocalSecretKey, receiverNoiseSecretKey _: ReceiverNoiseSecretKey, requiredCapabilities _: String + localSecretKey _: PubkyLocalSecretKey, requiredCapabilities _: String ) async throws -> PubkySessionBootstrapResult { result } diff --git a/BitkitTests/PaykitStorageCallbackTests.swift b/BitkitTests/PaykitStorageCallbackTests.swift index 050152ef7..abe2707bd 100644 --- a/BitkitTests/PaykitStorageCallbackTests.swift +++ b/BitkitTests/PaykitStorageCallbackTests.swift @@ -3,38 +3,6 @@ import Paykit import XCTest final class PaykitStorageCallbackTests: XCTestCase { - private final class Storage: @unchecked Sendable { - var data: Data? - var failLoad = false - var failSave = false - - func load() throws -> Data? { - if failLoad { - throw KeychainError.failedToLoad - } - return data - } - - func save(_ data: Data) throws { - if failSave { - throw KeychainError.failedToSave - } - self.data = data - } - } - - private final class NoSession: SdkPubkySessionProvider, @unchecked Sendable { - func loadSessionAccess() throws -> PubkySessionAccess? { - nil - } - - func publicStorageAvailable() throws -> Bool { - false - } - - func clearSessionAccess() throws {} - } - private final class SessionStorage: @unchecked Sendable { var failLoad = false var failDelete = false @@ -56,17 +24,12 @@ final class PaykitStorageCallbackTests: XCTestCase { } func testNativeSdkCanRetryAfterProductionSessionLoadFailure() async throws { - let storage = Storage() let sessionStorage = SessionStorage() let provider = PaykitSdkSessionProvider( loadSessionSecret: sessionStorage.loadSessionSecret, deleteKeychainValue: sessionStorage.deleteKeychainValue ) - let sdk = try PaykitSdk( - stateStore: PaykitSdkStateBlobStore(loadData: storage.load, saveData: storage.save), - sessionProvider: provider, - config: Paykit.defaultConfig(receiverPath: PaykitReceiverPath.wallet) - ) + let sdk = try PaykitSdk.withPubkySharedState(sessionProvider: provider, config: Paykit.defaultConfig(appId: "bitkit")) sessionStorage.failLoad = true do { @@ -80,21 +43,15 @@ final class PaykitStorageCallbackTests: XCTestCase { let identity = try await sdk.identityStatus() XCTAssertNil(identity) _ = try await sdk.forgetSessionAccess() - XCTAssertNotNil(storage.data) } func testNativeSdkCanRetryAfterProductionSessionClearFailure() async throws { - let storage = Storage() let sessionStorage = SessionStorage() let provider = PaykitSdkSessionProvider( loadSessionSecret: sessionStorage.loadSessionSecret, deleteKeychainValue: sessionStorage.deleteKeychainValue ) - let sdk = try PaykitSdk( - stateStore: PaykitSdkStateBlobStore(loadData: storage.load, saveData: storage.save), - sessionProvider: provider, - config: Paykit.defaultConfig(receiverPath: PaykitReceiverPath.wallet) - ) + let sdk = try PaykitSdk.withPubkySharedState(sessionProvider: provider, config: Paykit.defaultConfig(appId: "bitkit")) sessionStorage.failDelete = true do { @@ -110,32 +67,7 @@ final class PaykitStorageCallbackTests: XCTestCase { let status = try await sdk.identityStatus() let identity = try XCTUnwrap(status) XCTAssertNil(identity.publicKey) - XCTAssertFalse(identity.liveSessionAvailable) - XCTAssertNotNil(storage.data) - } - - func testNativeSdkCanRetryAfterPlatformStorageFailure() async throws { - for failLoad in [false, true] { - let storage = Storage() - let store = PaykitSdkStateBlobStore(loadData: storage.load, saveData: storage.save) - let sdk = try PaykitSdk( - stateStore: store, sessionProvider: NoSession(), - config: Paykit.defaultConfig(receiverPath: PaykitReceiverPath.wallet) - ) - storage.failLoad = failLoad - storage.failSave = !failLoad - do { - _ = try await sdk.forgetSessionAccess() - XCTFail("Expected injected storage failure") - } catch let PaykitError.Storage(code, _) { - XCTAssertEqual(code, failLoad ? "state_load_failed" : "state_save_failed") - } - storage.failLoad = false - storage.failSave = false - _ = try await sdk.forgetSessionAccess() - _ = try await sdk.identityStatus() - XCTAssertNotNil(storage.data) - } + XCTAssertEqual(identity.capability, .signedOut) } func testPlatformFailuresBecomeDeclaredStorageErrorsAndSdkErrorsArePreserved() throws { diff --git a/BitkitTests/PaykitSubscriptionProposalTests.swift b/BitkitTests/PaykitSubscriptionProposalTests.swift index 20490f698..64f916edc 100644 --- a/BitkitTests/PaykitSubscriptionProposalTests.swift +++ b/BitkitTests/PaykitSubscriptionProposalTests.swift @@ -58,6 +58,8 @@ final class PaykitSubscriptionProposalTests: XCTestCase { endsAt: nil ), acceptedPaymentEndpointIdentifiers: ["bitcoin:regtest", "lightning:bolt11", "lightning:lnurl"], + paymentEndpoints: nil, + requiredAppId: "bitkit", conversion: nil, paymentDeadline: nil, metadata: PrivateJsonObject(text: String(decoding: data, as: UTF8.self)) diff --git a/BitkitTests/PaymentNavigationHelperTests.swift b/BitkitTests/PaymentNavigationHelperTests.swift index 340a411c4..e429c55eb 100644 --- a/BitkitTests/PaymentNavigationHelperTests.swift +++ b/BitkitTests/PaymentNavigationHelperTests.swift @@ -242,7 +242,6 @@ final class PaymentNavigationHelperTests: XCTestCase { private func incomingPaymentRequest(endpointIdentifier: String) throws -> PaykitPaymentRequest { let record = try PaymentRequestRecord( counterparty: "pubkycontact", - counterpartyReceiverPath: "bitkit/wallet", paymentRequestId: "550e8400-e29b-41d4-a716-446655440000", localRole: .payer, state: .proposed, @@ -250,12 +249,17 @@ final class PaymentNavigationHelperTests: XCTestCase { proposalOutboundMessageId: nil, proposalOutboundStatus: nil, proposalEventId: "650e8400-e29b-41d4-a716-446655440000", + proposalAppId: "bitkit", + payerAppId: nil, + executionClaimAppId: nil, terms: PaymentRequestTerms( amount: PaymentRequestAmount(value: "0.00001", asset: "btc"), paymentReference: PaymentReference(text: "invoice-123"), proposalExpiresAt: nil, recurrence: nil, acceptedPaymentEndpointIdentifiers: [endpointIdentifier], + paymentEndpoints: nil, + requiredAppId: "bitkit", conversion: nil, paymentDeadline: nil, metadata: PrivateJsonObject(text: "{}") diff --git a/BitkitTests/PrivatePaykitServiceTests.swift b/BitkitTests/PrivatePaykitServiceTests.swift index c47ec27c8..89d1cebf3 100644 --- a/BitkitTests/PrivatePaykitServiceTests.swift +++ b/BitkitTests/PrivatePaykitServiceTests.swift @@ -12,16 +12,6 @@ final class PrivatePaykitServiceTests: XCTestCase { snapshotAppDefaultsDomain() } - func testSupportedReceiverPathsPreserveSupportedOrderWhenMergingDiscoveredServerPath() async { - let service = PrivatePaykitService() - - let initiallySavedPaths = await service.supportedReceiverPaths([PaykitReceiverPath.wallet]) - let rediscoveredPaths = await service.supportedReceiverPaths(initiallySavedPaths + [PaykitReceiverPath.server]) - - XCTAssertEqual(initiallySavedPaths, [PaykitReceiverPath.wallet]) - XCTAssertEqual(rediscoveredPaths, [PaykitReceiverPath.wallet, PaykitReceiverPath.server]) - } - func testInitialLinkBurstUsesBoundedTwoSecondRetryCadence() { XCTAssertEqual(PrivatePaykitService.initialLinkBurstRetryDelays.count, 14) XCTAssertTrue(PrivatePaykitService.initialLinkBurstRetryDelays.allSatisfy { $0 == 2_000_000_000 }) @@ -92,21 +82,6 @@ final class PrivatePaykitServiceTests: XCTestCase { XCTAssertEqual(preparedContacts, contacts) } - func testReceiverNoiseDerivationMatchesCrossPlatformVector() { - let seed = ( - "c55257c360c07c72029aebc1b53c05ed0362ada38ead3e3e9efa3708e534955" + - "31f09a6987599d18264c1e1c92f2cf141630c7a3c4ab7c81b2f001698e7463b04" - ).hexaData - - let key = PaykitReceiverNoiseKeyDerivation.derive( - seed: seed, - network: "bitcoin", - receiverPath: PaykitReceiverPath.wallet - ) - - XCTAssertEqual(key.hex, "500f4799bbb2d02103e3b74b365ddb478a3187333c053fa9eb62f4052ba6a327") - } - private func withIsolatedDefaults(_ body: (UserDefaults) throws -> Void) throws { let suiteName = "PrivatePaykitServiceTests.\(UUID().uuidString)" let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) @@ -208,8 +183,7 @@ final class PrivatePaykitServiceTests: XCTestCase { let reservations = await service.reservations( from: [endpoint], - publicKey: publicKey, - receiverPath: PaykitReceiverPath.wallet + publicKey: publicKey ) XCTAssertEqual(reservations.count, 1) let reservation = try XCTUnwrap(reservations.first) @@ -217,7 +191,6 @@ final class PrivatePaykitServiceTests: XCTestCase { XCTAssertEqual(attribution["type"], "private_paykit") XCTAssertEqual(attribution["counterparty"], publicKey) - XCTAssertEqual(attribution["receiver_path"], PaykitReceiverPath.wallet) XCTAssertEqual(attribution["payment_hash"], "payment-hash") } @@ -241,35 +214,24 @@ final class PrivatePaykitServiceTests: XCTestCase { let firstReservations = await service.reservations( from: [firstEndpoint], - publicKey: publicKey, - receiverPath: PaykitReceiverPath.wallet + publicKey: publicKey ) let repeatedReservations = await service.reservations( from: [firstEndpoint], - publicKey: publicKey, - receiverPath: PaykitReceiverPath.wallet + publicKey: publicKey ) let secondReservations = await service.reservations( from: [secondEndpoint], - publicKey: publicKey, - receiverPath: PaykitReceiverPath.wallet - ) - let serverReservations = await service.reservations( - from: [firstEndpoint], - publicKey: publicKey, - receiverPath: PaykitReceiverPath.server + publicKey: publicKey ) 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) - XCTAssertNotEqual(firstReservation.reservationId, serverReservation.reservationId) - XCTAssertEqual(serverReservation.attribution["receiver_path"], PaykitReceiverPath.server) - XCTAssertTrue(firstReservation.reservationId.hasPrefix("\(publicKey):\(PaykitReceiverPath.wallet):\(firstEndpoint.methodId.rawValue):")) + XCTAssertTrue(firstReservation.reservationId.hasPrefix("\(publicKey):\(firstEndpoint.methodId.rawValue):")) XCTAssertLessThanOrEqual(firstReservation.reservationId.count, 128) } @@ -327,13 +289,13 @@ final class PrivatePaykitServiceTests: XCTestCase { endpointData: #"{"value":"lnbc1cached"}"# ), ] - contactState.localInvoicesByReceiverPath[PaykitReceiverPath.wallet] = PrivatePaykitService.StoredInvoice( + contactState.localInvoice = PrivatePaykitService.StoredInvoice( bolt11: "lnbc1local", paymentHash: "hash", expiresAt: 123 ) contactState.receivedInvoicePaymentHashes = ["received-hash"] - contactState.publishedPrivatePaymentReceiverPaths = [PaykitReceiverPath.wallet] + contactState.hasPublishedPrivatePaymentList = true let state = PrivatePaykitService.PrivatePaykitState(contacts: [publicKey: contactState]) let data = try JSONEncoder().encode(state) @@ -346,11 +308,11 @@ 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.localInvoicesByReceiverPath[PaykitReceiverPath.wallet]?.bolt11, "lnbc1local") - XCTAssertEqual(decodedContact.localInvoicesByReceiverPath[PaykitReceiverPath.wallet]?.paymentHash, "hash") - XCTAssertEqual(decodedContact.localInvoicesByReceiverPath[PaykitReceiverPath.wallet]?.expiresAt, 123) + XCTAssertEqual(decodedContact.localInvoice?.bolt11, "lnbc1local") + XCTAssertEqual(decodedContact.localInvoice?.paymentHash, "hash") + XCTAssertEqual(decodedContact.localInvoice?.expiresAt, 123) XCTAssertEqual(decodedContact.receivedInvoicePaymentHashes, ["received-hash"]) - XCTAssertEqual(decodedContact.publishedPrivatePaymentReceiverPaths, [PaykitReceiverPath.wallet]) + XCTAssertEqual(decodedContact.hasPublishedPrivatePaymentList, true) } func testConsumingPrivatePaymentListClearsEndpointsAndRejectsSameVersionForPair() async throws { @@ -374,7 +336,7 @@ final class PrivatePaykitServiceTests: XCTestCase { max: nil, rawPayload: #"{"value":"lnurl1private"}"# ) - let context = PrivatePaykitPaymentContext(receiverPath: PaykitReceiverPath.wallet, paymentListVersion: 7) + let context = PrivatePaykitPaymentContext(paymentAppsByEndpoint: [:], paymentListVersion: 7) let attemptId = UUID() await service.cacheResolvedEndpoints([endpoint], publicKey: publicKey) @@ -382,7 +344,7 @@ final class PrivatePaykitServiceTests: XCTestCase { let contactState = await service.testContactState(publicKey: publicKey) XCTAssertTrue(contactState?.cachedResolvedEndpoints.isEmpty == true) - XCTAssertEqual(contactState?.consumedPrivatePaymentListVersionsByReceiverPath[PaykitReceiverPath.wallet], 7) + XCTAssertEqual(contactState?.consumedPrivatePaymentListVersion, 7) do { try await service.consumePrivatePaymentList(publicKey: publicKey, context: context, attemptId: UUID()) @@ -393,17 +355,16 @@ final class PrivatePaykitServiceTests: XCTestCase { } func testPaymentListConsumptionResolutionReleasesOnlyDefinitePreBroadcastFailures() async throws { - try await withIsolatedPrivatePaykitState { service in - let publicKey = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" - let outcomes: [(String, PrivatePaymentListSendOutcome, UInt64)] = [ - ("success", .succeeded, 7), - ("uncertain", .uncertain, 7), - ("definite-failure", .definitePreBroadcastFailure, 4), - ] - - for (receiverPath, outcome, expectedVersion) in outcomes { - let previousContext = PrivatePaykitPaymentContext(receiverPath: receiverPath, paymentListVersion: 4) - let attemptedContext = PrivatePaykitPaymentContext(receiverPath: receiverPath, paymentListVersion: 7) + let outcomes: [(PrivatePaymentListSendOutcome, UInt64)] = [ + (.succeeded, 7), + (.uncertain, 7), + (.definitePreBroadcastFailure, 4), + ] + for (outcome, expectedVersion) in outcomes { + try await withIsolatedPrivatePaykitState { service in + let publicKey = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" + let previousContext = PrivatePaykitPaymentContext(paymentAppsByEndpoint: [:], paymentListVersion: 4) + let attemptedContext = PrivatePaykitPaymentContext(paymentAppsByEndpoint: [:], paymentListVersion: 7) let previousAttemptId = UUID() try await service.consumePrivatePaymentList(publicKey: publicKey, context: previousContext, attemptId: previousAttemptId) try await service.resolvePrivatePaymentListConsumption( @@ -423,7 +384,7 @@ final class PrivatePaykitServiceTests: XCTestCase { ) let contactState = await service.testContactState(publicKey: publicKey) - XCTAssertEqual(contactState?.consumedPrivatePaymentListVersionsByReceiverPath[receiverPath], expectedVersion) + XCTAssertEqual(contactState?.consumedPrivatePaymentListVersion, expectedVersion) if outcome == .definitePreBroadcastFailure { try await service.consumePrivatePaymentList( @@ -438,7 +399,7 @@ final class PrivatePaykitServiceTests: XCTestCase { outcome: .definitePreBroadcastFailure ) let stateAfterDelayedRelease = await service.testContactState(publicKey: publicKey) - XCTAssertEqual(stateAfterDelayedRelease?.consumedPrivatePaymentListVersionsByReceiverPath[receiverPath], 7) + XCTAssertEqual(stateAfterDelayedRelease?.consumedPrivatePaymentListVersion, 7) } else { do { try await service.consumePrivatePaymentList( @@ -458,9 +419,8 @@ final class PrivatePaykitServiceTests: XCTestCase { func testStalePaymentListReleaseDoesNotUndoNewerConsumption() async throws { try await withIsolatedPrivatePaykitState { service in let publicKey = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" - let receiverPath = PaykitReceiverPath.wallet - let olderContext = PrivatePaykitPaymentContext(receiverPath: receiverPath, paymentListVersion: 7) - let newerContext = PrivatePaykitPaymentContext(receiverPath: receiverPath, paymentListVersion: 8) + let olderContext = PrivatePaykitPaymentContext(paymentAppsByEndpoint: [:], paymentListVersion: 7) + let newerContext = PrivatePaykitPaymentContext(paymentAppsByEndpoint: [:], paymentListVersion: 8) let olderAttemptId = UUID() let newerAttemptId = UUID() @@ -473,7 +433,7 @@ final class PrivatePaykitServiceTests: XCTestCase { outcome: .definitePreBroadcastFailure ) let stateAfterStaleRelease = await service.testContactState(publicKey: publicKey) - XCTAssertEqual(stateAfterStaleRelease?.consumedPrivatePaymentListVersionsByReceiverPath[receiverPath], 8) + XCTAssertEqual(stateAfterStaleRelease?.consumedPrivatePaymentListVersion, 8) try await service.resolvePrivatePaymentListConsumption( publicKey: publicKey, @@ -482,7 +442,7 @@ final class PrivatePaykitServiceTests: XCTestCase { outcome: .definitePreBroadcastFailure ) let stateAfterCurrentRelease = await service.testContactState(publicKey: publicKey) - XCTAssertEqual(stateAfterCurrentRelease?.consumedPrivatePaymentListVersionsByReceiverPath[receiverPath], 7) + XCTAssertEqual(stateAfterCurrentRelease?.consumedPrivatePaymentListVersion, 7) } } @@ -507,27 +467,27 @@ final class PrivatePaykitServiceTests: XCTestCase { max: nil, rawPayload: #"{"value":"lnurl1private"}"# ) - let context = PrivatePaykitPaymentContext(receiverPath: PaykitReceiverPath.server, paymentListVersion: 9) + let context = PrivatePaykitPaymentContext(paymentAppsByEndpoint: [:], paymentListVersion: 9) await service.cacheResolvedEndpoints([endpoint], publicKey: publicKey) try await service.consumePrivatePaymentList(publicKey: publicKey, context: context, attemptId: UUID()) await service.clearContactState(publicKey: publicKey) let contactState = await service.testContactState(publicKey: publicKey) - XCTAssertEqual(contactState?.consumedPrivatePaymentListVersionsByReceiverPath, [PaykitReceiverPath.server: 9]) + XCTAssertEqual(contactState?.consumedPrivatePaymentListVersion, 9) XCTAssertFalse(contactState?.hasContactOwnedCacheState == true) } - func testPrivatePaymentRecoveryUsesRequestedReceiverPath() async { + func testPrivatePaymentRecoveryUsesCounterpartyIdentity() async { let service = PrivatePaykitService() let publicKey = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" - await service.schedulePrivatePaymentRecovery(for: publicKey, receiverPath: PaykitReceiverPath.server) + await service.schedulePrivatePaymentRecovery(for: publicKey) let retryKeys = await service.testPendingMessageDrainRetryKeys() XCTAssertEqual( retryKeys, - [PrivateMessageDrainRetryKey(publicKey: publicKey, receiverPath: PaykitReceiverPath.server)] + [publicKey] ) await service.clearTestPendingMessageDrainRetries() } @@ -550,14 +510,12 @@ final class PrivatePaykitServiceTests: XCTestCase { endpointData: #"{"value":"lnbc1private"}"# ), ] - contactState.localInvoicesByReceiverPath = [ - PaykitReceiverPath.wallet: PrivatePaykitService.StoredInvoice( - bolt11: "lnbc1private", - paymentHash: "payment-hash", - expiresAt: 123 - ), - ] - contactState.publishedPrivatePaymentReceiverPaths = [PaykitReceiverPath.wallet] + contactState.localInvoice = PrivatePaykitService.StoredInvoice( + bolt11: "lnbc1private", + paymentHash: "payment-hash", + expiresAt: 123 + ) + contactState.hasPublishedPrivatePaymentList = true await service.setTestContactState(contactState, publicKey: successfulPublicKey) await service.setTestContactState(contactState, publicKey: failedPublicKey) PrivatePaykitService.markDeletedContactCleanupPending([successfulPublicKey, failedPublicKey]) @@ -575,10 +533,49 @@ final class PrivatePaykitServiceTests: XCTestCase { XCTAssertEqual(PrivatePaykitService.pendingDeletedContactCleanupKeys(), [failedPublicKey]) } - func testImmediatePublicationContinuesAfterMarkerInspectionFailure() async throws { + func testCleanupSkipsNeverLinkedContactsWithoutPublishedDetails() async { + let service = PrivatePaykitService() + var publishedState = PrivatePaykitService.ContactState() + publishedState.hasPublishedPrivatePaymentList = true + await service.setTestContactState(publishedState, publicKey: "published") + + let keys = await service.privatePaymentListCleanupKeys( + ["pubky-only", "linked", "published"], + linkedPublicKeys: ["linked"] + ) + + XCTAssertEqual(keys, ["linked", "published"]) + } + + func testImmediatePublicationSkipsContactsWithoutPaykit() async { + let service = PrivatePaykitService() + let operations = PrivatePaykitService.EndpointPublicationOperations( + currentPublicKey: { "pubkylocal" }, + ensureLink: { _ in throw PaykitError.NotFound(code: "not_found", context: "No App Registry") }, + buildEndpoints: { _ in + XCTFail("An unsupported contact should not reserve wallet addresses") + return [] + }, + syncPaymentLists: { _ in + XCTFail("An unsupported contact should not receive a private list") + return PrivatePaymentListDeliveryReport(queued: [], cleared: [], failedToQueue: [], failedToDeliver: []) + } + ) + + let error = await service.syncLocalEndpointPublication( + for: ["pubky1rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg"], + reason: "test", + requireImmediatePublication: true, + operations: operations + ) + + XCTAssertNil(error) + } + + func testImmediatePublicationContinuesAfterEndpointPreparationFailure() async throws { let failedPublicKey = "pubky1rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" let successfulPublicKey = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" - let markerError = NSError(domain: "PrivatePaykitServiceTests", code: 1) + let preparationError = NSError(domain: "PrivatePaykitServiceTests", code: 1) let endpoint = PublicPaykitService.Endpoint( methodId: .regtestOnchainP2wpkh, value: "bcrt1qendpoint", @@ -589,30 +586,10 @@ final class PrivatePaykitServiceTests: XCTestCase { var syncedUpdates = [PrivatePaymentListReservationUpdateInput]() let operations = PrivatePaykitService.EndpointPublicationOperations( currentPublicKey: { "pubkylocal" }, - linkedReceiverPaths: { _ in ([:], nil) }, - receiverPaths: { _ in [PaykitReceiverPath.wallet] }, - receiverPathSelection: { publicKey, _ in - if publicKey == failedPublicKey { - return PrivateReceiverPathSelection( - linkableReceiverPaths: [], - publishableReceiverPaths: [], - cleanupProtectedReceiverPaths: [PaykitReceiverPath.wallet], - error: markerError - ) - } - return PrivateReceiverPathSelection( - linkableReceiverPaths: [], - publishableReceiverPaths: [PaykitReceiverPath.wallet], - cleanupProtectedReceiverPaths: [], - error: nil - ) - }, - ensureLink: { _, _ in - XCTFail("No link should be prepared for this fixture") - }, - buildEndpoints: { publicKey, receiverPath in + ensureLink: { _ in }, + buildEndpoints: { publicKey in + if publicKey == failedPublicKey { throw preparationError } XCTAssertEqual(publicKey, successfulPublicKey) - XCTAssertEqual(receiverPath, PaykitReceiverPath.wallet) return [endpoint] }, syncPaymentLists: { updates in @@ -634,27 +611,20 @@ final class PrivatePaykitServiceTests: XCTestCase { operations: operations ) - XCTAssertNil(error) + XCTAssertNotNil(error) XCTAssertEqual(syncedUpdates.count, 1) let update = try XCTUnwrap(syncedUpdates.first) XCTAssertEqual(update.counterparty, successfulPublicKey) - XCTAssertEqual(update.counterpartyReceiverPath, PaykitReceiverPath.wallet) XCTAssertFalse(update.reservations.isEmpty) } - func testDeferredPublicationIgnoresReceiverPathSelectionFailure() async { + func testDeferredPublicationRetainsEndpointPreparationFailureForRetry() async { let publicKey = "pubky1rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" - let markerError = NSError(domain: "PrivatePaykitServiceTests", code: 1) + let preparationError = NSError(domain: "PrivatePaykitServiceTests", code: 1) let operations = PrivatePaykitService.EndpointPublicationOperations( currentPublicKey: { "pubkylocal" }, - linkedReceiverPaths: { _ in ([:], nil) }, - receiverPaths: { _ in [PaykitReceiverPath.wallet] }, - receiverPathSelection: { _, _ in throw markerError }, - ensureLink: { _, _ in XCTFail("No link should be prepared") }, - buildEndpoints: { _, _ in - XCTFail("No endpoint should be built") - return [] - }, + ensureLink: { _ in }, + buildEndpoints: { _ in throw preparationError }, syncPaymentLists: { _ in XCTFail("No payment list should be synced") return PrivatePaymentListDeliveryReport( @@ -701,14 +671,14 @@ private extension PrivatePaykitService { func setTestLocalInvoice(_ invoice: StoredInvoice, publicKey: String) { state.contacts[publicKey] = ContactState() - state.contacts[publicKey]?.localInvoicesByReceiverPath[PaykitReceiverPath.wallet] = invoice + state.contacts[publicKey]?.localInvoice = invoice } func testContactState(publicKey: String) -> ContactState? { state.contacts[publicKey] } - func testPendingMessageDrainRetryKeys() -> Set { + func testPendingMessageDrainRetryKeys() -> Set { pendingMessageDrainRetryKeys } diff --git a/BitkitTests/PubkyAuthApprovalSheetTests.swift b/BitkitTests/PubkyAuthApprovalSheetTests.swift index 1d92cda3d..3a5e59cc9 100644 --- a/BitkitTests/PubkyAuthApprovalSheetTests.swift +++ b/BitkitTests/PubkyAuthApprovalSheetTests.swift @@ -9,7 +9,7 @@ private let approvalTestXpub = private let approvalTestClientPublicKey = "5jsjx1o6fzu6aeeo697r3i5rx15zq41kikcye8wtwdqm4nb4tryo" private func approvalTestAuthUrl(secret: String = "e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3s") -> String { - "pubkyauth://signin_grant?caps=\(PubkyAuthClaim.watchOnlyAccountCapabilities)" + + "pubkyauth://signin_grant?caps=\(PubkyAuthClaim.requiredCapabilities)" + "&relay=https://httprelay.pubky.app/inbox/&secret=\(secret)" + "&cid=paykit.test&cpk=\(approvalTestClientPublicKey)" + "&x-bitkit-claim=watch-only-account-v1" @@ -47,6 +47,79 @@ final class PubkyAuthApprovalSheetTests: XCTestCase { XCTAssertEqual(PubkyAuthApprovalSheet.initialState(for: request), .authorize) } + @MainActor + func testPaykitOnlyApprovalDoesNotCreateOrTrackAnAccount() async throws { + let suiteName = "PubkyAuthApprovalSheetTests.\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) + defer { defaults.removePersistentDomain(forName: suiteName) } + let node = ApprovalFakeWatchOnlyAccountNode() + let manager = Bitkit.WatchOnlyAccountManager(defaults: defaults, node: node) + let authUrl = approvalTestAuthUrl().replacingOccurrences(of: "watch-only-account-v1", with: "paykit-access-v1") + let request = try PubkyAuthRequest.parse(url: authUrl) + XCTAssertEqual(PubkyAuthApprovalSheet.initialState(for: request), .authorize) + var approved = false + try await PubkyService.approveAuthRequest( + request: request, authUrl: authUrl, accountName: "", secretKeyHex: "secret", + accountManager: manager, + ordinaryApproval: { _, _, _, _ in XCTFail("Paykit access requires explicit companion approval") }, + companionApproval: { _, _, claim, payload, _ in + XCTAssertEqual(claim, .paykitAccessV1) + XCTAssertNil(payload) + approved = true + } + ) + XCTAssertTrue(approved) + XCTAssertTrue(manager.accounts.isEmpty) + XCTAssertTrue(node.trackingChanges.isEmpty) + XCTAssertTrue(try Bitkit.WatchOnlyAccountStore.load(defaults: defaults).isEmpty) + } + + @MainActor + func testPaykitOnlyReconnectPreservesAccountThroughFailureAndRetry() async throws { + let suiteName = "PubkyAuthApprovalSheetTests.\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) + defer { defaults.removePersistentDomain(forName: suiteName) } + let node = ApprovalFakeWatchOnlyAccountNode() + let manager = Bitkit.WatchOnlyAccountManager(defaults: defaults, node: node) + let firstUrl = approvalTestAuthUrl() + try await PubkyService.approveAuthRequest( + request: PubkyAuthRequest.parse(url: firstUrl), authUrl: firstUrl, + accountName: "Original server", secretKeyHex: "secret", accountManager: manager, + companionApproval: { _, _, _, _, _ in } + ) + let original = try XCTUnwrap(manager.accounts.first) + let snapshot = try Bitkit.WatchOnlyAccountStore.backupSnapshot(defaults: defaults) + let reconnectUrl = approvalTestAuthUrl(secret: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA") + .replacingOccurrences(of: "watch-only-account-v1", with: "paykit-access-v1") + let reconnectRequest = try PubkyAuthRequest.parse(url: reconnectUrl) + for failure in [ApprovalFakeError.deliveryFailed as Error, CancellationError()] { + await XCTAssertThrowsErrorAsync { + try await PubkyService.approveAuthRequest( + request: reconnectRequest, authUrl: reconnectUrl, + accountName: "Untrusted request name", secretKeyHex: "secret", + accountManager: manager, + companionApproval: { _, _, claim, payload, _ in + XCTAssertTrue(claim.includesPaykitAccess) + XCTAssertFalse(claim.includesWatchOnlyAccount) + XCTAssertNil(payload) + throw failure + } + ) + } + XCTAssertEqual(manager.accounts, [original]) + XCTAssertEqual(try Bitkit.WatchOnlyAccountStore.load(defaults: defaults), [original]) + } + try await PubkyService.approveAuthRequest( + request: reconnectRequest, authUrl: reconnectUrl, + accountName: "Untrusted request name", secretKeyHex: "secret", + accountManager: manager, + companionApproval: { _, _, _, _, _ in } + ) + XCTAssertEqual(manager.accounts, [original]) + XCTAssertEqual(node.trackingChanges, [true]) + XCTAssertEqual(try Bitkit.WatchOnlyAccountStore.backupSnapshot(defaults: defaults).allocationState, snapshot.allocationState) + } + @MainActor func testOrdinaryRequestUsesOrdinaryApproval() async throws { let authUrl = ordinaryApprovalTestAuthUrl() @@ -63,7 +136,7 @@ final class PubkyAuthApprovalSheetTests: XCTestCase { approvedCapabilities = capabilities approvedClientID = clientID }, - companionApproval: { _, _, _, _ in XCTFail("Ordinary auth must not deliver a companion claim") } + companionApproval: { _, _, _, _, _ in XCTFail("Ordinary auth must not deliver a companion claim") } ) XCTAssertEqual(approvedCapabilities, "/pub/example/:rw") @@ -131,7 +204,7 @@ final class PubkyAuthApprovalSheetTests: XCTestCase { secretKeyHex: "secret", accountManager: manager, ordinaryApproval: { _, _, _, _ in ordinaryApprovalCount += 1 }, - companionApproval: { _, _, _, _ in + companionApproval: { _, _, _, _, _ in companionApprovalCount += 1 throw ApprovalFakeError.deliveryFailed } @@ -164,7 +237,7 @@ final class PubkyAuthApprovalSheetTests: XCTestCase { accountName: "Creator store", secretKeyHex: "secret", accountManager: manager, - companionApproval: { _, _, _, _ in } + companionApproval: { _, _, _, _, _ in } ) XCTAssertEqual(manager.accounts.first?.setupState, .active) @@ -210,7 +283,7 @@ final class PubkyAuthApprovalSheetTests: XCTestCase { accountName: "First account", secretKeyHex: "secret", accountManager: manager, - companionApproval: { _, _, _, _ in await companionApprovalGate.approve() } + companionApproval: { _, _, _, _, _ in await companionApprovalGate.approve() } ) } try await companionApprovalGate.waitUntilFirstApprovalStarts() @@ -223,7 +296,7 @@ final class PubkyAuthApprovalSheetTests: XCTestCase { accountName: "Replacement account", secretKeyHex: "secret", accountManager: manager, - companionApproval: { _, _, _, _ in XCTFail("Concurrent companion approval must not start") } + companionApproval: { _, _, _, _, _ in XCTFail("Concurrent companion approval must not start") } ) XCTFail("Expected concurrent authorization to be rejected") } catch { @@ -244,7 +317,7 @@ final class PubkyAuthApprovalSheetTests: XCTestCase { accountName: "Second account", secretKeyHex: "secret", accountManager: manager, - companionApproval: { _, _, _, _ in } + companionApproval: { _, _, _, _, _ in } ) XCTAssertEqual(manager.accounts.map(\.setupState), [.active, .active]) @@ -269,7 +342,7 @@ final class PubkyAuthApprovalSheetTests: XCTestCase { accountName: "Creator store", secretKeyHex: "secret", accountManager: manager, - companionApproval: { _, _, _, _ in + companionApproval: { _, _, _, _, _ in throw Paykit.PubkyAuthCompanionClaimApprovalError.AuthorizationFailure(reason: "normal auth failed") } ) @@ -298,7 +371,7 @@ final class PubkyAuthApprovalSheetTests: XCTestCase { accountName: "Creator store", secretKeyHex: "secret", accountManager: manager, - companionApproval: { _, _, _, _ in + companionApproval: { _, _, _, _, _ in throw Paykit.PubkyAuthCompanionClaimApprovalError.AuthorizationFailure(reason: "normal auth failed") } ) @@ -311,7 +384,7 @@ final class PubkyAuthApprovalSheetTests: XCTestCase { accountName: "Creator store", secretKeyHex: "secret", accountManager: manager, - companionApproval: { _, _, _, _ in throw ApprovalFakeError.deliveryFailed } + companionApproval: { _, _, _, _, _ in throw ApprovalFakeError.deliveryFailed } ) } @@ -339,8 +412,8 @@ final class PubkyAuthApprovalSheetTests: XCTestCase { accountName: "Creator store", secretKeyHex: "secret", accountManager: initialManager, - companionApproval: { _, _, payload, _ in - deliveredPayloads.append(payload) + companionApproval: { _, _, _, payload, _ in + try deliveredPayloads.append(XCTUnwrap(payload)) throw Paykit.PubkyAuthCompanionClaimApprovalError.AuthorizationFailure(reason: "normal auth failed") } ) @@ -357,7 +430,7 @@ final class PubkyAuthApprovalSheetTests: XCTestCase { accountName: "Creator store", secretKeyHex: "secret", accountManager: restartedManager, - companionApproval: { _, _, payload, _ in deliveredPayloads.append(payload) } + companionApproval: { _, _, _, payload, _ in try deliveredPayloads.append(XCTUnwrap(payload)) } ) let activeAccount = try XCTUnwrap(restartedManager.accounts.first) @@ -391,7 +464,7 @@ final class PubkyAuthApprovalSheetTests: XCTestCase { accountName: "Creator store", secretKeyHex: "secret", accountManager: manager, - companionApproval: { _, _, _, _ in companionApprovalCount += 1 } + companionApproval: { _, _, _, _, _ in companionApprovalCount += 1 } ) } @@ -421,7 +494,7 @@ final class PubkyAuthApprovalSheetTests: XCTestCase { accountName: "Creator store", secretKeyHex: "secret", accountManager: manager, - companionApproval: { _, _, _, _ in + companionApproval: { _, _, _, _, _ in await companionApprovalGate.approve() try Task.checkCancellation() } diff --git a/BitkitTests/PubkyAuthRequestTests.swift b/BitkitTests/PubkyAuthRequestTests.swift index 4dd3ca1a7..5a10e6f88 100644 --- a/BitkitTests/PubkyAuthRequestTests.swift +++ b/BitkitTests/PubkyAuthRequestTests.swift @@ -17,7 +17,7 @@ final class PubkyAuthRequestTests: XCTestCase { } func testProtocolUrlNormalizesBitkitSpecificSetupHandoff() throws { - let url = "bitkit://pubky-auth/setup?caps=\(PubkyAuthClaim.watchOnlyAccountCapabilities)" + + let url = "bitkit://pubky-auth/setup?caps=\(PubkyAuthClaim.requiredCapabilities)" + "&relay=\(relay)&secret=\(secret)&cid=paykit.test&cpk=\(publicKey)&x-bitkit-claim=watch-only-account-v1" XCTAssertTrue(PubkyAuthRequest.isProtocolURL(url)) @@ -26,11 +26,11 @@ final class PubkyAuthRequestTests: XCTestCase { XCTAssertTrue(request.rawUrl.hasPrefix("pubkyauth://signin_grant?")) XCTAssertEqual(request.bitkitClaim, .watchOnlyAccountV1) - XCTAssertEqual(request.capabilities, PubkyAuthClaim.watchOnlyAccountCapabilities) + XCTAssertEqual(request.capabilities, PubkyAuthClaim.requiredCapabilities) } func testRelayOriginShowsOnlyTheAuthorizationDestination() throws { - let url = "bitkit://pubky-auth/setup?caps=\(PubkyAuthClaim.watchOnlyAccountCapabilities)" + + let url = "bitkit://pubky-auth/setup?caps=\(PubkyAuthClaim.requiredCapabilities)" + "&relay=https%3A%2F%2FRelay.Example%3A8443%2Finbox%2F&secret=\(secret)" + "&cid=paykit.test&cpk=\(publicKey)&x-bitkit-claim=watch-only-account-v1" @@ -40,7 +40,7 @@ final class PubkyAuthRequestTests: XCTestCase { } func testProtocolUrlRejectsBitkitSpecificSetupHandoffWithoutClaimMarker() { - let url = "bitkit://pubky-auth/setup?caps=\(PubkyAuthClaim.watchOnlyAccountCapabilities)" + + let url = "bitkit://pubky-auth/setup?caps=\(PubkyAuthClaim.requiredCapabilities)" + "&relay=\(relay)&secret=\(secret)&cid=paykit.test&cpk=\(publicKey)" XCTAssertThrowsError(try PubkyAuthRequest.parse(url: url)) { @@ -200,7 +200,7 @@ final class PubkyAuthRequestTests: XCTestCase { } func testParseUrlRecognizesWatchOnlyAccountClaim() throws { - let capabilities = PubkyAuthClaim.watchOnlyAccountCapabilities + let capabilities = PubkyAuthClaim.requiredCapabilities let url = authUrl(capabilities: capabilities, claimValues: [PubkyAuthClaim.watchOnlyAccountV1.rawValue]) let request = try PubkyAuthRequest.parse(url: url) @@ -208,22 +208,51 @@ final class PubkyAuthRequestTests: XCTestCase { XCTAssertEqual(request.bitkitClaim, .watchOnlyAccountV1) } - func testParseUrlRecognizesWatchOnlyAccountClaimWithReorderedCapabilities() throws { - let capabilities = PubkyAuthClaim.watchOnlyAccountCapabilities - .split(separator: ",") - .reversed() - .joined(separator: ",") - let url = authUrl(capabilities: capabilities, claimValues: [PubkyAuthClaim.watchOnlyAccountV1.rawValue]) + func testParseUrlRecognizesIndependentPaykitClaimRequests() throws { + for value in [ + "paykit-access-v1", + "paykit-access-v1.watch-only-account-v1", + "watch-only-account-v1.paykit-access-v1", + ] { + let url = authUrl(capabilities: PubkyAuthClaim.requiredCapabilities, claimValues: [value]) + let request = try PubkyAuthRequest.parse(url: url) + let claim = try XCTUnwrap(request.bitkitClaim) + XCTAssertEqual(claim.rawValue, value) + XCTAssertTrue(claim.includesPaykitAccess) + XCTAssertEqual(claim.includesWatchOnlyAccount, value.contains("watch-only-account-v1")) + XCTAssertThrowsError(try PubkyAuthRequest.parse(url: authUrl(capabilities: "/pub/:rw", claimValues: [claim.rawValue]))) + XCTAssertThrowsError(try PubkyAuthRequest.parse(url: authUrl( + capabilities: PubkyAuthClaim.requiredCapabilities, + claimValues: [claim.rawValue, PubkyAuthClaim.watchOnlyAccountV1.rawValue] + ))) + } + } - let request = try PubkyAuthRequest.parse(url: url) + func testParseUrlRejectsInvalidClaimItems() { + for value in [ + "", ".", "paykit-access-v1.", ".watch-only-account-v1", + "paykit-access-v1..watch-only-account-v1", + "paykit-access-v1.paykit-access-v1", + "watch-only-account-v1.watch-only-account-v1", + "paykit-access-v1.unknown-v1", + ] { + XCTAssertThrowsError(try PubkyAuthRequest.parse(url: authUrl( + capabilities: PubkyAuthClaim.requiredCapabilities, + claimValues: [value] + )), value) + } + } - XCTAssertEqual(request.bitkitClaim, .watchOnlyAccountV1) + func testWatchOnlyCapabilityMatcherAllowsRepeatedRequiredCapability() { + let capabilities = "\(PubkyAuthClaim.requiredCapabilities), \(PubkyAuthClaim.requiredCapabilities)" + + XCTAssertTrue(PubkyAuthClaim.matchesRequiredCapabilities(capabilities)) } func testWatchOnlyCapabilityMatcherAllowsWhitespace() { - let capabilities = PubkyAuthClaim.watchOnlyAccountCapabilities.replacingOccurrences(of: ",", with: " , ") + let capabilities = " \(PubkyAuthClaim.requiredCapabilities) " - XCTAssertTrue(PubkyAuthClaim.matchesWatchOnlyAccountCapabilities(capabilities)) + XCTAssertTrue(PubkyAuthClaim.matchesRequiredCapabilities(capabilities)) } func testParseUrlWithoutBitkitClaimPreservesNormalAuth() throws { @@ -232,17 +261,15 @@ final class PubkyAuthRequestTests: XCTestCase { XCTAssertNil(request.bitkitClaim) } - func testParseUrlRejectsWatchOnlyCapabilityWithoutClaim() { - let url = authUrl(capabilities: PubkyAuthClaim.watchOnlyAccountCapabilities) + func testParseUrlAllowsPaykitSessionWithoutCompanionClaim() throws { + let url = authUrl(capabilities: PubkyAuthClaim.requiredCapabilities) - XCTAssertThrowsError(try PubkyAuthRequest.parse(url: url)) { - XCTAssertEqual($0 as? PubkyAuthRequestError, .missingBitkitClaim) - } + XCTAssertNil(try PubkyAuthRequest.parse(url: url).bitkitClaim) } func testParseUrlRejectsDuplicateBitkitClaim() { let url = authUrl( - capabilities: PubkyAuthClaim.watchOnlyAccountCapabilities, + capabilities: PubkyAuthClaim.requiredCapabilities, claimValues: [PubkyAuthClaim.watchOnlyAccountV1.rawValue, PubkyAuthClaim.watchOnlyAccountV1.rawValue] ) @@ -268,7 +295,7 @@ final class PubkyAuthRequestTests: XCTestCase { } func testParseUrlRejectsUnknownBitkitClaim() { - let url = authUrl(capabilities: PubkyAuthClaim.watchOnlyAccountCapabilities, claimValues: ["unknown-v1"]) + let url = authUrl(capabilities: PubkyAuthClaim.requiredCapabilities, claimValues: ["unknown-v1"]) XCTAssertThrowsError(try PubkyAuthRequest.parse(url: url)) { XCTAssertEqual($0 as? PubkyAuthRequestError, .unsupportedBitkitClaim("unknown-v1")) @@ -283,8 +310,8 @@ final class PubkyAuthRequestTests: XCTestCase { } } - func testParseUrlRejectsWatchOnlyClaimWithoutPrivateCapability() { - let capabilities = "/pub/paykit/v0/bitkit/server/:rw" + func testParseUrlRejectsWatchOnlyClaimWithoutWriteCapability() { + let capabilities = "/pub/paykit/:r" let url = authUrl(capabilities: capabilities, claimValues: [PubkyAuthClaim.watchOnlyAccountV1.rawValue]) XCTAssertThrowsError(try PubkyAuthRequest.parse(url: url)) { @@ -293,9 +320,9 @@ final class PubkyAuthRequestTests: XCTestCase { } func testWatchOnlyCapabilityMatcherRejectsEmptyCapability() { - let capabilities = "\(PubkyAuthClaim.watchOnlyAccountCapabilities)," - - XCTAssertFalse(PubkyAuthClaim.matchesWatchOnlyAccountCapabilities(capabilities)) + for capabilities in ["", " ", "\(PubkyAuthClaim.requiredCapabilities),"] { + XCTAssertFalse(PubkyAuthClaim.matchesRequiredCapabilities(capabilities)) + } } // MARK: - parseCapabilities @@ -394,8 +421,8 @@ final class PubkyAuthRequestTests: XCTestCase { // MARK: - PubkyAuthPermission display func testDisplayPathRemovesCapabilitySeparator() { - let permission = PubkyAuthPermission(path: "/pub/paykit/v0/bitkit/server/", accessLevel: "rw") - XCTAssertEqual(permission.displayPath, "/pub/paykit/v0/bitkit/server") + let permission = PubkyAuthPermission(path: "/pub/paykit/", accessLevel: "rw") + XCTAssertEqual(permission.displayPath, "/pub/paykit") } func testDisplayPathPreservesRoot() { diff --git a/BitkitTests/PubkyAuthURLSchemeTests.swift b/BitkitTests/PubkyAuthURLSchemeTests.swift index 24ec4e648..19b72a5e4 100644 --- a/BitkitTests/PubkyAuthURLSchemeTests.swift +++ b/BitkitTests/PubkyAuthURLSchemeTests.swift @@ -41,7 +41,7 @@ final class PubkyAuthURLSchemeTests: XCTestCase { let sheets = SheetViewModel() let app = AppViewModel(sheetViewModel: sheets, navigationViewModel: NavigationViewModel()) - let url = try XCTUnwrap(URL(string: "bitkit://pubky-auth/setup?caps=\(PubkyAuthClaim.watchOnlyAccountCapabilities)" + + let url = try XCTUnwrap(URL(string: "bitkit://pubky-auth/setup?caps=\(PubkyAuthClaim.requiredCapabilities)" + "&relay=https%3A%2F%2Fhttprelay.pubky.app%2Finbox%2F" + "&secret=e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3s\(grantRequester)&x-bitkit-claim=watch-only-account-v1")) var routeCount = 0 @@ -81,13 +81,13 @@ final class PubkyAuthURLSchemeTests: XCTestCase { XCTAssertNil(sheets.activeSheetConfiguration) - let duplicateRelayURL = "bitkit://pubky-auth/setup?caps=\(PubkyAuthClaim.watchOnlyAccountCapabilities)" + + let duplicateRelayURL = "bitkit://pubky-auth/setup?caps=\(PubkyAuthClaim.requiredCapabilities)" + "&relay=https%3A%2F%2Fa&relay=https%3A%2F%2Fb" + "&secret=e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3s\(grantRequester)&x-bitkit-claim=watch-only-account-v1" try await app.handleScannedData(duplicateRelayURL) XCTAssertNil(sheets.activeSheetConfiguration) - let duplicateSecretURL = "bitkit://pubky-auth/setup?caps=\(PubkyAuthClaim.watchOnlyAccountCapabilities)" + + let duplicateSecretURL = "bitkit://pubky-auth/setup?caps=\(PubkyAuthClaim.requiredCapabilities)" + "&relay=https%3A%2F%2Fhttprelay.pubky.app%2Finbox%2F" + "&secret=first&secret=second\(grantRequester)&x-bitkit-claim=watch-only-account-v1" try await app.handleScannedData(duplicateSecretURL) @@ -97,7 +97,7 @@ final class PubkyAuthURLSchemeTests: XCTestCase { @MainActor func testNonNodeDeepLinksReleaseAfterStartupGatesWithoutWaitingForLDK() async throws { let app = AppViewModel(sheetViewModel: SheetViewModel(), navigationViewModel: NavigationViewModel()) - let pubkyURL = try XCTUnwrap(URL(string: "bitkit://pubky-auth/setup?caps=\(PubkyAuthClaim.watchOnlyAccountCapabilities)" + + let pubkyURL = try XCTUnwrap(URL(string: "bitkit://pubky-auth/setup?caps=\(PubkyAuthClaim.requiredCapabilities)" + "&relay=https%3A%2F%2Fhttprelay.pubky.app%2Finbox%2F" + "&secret=e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3s\(grantRequester)&x-bitkit-claim=watch-only-account-v1")) let lightningPubkyAuthURL = try XCTUnwrap(URL(string: "lightning:pubkyauth://signin")) diff --git a/BitkitTests/PubkyIdentityRepublishTests.swift b/BitkitTests/PubkyIdentityRepublishTests.swift index 4682bf6c8..1d6e2bdbf 100644 --- a/BitkitTests/PubkyIdentityRepublishTests.swift +++ b/BitkitTests/PubkyIdentityRepublishTests.swift @@ -187,7 +187,8 @@ final class PubkyIdentityRepublishTests: XCTestCase { ) case .companion: try await PubkyService.approveAuthWithCompanionClaim( - authUrl: authUrl, approvedClientID: "paykit.test", unsignedPayload: Data(), + authUrl: authUrl, approvedClientID: "paykit.test", claim: .watchOnlyAccountV1, + accountPayload: Data([1]) + Data(repeating: 0, count: 83), secretKeyHex: secretKeyHex, sdkService: service ) case .ring: diff --git a/BitkitTests/PubkyProfileManagerTests.swift b/BitkitTests/PubkyProfileManagerTests.swift index a21e726ba..ab159be71 100644 --- a/BitkitTests/PubkyProfileManagerTests.swift +++ b/BitkitTests/PubkyProfileManagerTests.swift @@ -208,7 +208,7 @@ final class PubkyProfileManagerTests: XCTestCase { @MainActor func testFailedRingAdoptionDoesNotRestoreIdentityAfterLocalReset() async throws { let savedReference = AdoptedPubkyReference.current - let keys: [KeychainEntryType] = [.paykitSession, .pubkySecretKey, .paykitSdkState] + let keys: [KeychainEntryType] = [.paykitSession, .pubkySecretKey] let savedCredentials = try keys.map { try Keychain.load(key: $0) } let defaults = UserDefaults.standard let preferenceKeys = [ @@ -387,7 +387,7 @@ final class PubkyProfileManagerTests: XCTestCase { @MainActor func testRingAdoptionDropsLateProfileAfterSessionTeardown() async throws { let savedReference = AdoptedPubkyReference.current - let keychainKeys: [KeychainEntryType] = [.paykitSession, .pubkySecretKey, .paykitSdkState] + let keychainKeys: [KeychainEntryType] = [.paykitSession, .pubkySecretKey] let savedCredentials = try keychainKeys.map { try Keychain.load(key: $0) } let defaults = UserDefaults.standard let preferenceKeys = [ @@ -733,7 +733,7 @@ final class PubkyProfileManagerTests: XCTestCase { @MainActor func testSignupDoesNotRestoreProfileStateAfterWalletReset() async throws { snapshotAppDefaultsDomain() - let keys: [KeychainEntryType] = [.paykitSdkState, .paykitSession, .pubkySecretKey] + let keys: [KeychainEntryType] = [.paykitSession, .pubkySecretKey] let saved = try keys.map { try Keychain.load(key: $0) } let savedOverrides = ContactsManager.backupContactProfileOverrides() defer { @@ -749,7 +749,9 @@ final class PubkyProfileManagerTests: XCTestCase { for resetStep in ["register", "authorize", "activate"] { let manager = PubkyProfileManager() let session = PubkyRegisteredIdentity( - result: PubkySessionBootstrapResult(sessionAccess: PubkySessionAccess(noPointer: .init()), publicKey: "pubky_test"), + result: PubkySessionBootstrapResult( + sessionAccess: PubkySessionAccess(noPointer: .init()), publicKey: "pubky_test", capability: .privateLinkCapable + ), walletGeneration: 0 ) do { @@ -801,7 +803,9 @@ final class PubkyProfileManagerTests: XCTestCase { defaults.set(true, forKey: "pubky_profile_setup_pending") let manager = PubkyProfileManager() let session = PubkyRegisteredIdentity( - result: PubkySessionBootstrapResult(sessionAccess: PubkySessionAccess(noPointer: .init()), publicKey: "pubky_test"), + result: PubkySessionBootstrapResult( + sessionAccess: PubkySessionAccess(noPointer: .init()), publicKey: "pubky_test", capability: .privateLinkCapable + ), walletGeneration: 0 ) var events: [String] = [] @@ -853,7 +857,9 @@ final class PubkyProfileManagerTests: XCTestCase { let manager = PubkyProfileManager() let session = PubkyRegisteredIdentity( - result: PubkySessionBootstrapResult(sessionAccess: PubkySessionAccess(noPointer: .init()), publicKey: "pubky_test"), + result: PubkySessionBootstrapResult( + sessionAccess: PubkySessionAccess(noPointer: .init()), publicKey: "pubky_test", capability: .privateLinkCapable + ), walletGeneration: 0 ) var shouldFailActivation = true @@ -925,7 +931,9 @@ final class PubkyProfileManagerTests: XCTestCase { for cancelSignup in [false, true] { let manager = PubkyProfileManager() let session = PubkyRegisteredIdentity( - result: PubkySessionBootstrapResult(sessionAccess: PubkySessionAccess(noPointer: .init()), publicKey: "pubky_first"), + result: PubkySessionBootstrapResult( + sessionAccess: PubkySessionAccess(noPointer: .init()), publicKey: "pubky_first", capability: .privateLinkCapable + ), walletGeneration: 0 ) let approvalStarted = expectation(description: "Approval started") diff --git a/BitkitTests/WatchOnlyAccountServiceTests.swift b/BitkitTests/WatchOnlyAccountServiceTests.swift index 31d51c03f..a20495fae 100644 --- a/BitkitTests/WatchOnlyAccountServiceTests.swift +++ b/BitkitTests/WatchOnlyAccountServiceTests.swift @@ -1,6 +1,7 @@ @testable import Bitkit import CryptoKit import LDKNode +import Paykit import XCTest private let testXpub = @@ -24,6 +25,45 @@ private let offCurveKeyXpub = "521AAwdZafEz7mnzBBsz4wKY5e4cp9LB" final class WatchOnlyAccountServiceTests: XCTestCase { + func testCombinedCompanionClaimMatchesWireFixture() throws { + // Same wire vector as Paykit Server's bitkit-combined-claim-v1.json. + let account = Data([1, 1, 2, 3, 4, 0]) + Data(repeating: 9, count: 78) + let secret = Data(repeating: 11, count: 32) + let key = try PaykitIdentitySecretKey(bytes: secret, keyGeneration: 0x0102_0304_0506_0708) + + let claim = try XCTUnwrap(PubkyAuthClaim(rawValue: "paykit-access-v1.watch-only-account-v1")) + let payload = try claim.encode(accountPayload: account, paykitKey: key) + let expected = ( + "01010203040009090909090909090909090909090909090909090909090909090909090909090909" + + "09090909090909090909090909090909090909090909090909090909090909090909090909090909" + + "0909090901020304050607080b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b" + + "0b0b0b0b" + ).hexaData + + XCTAssertEqual(payload, expected) + XCTAssertEqual(payload.count, 124) + XCTAssertEqual(payload.prefix(84), account) + XCTAssertEqual(payload.subdata(in: 84 ..< 92), Data([1, 2, 3, 4, 5, 6, 7, 8])) + XCTAssertEqual(payload.suffix(32), secret) + let reorderedClaim = try XCTUnwrap(PubkyAuthClaim(rawValue: "watch-only-account-v1.paykit-access-v1")) + XCTAssertEqual(try reorderedClaim.encode(accountPayload: account, paykitKey: key), payload) + XCTAssertThrowsError(try claim.encode(accountPayload: Data(), paykitKey: key)) + XCTAssertThrowsError(try claim.encode(accountPayload: account, paykitKey: nil)) + XCTAssertThrowsError(try claim.encode(accountPayload: nil, paykitKey: key)) + } + + func testIndependentClaimsExportOnlyRequestedMaterial() throws { + let account = try WatchOnlyAccountClaimCodec.encode(record: makeRecord(accountIndex: 42, xpub: testXpub)) + let secret = Data(repeating: 7, count: 32) + let key = try PaykitIdentitySecretKey(bytes: secret, keyGeneration: 3) + let paykitPayload = try PubkyAuthClaim.paykitAccessV1.encode(accountPayload: nil, paykitKey: key) + XCTAssertEqual(paykitPayload, Data([1, 0, 0, 0, 0, 0, 0, 0, 3]) + secret) + XCTAssertEqual(try PubkyAuthClaim.watchOnlyAccountV1.encode(accountPayload: account, paykitKey: nil), account) + XCTAssertThrowsError(try PubkyAuthClaim.watchOnlyAccountV1.encode(accountPayload: account, paykitKey: key)) + XCTAssertThrowsError(try PubkyAuthClaim.paykitAccessV1.encode(accountPayload: account, paykitKey: key)) + XCTAssertThrowsError(try PubkyAuthClaim.paykitAccessV1.encode(accountPayload: nil, paykitKey: nil)) + } + func testUnsignedClaimContainsExactAccountMetadata() throws { let rawXpub = testSerializedXpubHex.hexaData let record = makeRecord(accountIndex: 42, xpub: testXpub) @@ -416,15 +456,15 @@ final class WatchOnlyAccountServiceTests: XCTestCase { let node = FakeWatchOnlyAccountNode() let manager = WatchOnlyAccountManager(defaults: defaults, node: node) let first = try await manager.prepareUnsignedClaim( - authUrl: "pubkyauth://signin?caps=/pub/paykit/v0/bitkit/server/:rw&secret=same&relay=https%3A%2F%2Frelay.test&x-bitkit-claim=watch-only-account-v1", + authUrl: "pubkyauth://signin?caps=/pub/paykit/:rw&secret=same&relay=https%3A%2F%2Frelay.test&x-bitkit-claim=watch-only-account-v1", name: "First" ) let reordered = try await manager.prepareUnsignedClaim( - authUrl: "pubkyauth://signin?relay=https://relay.test&x-bitkit-claim=watch-only-account-v1&secret=s%61me&caps=/pub/paykit/v0/bitkit/server/:rw", + authUrl: "pubkyauth://signin?relay=https://relay.test&x-bitkit-claim=watch-only-account-v1&secret=s%61me&caps=/pub/paykit/:rw", name: "Renamed" ) let differentRelay = try await manager.prepareUnsignedClaim( - authUrl: "pubkyauth://signin?relay=https://other-relay.test&x-bitkit-claim=watch-only-account-v1&secret=same&caps=/pub/paykit/v0/bitkit/server/:rw", + authUrl: "pubkyauth://signin?relay=https://other-relay.test&x-bitkit-claim=watch-only-account-v1&secret=same&caps=/pub/paykit/:rw", name: "Other relay" ) diff --git a/Docs/paykit-issuer-interoperability.md b/Docs/paykit-issuer-interoperability.md index 72f0d8869..873e10d4b 100644 --- a/Docs/paykit-issuer-interoperability.md +++ b/Docs/paykit-issuer-interoperability.md @@ -74,7 +74,7 @@ After this shape check, Bitkit validates that the value is usable: an on-chain a ## Delivery prerequisites -The issuer and wallet must be linked Paykit peers on the same receiver path before Bitkit polls the request. The issuer must advertise a usable endpoint for at least one identifier retained from the request. A request that fails the request gate is not presented; a request whose endpoint cannot be resolved is deferred until usable payment details arrive. +The issuer and wallet identities must be linked Paykit peers before Bitkit polls the request. Requests may require a specific payment App; Bitkit uses the SDK's request-specific resolver and preserves the selected App in its proof. The issuer can include exact `paymentEndpoints` in the request or supply endpoints through its Payment List. At least one must match an identifier retained from the request. A request that fails the request gate is not presented; a request whose endpoint cannot be resolved is deferred until usable payment details arrive. ## Contract fixtures diff --git a/Docs/pubky-auth-companion-claims.md b/Docs/pubky-auth-companion-claims.md new file mode 100644 index 000000000..e7ebe4b5f --- /dev/null +++ b/Docs/pubky-auth-companion-claims.md @@ -0,0 +1,59 @@ +# Bitkit Pubky Auth companion claims + +Bitkit can authorize a Pubky session and share Paykit access, a watch-only Bitcoin account, or both. The request explicitly selects the material to share; homeserver write permissions alone never imply key export. + +## Request + +- The Pubky Auth URL includes exactly one `x-bitkit-claim` parameter containing a dot-separated list of independent requested items: + - `watch-only-account-v1`: a watch-only account. + - `paykit-access-v1`: the generation-bound Paykit secret. +- Paykit Server requests both with `x-bitkit-claim=paykit-access-v1.watch-only-account-v1`. Either order is accepted; the exact value is retained for signing and relay-channel derivation. +- The capability is `/pub/paykit/:rw`. +- Empty, unknown, or duplicate items and duplicate companion-claim parameters are rejected. Ordinary Pubky Auth without a companion claim shares only the session. +- Watch-only approval creates a fresh native-SegWit account. New account indexes start at `1`, increase monotonically, and are never recycled. Retrying the same logical auth request reuses its incomplete account even if query parameters are reordered. +- Bitkit automatically names the account from the requesting service. The user can rename it later. The local name is not disclosed in the claim. +- Reconnecting requests only `paykit-access-v1`; the server retains its existing watch-only account and verifies the approving identity. Bitkit does not allocate or share an xpub for this request. The server rejects an unexpected replacement xpub or account index. +- Paykit-only approval neither allocates nor loads a Bitcoin account. Its approval screen explains access to private Paykit state and communication, without watch-only or content-earning UI. + +## Claim payload + +The watch-only payload is 84 bytes: + +| Offset | Size | Value | +| --- | ---: | --- | +| 0 | 1 | Claim version, `0x01` | +| 1 | 4 | BIP account index, unsigned big-endian | +| 5 | 1 | Address type, `0x00` for native SegWit | +| 6 | 78 | Base58Check-decoded extended public key, including its 4-byte version | + +The Paykit-only payload is 41 bytes: version `1`, 8-byte nonzero big-endian key generation, and 32-byte Paykit identity secret. When both items are requested, the payload is 124 bytes: the watch-only payload followed by that generation and secret, without a second version byte. Payload order does not depend on request-item order. + +Bitkit passes the exact requested list as `claim_type` and only the requested material as the payload to Paykit's `approveAuthWithCompanionClaim` API. Paykit appends a 64-byte Ed25519 signature, encrypts the claim, delivers it in one companion relay response, and only then approves normal Pubky Auth. + +For requests including Paykit access, Bitkit derives the Paykit secret from its local or shared-Keychain Pubky identity secret and the current App Registry generation. The recipient derives the separate Noise and shared-state encryption keys from it. Neither the Pubky root secret nor Bitcoin spending keys are shared. A delegated secret cannot derive another generation. + +The signature input is the byte concatenation: + +```text +UTF8("x-bitkit-claim|" || claim_type || "|") +|| SHA256(decoded_auth_request_secret) +|| unsigned_claim_bytes +``` + +`decoded_auth_request_secret` is the raw 32-byte value produced by base64url-no-pad decoding the URL's `secret` parameter, not UTF-8 text. + +The server verifies the signature with the creator's Pubky Ed25519 public key from the authenticated session. Binding the signature to the request secret prevents a valid signed claim from being moved to a different request; possession of the relay secret alone is insufficient to substitute an attacker's xpub. + +## Delivery and lifecycle + +- The companion channel is `base_relay/{base64url_no_pad(BLAKE3(UTF8(claim_type || "|") || secret))}`. +- Paykit encrypts the signed claim with the auth request secret using XSalsa20-Poly1305 and delivers it before approving the Pubky grant. The SDK owns relay transport and cryptography. +- Bitkit persists the account before delivery and reuses the same account index and unsigned xpub payload when retrying an incomplete setup. Each attempt may create new encrypted relay messages; delivery is not guaranteed exactly once. +- For new watch-only accounts, Bitkit durably marks and loads the account as authorizing before calling Paykit. Successful approval marks it active and leaves tracking enabled. An initial preparation or companion-delivery failure returns it to pending and unloads it again. Reauthorization of an already active account does not change this state. +- Account registration and address revelation finish before authorization. LDK's periodic sync fetches transaction history; a full wallet sync is not a prerequisite for delivering the authorization. +- If Paykit reports that companion delivery succeeded but grant approval failed, Bitkit leaves the new account authorizing and tracked. The same conservative state is retained if local activation persistence fails after Paykit returns success. Retrying uses the same account and xpub payload; retry failures keep the account tracked so Bitkit does not lose visibility into addresses the server may already have derived. +- Disabling tracking unloads the account from LDK Node at runtime. It does not delete persisted wallet state, the xpub, or the server session. +- Enabled active or authorizing accounts are configured before LDK Node starts. Electrum full scans use a batch size of `100` and stop gap of `1000`. +- Bitkit pre-reveals external receive indexes `0...999` for each tracked account. LDK then maintains a rolling stop-gap window: the first address with transaction history must be at or below index `999`, and after activity at index `n`, the next active index must be at or below `n + 1000` so there are never `1000` consecutive inactive addresses. +- On startup and before app-driven sync, Bitkit reconciles persisted account state with LDK and restores the pre-revealed range. Accounts removed by a backup remain scheduled for unload until reconciliation succeeds, allowing transient failures to retry safely. +- Account metadata and monotonic allocation state are included in the existing encrypted wallet backup and use the same JSON field names on iOS and Android. diff --git a/Docs/watch-only-account-claim-v1.md b/Docs/watch-only-account-claim-v1.md deleted file mode 100644 index 2d9e9446a..000000000 --- a/Docs/watch-only-account-claim-v1.md +++ /dev/null @@ -1,52 +0,0 @@ -# Bitkit watch-only account claim v1 - -This document records the client contract implemented by Bitkit iOS and Android for Paykit Server setup requests. - -## Request - -- The Pubky Auth URL includes `x-bitkit-claim=watch-only-account-v1`. -- The exact capabilities are `/pub/paykit/v0/bitkit/server/:rw` and `/pub/paykit/v0/private/bitkit/server/:rw`. -- Missing, unknown, mismatched, or duplicate companion-claim parameters are rejected. -- Every distinct auth request creates a fresh native-SegWit account, beginning at BIP84 account index `1`. Account indexes increase monotonically and are never reused. Retrying the same logical auth request reuses its incomplete account even if query parameters are reordered. -- Bitkit automatically names the account from the requesting service. The user can rename it later. The local name is not disclosed in the claim. - -## Claim payload - -Bitkit serializes this exact 84-byte unsigned payload: - -| Offset | Size | Value | -| --- | ---: | --- | -| 0 | 1 | Claim version, `0x01` | -| 1 | 4 | BIP account index, unsigned big-endian | -| 5 | 1 | Address type, `0x00` for native SegWit | -| 6 | 78 | Base58Check-decoded extended public key, including its 4-byte version | - -Bitkit passes the payload to Paykit's `approveAuthWithCompanionClaim` API. Paykit appends a 64-byte Ed25519 signature, encrypts the resulting 148-byte claim, delivers it on the companion relay channel, and only then approves normal Pubky Auth. - -The signature input is the byte concatenation: - -```text -UTF8("x-bitkit-claim|watch-only-account-v1|") -|| SHA256(decoded_auth_request_secret) -|| claim_bytes[0..<84] -``` - -`decoded_auth_request_secret` is the raw 32-byte value produced by base64url-no-pad decoding the URL's `secret` parameter, not UTF-8 text. - -The server verifies the signature with the creator's Pubky Ed25519 public key from the authenticated session. Binding the signature to the request secret prevents a valid signed claim from being moved to a different request; possession of the relay secret alone is insufficient to substitute an attacker's xpub. - -## Delivery and lifecycle - -- The normal AuthToken channel is `base_relay/{base64url_no_pad(BLAKE3(secret))}`. -- The companion channel is `base_relay/{base64url_no_pad(BLAKE3(ASCII("watch-only-account-v1|") || secret))}`. -- Paykit encrypts the complete 148-byte signed claim on the companion channel with the auth request secret using XSalsa20-Poly1305. -- Paykit delivers the claim before approving the normal Pubky Auth token, avoiding a session that was authorized without its required account claim. -- Bitkit persists the account before delivery and reuses the same account index and unsigned xpub payload when retrying an incomplete setup. Each attempt may create new encrypted relay messages; delivery is not guaranteed exactly once. -- Bitkit durably marks and loads an incomplete account as authorizing before calling Paykit. Successful combined approval marks it active and leaves tracking enabled. An initial preparation or companion-delivery failure returns it to pending and unloads it again. -- Account registration and address revelation finish before authorization. LDK's periodic sync fetches transaction history; a full wallet sync is not a prerequisite for delivering the authorization. -- If Paykit reports that companion delivery succeeded but normal AuthToken delivery failed, Bitkit leaves the account authorizing and tracked. The same conservative state is retained if local activation persistence fails after Paykit returns success. Retrying reruns the combined Paykit approval with the same account and xpub payload; retry failures keep the account tracked so Bitkit does not lose visibility into addresses the server may already have derived. -- Disabling tracking unloads the account from LDK Node at runtime. It does not delete persisted wallet state, the xpub, or the server session. -- Enabled active or authorizing accounts are configured before LDK Node starts. Electrum full scans use a batch size of `100` and stop gap of `1000`. -- Bitkit pre-reveals external receive indexes `0...999` for each tracked account. LDK then maintains a rolling stop-gap window: the first address with transaction history must be at or below index `999`, and after activity at index `n`, the next active index must be at or below `n + 1000` so there are never `1000` consecutive inactive addresses. -- On startup and before app-driven sync, Bitkit reconciles persisted account state with LDK and restores the pre-revealed range. Accounts removed by a backup remain scheduled for unload until reconciliation succeeds, allowing transient failures to retry safely. -- Account metadata and monotonic allocation state are included in the existing encrypted wallet backup and use the same JSON field names on iOS and Android. diff --git a/changelog.d/next/paykit-shared-runtime.changed.md b/changelog.d/next/paykit-shared-runtime.changed.md new file mode 100644 index 000000000..9f5ab6ca6 --- /dev/null +++ b/changelog.d/next/paykit-shared-runtime.changed.md @@ -0,0 +1 @@ +Share Paykit contacts and payment state with authorized apps, identify received payments from shared requests by contact, and keep wallet keys private. diff --git a/journeys/README.md b/journeys/README.md index 4b76c7b34..0ae9e8506 100644 --- a/journeys/README.md +++ b/journeys/README.md @@ -150,7 +150,7 @@ journey PR, which is what made this file conflict on every merge. | A hardware wallet to pair, watch and sign with | the deterministic Trezor emulator from `bitkit-docker`, reached through Trezor Bridge on the host; the simulator has no Bluetooth LE and iOS cannot do WebUSB, so BLE pairing and USB are not covered — [hardware-wallet](hardware-wallet/README.md) | | Push notifications to a backgrounded or killed app | a real APNs round trip on an attached physical device, never the simulator, read back from Notification Center — [cjit-notifications](cjit-notifications/README.md) | | The OS notification-permission dialog | the one-shot `UNUserNotificationCenter` alert, reset with `xcrun simctl uninstall to.bitkit` and a rebuild — [notification-permission](notification-permission/README.md) | -| An incoming Payment Request from a linked issuer | the fixture issuer, saved as a contact and linked on receiver path `bitkit/server` — [payment-requests](payment-requests/README.md) | +| An incoming Payment Request from a linked issuer | the fixture issuer, saved as a contact and linked as identities — [payment-requests](payment-requests/README.md) | | A Pubky identity and a two-wallet marketplace purchase | a Bitkit-generated Pubky profile, plus the integration fixture runtime: Pubky testnet, Paykit Server, regtest bitcoind and Fulcrum — [pubky-auth](pubky-auth/README.md), [pubky-marketplace](pubky-marketplace/README.md) | | LNURL pay, withdraw, channel and auth, and Lightning Addresses | the `bitkit-docker` `lnurl-server` on local regtest, reached by a simulator app built with `E2E_BUILD`, whose default `E2E_BACKEND=local` targets the host at `127.0.0.1`; it issues memo invoices, so a check that needs a description-hash invoice needs another endpoint | | Deep links handed to the app | `xcrun simctl openurl ""`; only `bitkit://pubky-auth/setup`, `bitkit://contact?pubky=`, web URLs, Pubky callbacks and payment URIs route — there is no screen or sheet router — [pubky-auth](pubky-auth/README.md), [deeplinks](deeplinks), [Not ported](#not-ported) | diff --git a/journeys/payment-requests/README.md b/journeys/payment-requests/README.md index 033c17459..9f4d28ca6 100644 --- a/journeys/payment-requests/README.md +++ b/journeys/payment-requests/README.md @@ -10,7 +10,7 @@ The resolution-failure journey is ported alongside Android's matching `requested ### Setup -Run Bitkit against regtest with Paykit UI enabled. Authenticate a Pubky identity, save the fixture issuer as a contact, link its server receiver on `bitkit/server`, and give the wallet enough on-chain balance to pay 100,000 sats. The fixture issuer must be able to publish a Paykit endpoint and send a one-time Payment Request to that linked peer. A Bitkit app acting as the issuer exposes `bitkit/wallet` instead, so a Bitkit-to-Bitkit run uses that negotiated path throughout. +Run Bitkit against regtest with Paykit UI enabled. Authenticate a Pubky identity, save and link the fixture issuer as a contact, and give the wallet enough on-chain balance to pay 100,000 sats. The fixture issuer must be able to publish a Paykit endpoint and send a one-time Payment Request to that linked peer. Its App ID is `paykit-server`; Bitkit uses `bitkit`. The accepted journey uses: @@ -76,8 +76,8 @@ correctly prevents it from entering the presentation queue. ### Setup Use a second Bitkit instance as the requester instead of the fixture issuer: both instances are -authenticated Pubky identities, saved as each other's contacts and linked on receiver path -`bitkit/wallet`, and the payer holds enough balance to pay 21,000 sats. +authenticated Pubky identities, saved as each other's contacts and linked, and the payer holds +enough balance to pay 21,000 sats. ## Definite pre-broadcast retry diff --git a/journeys/payment-requests/contact-request-or-pay.xml b/journeys/payment-requests/contact-request-or-pay.xml index a805d3e38..5b39f432e 100644 --- a/journeys/payment-requests/contact-request-or-pay.xml +++ b/journeys/payment-requests/contact-request-or-pay.xml @@ -1,6 +1,6 @@ - Verifies that Pay on a linked contact offers Request or Pay, that paying shows progress on the sheet until the amount screen opens within a few seconds, and that Request opens the Payment Request amount screen. Requires two Bitkit instances saved as each other's contacts and linked on receiver path "bitkit/wallet", with the payer funded; see README.md. Let the payer run for a minute after launch before starting, so start-up Paykit work does not dominate the timings. Open the contact with xcrun simctl openurl booted "bitkit://contact?pubky=<requester-public-key>". + Verifies that Pay on a linked contact offers Request or Pay, that paying shows progress on the sheet until the amount screen opens within a few seconds, and that Request opens the Payment Request amount screen. Requires two Bitkit instances saved as each other's contacts and linked as identities, with the payer funded; see README.md. Let the payer run for a minute after launch before starting, so start-up Paykit work does not dominate the timings. Open the contact with xcrun simctl openurl booted "bitkit://contact?pubky=<requester-public-key>". Open bitkit://contact?pubky=<requester-public-key> on the payer diff --git a/journeys/payment-requests/delete-and-readd-contact.xml b/journeys/payment-requests/delete-and-readd-contact.xml index e74090701..9c0348738 100644 --- a/journeys/payment-requests/delete-and-readd-contact.xml +++ b/journeys/payment-requests/delete-and-readd-contact.xml @@ -1,6 +1,6 @@ - Verifies that deleting a contact stops incoming private Payment Requests until the user explicitly adds the contact again. Requires two authenticated Bitkit instances saved as each other's contacts and linked on receiver path "bitkit/wallet". The payer must have no active subscription with the requester. No payment needs to be sent. + Verifies that deleting a contact stops incoming private Payment Requests until the user explicitly adds the contact again. Requires two authenticated Bitkit instances saved as each other's contacts and linked as identities. The payer must have no active subscription with the requester. No payment needs to be sent. On the requester, send the payer contact a Payment Request for 5,000 sats with the note "Before deletion" diff --git a/journeys/payment-requests/issuer-interoperability.xml b/journeys/payment-requests/issuer-interoperability.xml index ac3e6a28a..78bb440a5 100644 --- a/journeys/payment-requests/issuer-interoperability.xml +++ b/journeys/payment-requests/issuer-interoperability.xml @@ -1,16 +1,16 @@ - Verifies that the canonical accepted regtest issuer fixture reaches Bitkit and opens the payment confirmation flow. Requires the saved and linked server fixture plus the funded regtest wallet described in README.md. The canonical fixture uses "bitkit/server"; a Bitkit app acting as issuer uses its negotiated "bitkit/wallet" path instead. Rejected shapes are covered by the shared fixture unit tests because Bitkit intentionally does not present them. + Verifies that the canonical accepted regtest issuer fixture reaches Bitkit and opens the payment confirmation flow. Requires the saved and linked server fixture plus the funded regtest wallet described in README.md. The fixture uses App ID "paykit-server"; Bitkit uses "bitkit". Rejected shapes are covered by the shared fixture unit tests because Bitkit intentionally does not present them. - Launch the E2E Bitkit app with Paykit UI enabled and the fixture issuer saved as a contact and linked on receiver path "bitkit/server" + Launch the E2E Bitkit app with Paykit UI enabled and the fixture issuer saved as a contact and linked as identities Have the issuer publish a current regtest P2WPKH address under identifier "btc-regtest-p2wpkh" with JSON payload {"value":"<current address>"} Have the issuer send proposed one-time Payment Request "71300000-0000-4000-8000-000000000001" for amount "0.001", asset "btc", and accepted identifier "btc-regtest-p2wpkh" Verify the Payment Request confirmation screen (id "PaymentRequestConfirm") appears with 100,000 sats Dismiss the Payment Request confirmation screen without paying Tap the pending Payment Requests bell (id "PaymentRequestsBell") Verify the incoming Payment Requests sheet (id "PaymentRequestsSheet") appears - Verify the one-time request row (id "PaymentRequestRow-71300000-0000-4000-8000-000000000001-one-time") shows 100,000 sats + Using the saved issuer public key, verify the one-time request row (id "PaymentRequestRow-71300000-0000-4000-8000-000000000001-one-time") shows 100,000 sats Tap the one-time request row (id "PaymentRequestRow-71300000-0000-4000-8000-000000000001-one-time") Verify Payment Request details (id "PaymentRequestDetailScreen") shows an unsigned 100,000 sats amount (id "PaymentRequestDetailsAmount") and status "Waiting for payment" (id "PaymentRequestDetailsStatus") Tap Back (id "NavigationBack") diff --git a/journeys/payment-requests/request-summary.xml b/journeys/payment-requests/request-summary.xml index 1fb763153..bdf26b505 100644 --- a/journeys/payment-requests/request-summary.xml +++ b/journeys/payment-requests/request-summary.xml @@ -1,6 +1,6 @@ - Verifies that the collapsed Payment Request confirmation shows who the request is from and what it is for, keeps the note in the details, and shows "Not specified" in For when the request has no note. Requires two Bitkit instances saved as each other's contacts and linked on receiver path "bitkit/wallet", with the payer funded to cover 21,000 sats; see README.md. + Verifies that the collapsed Payment Request confirmation shows who the request is from and what it is for, keeps the note in the details, and shows "Not specified" in For when the request has no note. Requires two Bitkit instances saved as each other's contacts and linked as identities, with the payer funded to cover 21,000 sats; see README.md. On the requester, send the payer contact a Payment Request for 21,000 sats with the note "Lunch last week" diff --git a/journeys/pubky-auth/open-watch-only-link.xml b/journeys/pubky-auth/open-watch-only-link.xml index 38272993d..2b8fc999e 100644 --- a/journeys/pubky-auth/open-watch-only-link.xml +++ b/journeys/pubky-auth/open-watch-only-link.xml @@ -2,12 +2,12 @@ Precondition: an onboarded E2E Bitkit build with Paykit UI enabled and a Bitkit-generated Pubky identity. This journey launches the terminated app with a local-only dummy setup request and cancels before account material is exported. Run `xcrun simctl terminate <UDID> to.bitkit` - Run `xcrun simctl openurl <UDID> "bitkit://pubky-auth/setup?caps=/pub/paykit/v0/bitkit/server/:rw,/pub/paykit/v0/private/bitkit/server/:rw&relay=https%3A%2F%2Fhttprelay.pubky.app%2Finbox%2F&secret=e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3s&cid=paykit.test&cpk=5jsjx1o6fzu6aeeo697r3i5rx15zq41kikcye8wtwdqm4nb4tryo&x-bitkit-claim=watch-only-account-v1"` + Run `xcrun simctl openurl <UDID> "bitkit://pubky-auth/setup?caps=/pub/paykit/:rw&relay=https%3A%2F%2Fhttprelay.pubky.app%2Finbox%2F&secret=e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3s&cid=paykit.test&cpk=5jsjx1o6fzu6aeeo697r3i5rx15zq41kikcye8wtwdqm4nb4tryo&x-bitkit-claim=watch-only-account-v1"` If the simulator asks to open the link in Bitkit, tap Open Verify the watch-only consent screen (id "PubkyAuthWatchOnlyConsent") is visible Tap Cancel (id "PubkyAuthWatchOnlyCancel") Verify the watch-only consent screen (id "PubkyAuthWatchOnlyConsent") is no longer visible - Run `xcrun simctl openurl <UDID> "bitkit://pubky-auth/setup?caps=/pub/paykit/v0/bitkit/server/:rw,/pub/paykit/v0/private/bitkit/server/:rw&relay=https%3A%2F%2Fhttprelay.pubky.app%2Finbox%2F&secret=e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3s&cid=paykit.test&cpk=5jsjx1o6fzu6aeeo697r3i5rx15zq41kikcye8wtwdqm4nb4tryo&x-bitkit-claim=unsupported-v1"` + Run `xcrun simctl openurl <UDID> "bitkit://pubky-auth/setup?caps=/pub/paykit/:rw&relay=https%3A%2F%2Fhttprelay.pubky.app%2Finbox%2F&secret=e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3s&cid=paykit.test&cpk=5jsjx1o6fzu6aeeo697r3i5rx15zq41kikcye8wtwdqm4nb4tryo&x-bitkit-claim=unsupported-v1"` Verify the invalid request toast (id "PubkyAuthInvalidRequestToast") is visible Verify the watch-only consent screen (id "PubkyAuthWatchOnlyConsent") is not visible diff --git a/journeys/pubky-marketplace/README.md b/journeys/pubky-marketplace/README.md index 642193bfc..d5f4d24f2 100644 --- a/journeys/pubky-marketplace/README.md +++ b/journeys/pubky-marketplace/README.md @@ -11,17 +11,15 @@ The journey needs a controlled integration fixture outside this repository. The the fixture state and must provide: - A fresh Pubky testnet or isolated staging namespace reachable by both simulator wallets. -- A Paykit Server compatible with the watch-only setup contract merged in - [`pubky/paykit-server#2`](https://github.com/pubky/paykit-server/pull/2), including a `/setup` auth - URL whose payload carries `x-bitkit-claim=watch-only-account-v1`. Revision - [`867fc883`](https://github.com/pubky/paykit-server/commit/867fc883125c7b89a7b712c2551619cccdfdc0f7) - is the producer provenance used by the accepted run. +- A Paykit Server using the same shared-runtime SDK and the + [companion-claim contract](../../Docs/pubky-auth-companion-claims.md), including a `/setup` auth + URL whose payload requests `x-bitkit-claim=paykit-access-v1.watch-only-account-v1`. - A regtest bitcoind and Electrum/Fulcrum endpoint on the same chain. Before launching either wallet, publish the fixture's Fulcrum endpoint to the simulator host at `tcp://127.0.0.1:60001`; this is Bitkit's local-E2E default and prevents either wallet from retaining a taller foreign regtest tip. -- A clean seller wallet, a separate clean funded buyer wallet, and bilateral Paykit peer links at - `bitkit/server` and `bitkit/wallet`. +- A clean seller wallet, a separate clean funded buyer wallet, and an identity-wide Paykit link + between them. The seller's Bitkit and Paykit Server share that identity's state. - A driver that creates exactly one purchase for the buyer, reports the Payment Request id and delivery state, returns the derived address and expected amount, mines exactly one authorized block, and reports signed Paykit and marketplace completion state. @@ -57,11 +55,23 @@ The full journey depends on the sibling work from the parent epic: from reaching `SendSuccess`. Before opening the fixture setup auth URL, use the header profile button and Create path to give -each wallet a Bitkit-generated Pubky identity; an identity imported through Pubky Ring cannot -approve the setup auth URL. Contact payments must then be enabled in General Settings, and the buyer +each wallet a Pubky identity. A Ring identity works when its root secret is available through +shared Keychain. Contact payments must then be enabled in General Settings, and the buyer and seller must save each other before Bitkit's `receivePrivateMessagesFromLinkedPeers()` poll can receive the request. +## Companion approval checks + +`paykit-only-approval.xml` and `paykit-reconnect.xml` cover consent and cancellation +without delivering secrets. Open the fixture's `bitkit://pubky-auth/setup` URL with +`xcrun simctl openurl ""`. + +For successful delivery, use a controlled live relay. Verify a Paykit-only recipient +receives the current Paykit key/generation but no account material, and that no account +is allocated. For reconnect, the server requests only Paykit access for the existing +creator. Verify setup preserves its xpub, index, tracking, pending invoices, and address allocation. Do not +record secret-bearing URLs or Paykit keys in evidence. + ## Periodic payout detection Use separate seller and buyer devices. Before creating the purchase, return the seller to Home, @@ -82,7 +92,7 @@ artifacts at each boundary: | --- | --- | --- | | Watch-only claim | `PubkyAuthWatchOnlyConsent`, `PubkyAuthWatchOnlyApprove`, `PubkyAuthAuthorize`, and `PubkyAuthOK` snapshots | Setup completion and the claimed xpub/account index, with no spending key | | Linked buyer | Enabled `ContactPaymentsToggle`, `Contact_`, and `Contact_` snapshots | Seller and buyer peer-link state | -| Incoming request | `ReviewAmount`, `PaymentRequestsBell`, `PaymentRequestsSheet`, and `PaymentRequestRow----one-time` snapshots showing the automatic review, seller, amount, and note when present | Delivery record and exact Payment Request id | +| Incoming request | `ReviewAmount`, `PaymentRequestsBell`, `PaymentRequestsSheet`, and `PaymentRequestRow--one-time` snapshots showing the automatic review, seller, amount, and note when present | Delivery record and exact Payment Request id | | Payment approval | `PaymentRequestPay-`, `ReviewAmount`, and `ReviewContactRecipient` snapshots | Derived regtest address and expected amount | | Broadcast | `SendSuccess` snapshot and buyer activity details | Transaction in the fixture mempool with an amount-matched output | | Confirmation | `StatusConfirmed`, `ActivityAmount`, and `ActivityTxDetails` snapshots | Transaction id at one or more confirmations and completed purchase status | @@ -91,47 +101,3 @@ artifacts at each boundary: Paykit state, and marketplace state are the confirmation authority. Both platforms open fresh requests in payment review automatically, dismiss that review, and open `PaymentRequestsSheet` from `PaymentRequestsBell`; iOS uses the review's in-sheet back control instead of Android system back. - -## Accepted exact-head run from 2026-09-02 - -The final iOS acceptance replay used integration merge -`a87d298939b868b899a1f1139ce84a5227703a51`, composed from merged Pay-selector head -`70cd26346c6a539f792564e2e9bddb41cc00eecc` and this journey's pre-run documentation head -`c0cf2d553b1cdcf6dd1bba6d8865a80292b4d7e8`. Its production tree was identical to the selector -head. The installed Bitkit dylib SHA-256 was -`6c5a1be9db1f5985d92629021db7836befd0a3d31c0db638ea284988b123467c`. - -- The controlled seller identity was - `pubkyhbn4tahj71yzpmtarz5amtqqf5fmicdd7rs8ao448tzaujdapfiy`. The fresh buyer simulator was - `DED250A8-2952-42B9-B585-6654E361C87A`, with identity - `pubkyyrnr7smimj8fohmwk84jdoyn6xxdsgo175fu4xd844f1x3xk3xao`. -- The seller setup retained watch-only account index `1` without Bitcoin spending authority. The - buyer enabled `ContactPaymentsToggle`, saved the seller as - `Contact_pubkyhbn4tahj71yzpmtarz5amtqqf5fmicdd7rs8ao448tzaujdapfiy`, and linked at - `bitkit/wallet` with failure count `0`. -- Funding transaction `a57ec0f1b0ebaf493cb98799d3fbf126ecefa9f7f28dcb4f4f14386324b9faac` - paid the fresh buyer exactly 1,000,000 sats. One funding block advanced the shared chain to height - `16402` with tip `6b3dcd34ae81b07a064a1c3497ff47f60f2cda3cb5b27fc5b94777b829c6b6f1`. -- Bundle `SSK4TAEXNRSY2RA8E0FCNVXDE4` produced request - `5f07c465-dc67-42ce-96a0-4209e5389618` for `0.00015000 btc`. Endpoint - `btc-regtest-p2wpkh` contained JSON value - `bcrt1qhlqp9nv4awtzl6psdqmuavanlxzyaemv3pce60`. -- `PaymentRequestRow-5f07c465-dc67-42ce-96a0-4209e5389618` showed 15,000 sats and exposed - `PaymentRequestPay-5f07c465-dc67-42ce-96a0-4209e5389618`. Review preserved 15,000 sats, - `ReviewContactRecipient` resolved to `hbn4...pfiy`, and the 143-sat fee and enabled `GRAB` were - visible. -- Exactly one swipe reached `SendSuccess` and broadcast - `2822f6b43aebeb228e0bfe96c1b201cf41e0ed46a677d704403dc629b09d6f2a`. Output zero paid the - request address exactly 15,000 sats, output one returned 984,857 sats, and the fee was 143 sats. -- Before mining, the transaction was the only mempool entry, signed Paykit state was - `detected/0/true`, and the Locks bundle remained pending. Exactly one authorized block, - `6235eff06b00ed2b736635913e76269cd6524c4fed80f247d244102806816aed`, advanced the chain to - height `16403`. -- Signed Paykit state then reached `confirmed/1/true`, the Locks bundle completed without a failure, - guarded content returned HTTP 200, and the mempool was empty. Bitkit independently synchronized - to the same height and tip. -- The buyer displayed `StatusConfirmed`, a 15,000-sat payment, a 143-sat fee, the seller contact, - and the exact transaction under `ActivityTxDetails`. Request history retained exactly - `PaymentRequestRow-5f07c465-dc67-42ce-96a0-4209e5389618` without Pay or Dismiss actions. -- The sanitized replay video SHA-256 is - `ec6f0819c9e4e4066e8969f092328615bb286925599b6f92c733eba4b999c122`. diff --git a/journeys/pubky-marketplace/paykit-only-approval.xml b/journeys/pubky-marketplace/paykit-only-approval.xml new file mode 100644 index 000000000..1d92e3aba --- /dev/null +++ b/journeys/pubky-marketplace/paykit-only-approval.xml @@ -0,0 +1,19 @@ + + + Verifies explicit Paykit-only consent and cancellation without allocating a Bitcoin account. + Precondition: an isolated wallet with a Bitkit-generated Pubky identity or an available Ring + root secret. The fixture supplies a valid fresh bitkit://pubky-auth/setup URL with exactly + /pub/paykit/:rw and x-bitkit-claim=paykit-access-v1. Consent and cancellation require no live + authorization relay. Successful delivery is a separate fixture-backed README check. + + + Record the current wallet's watch-only account names, derivation paths, and tracking settings + Open the fixture Paykit-only auth URL in the wallet + Verify the authorization screen shows exactly /pub/paykit with READ, WRITE access + Verify Paykit access (id "PubkyAuthPaykitAccess") describes access to private Paykit data and sending Paykit messages, without wallet spending keys + Verify no Earn introduction, watch-only consent (id "PubkyAuthWatchOnlyConsent"), or account picker is shown + Tap Cancel (id "PubkyAuthCancel") + Verify the authorization sheet is dismissed + Verify the current wallet's watch-only accounts and tracking settings are unchanged + + diff --git a/journeys/pubky-marketplace/paykit-reconnect.xml b/journeys/pubky-marketplace/paykit-reconnect.xml new file mode 100644 index 000000000..4a5fe26f5 --- /dev/null +++ b/journeys/pubky-marketplace/paykit-reconnect.xml @@ -0,0 +1,18 @@ + + + Verifies Paykit-only reconnect consent and cancellation without a new watch-only account. + Precondition: an isolated wallet with one known service account that is active and tracked, + and a valid fresh bitkit://pubky-auth/setup URL with exactly /pub/paykit/:rw and + x-bitkit-claim=paykit-access-v1. The cancel steps need no live authorization relay. + Successful reconnect is a separate fixture-backed README check. + + + Record the known service account's name, derivation path, xpub, and tracking setting + Open the fixture Paykit-only reconnect auth URL in the wallet + Verify the authorization screen is visible without watch-only consent or an account picker + Verify Paykit access (id "PubkyAuthPaykitAccess") describes private Paykit data and messages without sharing identity or spending keys + Tap Cancel (id "PubkyAuthCancel") + Verify the authorization sheet is dismissed + Verify the known service account's name, derivation path, xpub, and tracking setting are unchanged and no new account was created + + diff --git a/journeys/pubky-marketplace/wallet-leg.xml b/journeys/pubky-marketplace/wallet-leg.xml index d20368440..1be4983cf 100644 --- a/journeys/pubky-marketplace/wallet-leg.xml +++ b/journeys/pubky-marketplace/wallet-leg.xml @@ -7,19 +7,20 @@ Precondition: two clean simulator wallets and the integration fixture described in this suite's README. Publish fixture Fulcrum at tcp://127.0.0.1:60001 and build both wallets with the local E2E regtest settings before first launch. After creating both wallets, use the header profile - button and Create path to create a Bitkit-generated Pubky identity in each wallet. A Pubky - Ring-imported identity cannot approve the fixture setup auth URL. Start with the seller wallet + button and Create path to create a Pubky identity in each wallet, or use a Ring identity whose + root secret is available through shared Keychain. Start with the seller wallet open and the fixture's fresh setup auth URL available. Open the fixture setup auth URL in the seller wallet Verify the watch-only consent screen (id "PubkyAuthWatchOnlyConsent") is visible Capture the watch-only consent evidence, then tap Approve (id "PubkyAuthWatchOnlyApprove") - Verify the authorization screen shows exactly /pub/paykit/v0/bitkit/server and /pub/paykit/v0/private/bitkit/server, each with READ, WRITE access + Verify the authorization screen shows exactly /pub/paykit with READ, WRITE access + Verify Paykit access (id "PubkyAuthPaykitAccess") explains the shared private messages and payment history Tap Authorize (id "PubkyAuthAuthorize") Verify authorization succeeds (id "PubkyAuthOK") Tap OK (id "PubkyAuthOK") - Verify the fixture completes setup with the seller account xpub and no spending authority + Verify the fixture completes setup with the seller account xpub and generation-bound Paykit key, but no Pubky root secret or spending authority Open General Settings in both wallets and verify contact payments (id "ContactPaymentsToggle") are enabled Open Contacts in the buyer wallet and save the fixture's seller public key Verify the seller contact (id "Contact_<seller-public-key>") is visible @@ -34,7 +35,7 @@ Verify the fresh request opens payment review automatically (id "ReviewAmount") Tap back to dismiss the automatic review, verify the requests bell (id "PaymentRequestsBell") is visible, then tap it Verify the incoming requests sheet (id "PaymentRequestsSheet") is visible - Using the seller public key and negotiated receiver path, verify the request row (id "PaymentRequestRow-<payment-request-id>-<seller-public-key>-<negotiated-receiver-path>-one-time") shows the seller and expected amount, plus the fixture note when present + Using the seller public key, verify the request row (id "PaymentRequestRow-<payment-request-id>-one-time") shows the seller and expected amount, plus the fixture note when present Capture the incoming-request evidence, then tap Pay (id "PaymentRequestPay-<payment-request-id>") Verify the review amount (id "ReviewAmount") matches the fixture amount Show details and verify the recipient (id "ReviewContactRecipient") is the seller contact @@ -47,6 +48,7 @@ Wait for periodic synchronization to detect the payout while the seller app remains active If the seller's received-transaction sheet appears, capture it and tap its button (id "ReceivedTransactionButton") to dismiss it Verify the seller savings balance (id "ActivitySavings") increased by the fixture payout amount and the latest received activity is visible + Verify the received activity identifies the buyer contact after shared Paykit synchronization, including after reopening the app Tap the seller's latest received activity (id "ActivityShort-0"), then tap transaction details (id "ActivityTxDetails") Verify the transaction id (id "TXID") matches the fixture transaction Capture the final activity, transaction id, confirmation height, and completed purchase evidence diff --git a/journeys/subscriptions/create-and-propose.xml b/journeys/subscriptions/create-and-propose.xml index 98e344c8d..bbadcc520 100644 --- a/journeys/subscriptions/create-and-propose.xml +++ b/journeys/subscriptions/create-and-propose.xml @@ -11,7 +11,7 @@ "Proposal" rather than reading on one line. - Launch the E2E Bitkit app with Paykit UI enabled and the payer instance saved as a contact linked on receiver path "bitkit/wallet" + Launch the E2E Bitkit app with Paykit UI enabled and the payer instance saved as a contact linked as identities Open the drawer menu and tap Subscriptions Verify the Subscriptions screen (id "SubscriptionsScreen") appears with the Overview tab (id "Tab-overview") selected Tap Create (id "SubscriptionCreate") From 7ae5beef662ed95d6e9f263186d73a6cae0f894b Mon Sep 17 00:00:00 2001 From: benk10 Date: Wed, 30 Sep 2026 22:55:36 -0500 Subject: [PATCH 02/68] chore: rename changelog fragment --- .../next/{paykit-shared-runtime.changed.md => 856.changed.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename changelog.d/next/{paykit-shared-runtime.changed.md => 856.changed.md} (100%) diff --git a/changelog.d/next/paykit-shared-runtime.changed.md b/changelog.d/next/856.changed.md similarity index 100% rename from changelog.d/next/paykit-shared-runtime.changed.md rename to changelog.d/next/856.changed.md From 10de74fe9200aa8a02668265f1070959689c44d0 Mon Sep 17 00:00:00 2001 From: benk10 Date: Wed, 30 Sep 2026 23:11:57 -0500 Subject: [PATCH 03/68] docs: clarify paykit integration contracts --- journeys/payment-requests/request-summary.xml | 2 +- journeys/pubky-marketplace/README.md | 22 +++++-------------- .../paykit-only-approval.xml | 2 +- .../pubky-marketplace/paykit-reconnect.xml | 2 +- 4 files changed, 9 insertions(+), 19 deletions(-) diff --git a/journeys/payment-requests/request-summary.xml b/journeys/payment-requests/request-summary.xml index bdf26b505..73c9b8d86 100644 --- a/journeys/payment-requests/request-summary.xml +++ b/journeys/payment-requests/request-summary.xml @@ -8,7 +8,7 @@ Verify From (id "PaymentRequestFrom") shows the requester's contact name Verify For (id "PaymentRequestFor") shows "Lunch last week" Tap Show details (id "SendConfirmToggleDetails") - Verify From (id "PaymentRequestFrom") and For (id "PaymentRequestFor") are no longer shown, and the recipient (id "ReviewContactRecipient") under Contact is the requester's contact + Verify From (id "PaymentRequestFrom") and For (id "PaymentRequestFor") are hidden in details, and the recipient (id "ReviewContactRecipient") under Contact is the requester's contact Verify the invoice note (id "PaymentRequestInvoiceNote") shows "Lunch last week" Close the Payment Request confirmation screen without paying On the requester, send the payer contact a Payment Request for 5,000 sats with no note diff --git a/journeys/pubky-marketplace/README.md b/journeys/pubky-marketplace/README.md index d5f4d24f2..29243e51b 100644 --- a/journeys/pubky-marketplace/README.md +++ b/journeys/pubky-marketplace/README.md @@ -1,7 +1,7 @@ # Pubky marketplace wallet leg -This suite covers the two-wallet Bitkit leg of a Pubky marketplace purchase: a seller grants a -watch-only account claim, a linked buyer receives the resulting Payment Request, and the buyer pays +This suite covers the two-wallet Bitkit leg of a Pubky marketplace purchase: a seller grants +Paykit access and a watch-only account, a linked buyer receives the Payment Request, and the buyer pays the request on regtest through confirmation. It does not cover marketplace browsing, Locks content delivery, fiat payment, or Hypercolor. @@ -24,8 +24,8 @@ the fixture state and must provide: delivery state, returns the derived address and expected amount, mines exactly one authorized block, and reports signed Paykit and marketplace completion state. -The request and endpoint must satisfy the issuer contract from iOS issue -[#713](https://github.com/synonymdev/bitkit-ios/issues/713): lowercase `btc`, a network-correct +The request and endpoint must satisfy the +[issuer contract](../../Docs/paykit-issuer-interoperability.md): lowercase `btc`, a network-correct `btc-regtest-*` endpoint identifier, and a JSON endpoint payload with a non-empty string `value`. The fixture must keep watch-only account material and spending authority separate. Evidence records the claimed account xpub and account index while omitting wallet seed material and tokens. @@ -42,17 +42,7 @@ E2E_HOMESERVER_PUBKY=" No stored Electrum override is required: `E2E_BUILD` with the local backend resolves Electrum to `tcp://127.0.0.1:60001`. Repeat the command for the seller and buyer simulator identifiers. -## Required app changes - -The full journey depends on the sibling work from the parent epic: - -- [#713](https://github.com/synonymdev/bitkit-ios/issues/713) defines the issuer interop contract. -- [#714](https://github.com/synonymdev/bitkit-ios/issues/714) supplies the stable per-request Pay - action identifier used by this journey. -- [#715](https://github.com/synonymdev/bitkit-ios/issues/715) opens the fixture's `pubkyauth` URL - directly in Bitkit. -- [#717](https://github.com/synonymdev/bitkit-ios/issues/717) prevents an Electrum-rejected broadcast - from reaching `SendSuccess`. +## Wallet setup Before opening the fixture setup auth URL, use the header profile button and Create path to give each wallet a Pubky identity. A Ring identity works when its root secret is available through @@ -90,7 +80,7 @@ artifacts at each boundary: | Boundary | Bitkit evidence | Integration evidence | | --- | --- | --- | -| Watch-only claim | `PubkyAuthWatchOnlyConsent`, `PubkyAuthWatchOnlyApprove`, `PubkyAuthAuthorize`, and `PubkyAuthOK` snapshots | Setup completion and the claimed xpub/account index, with no spending key | +| Combined claim | `PubkyAuthWatchOnlyConsent`, `PubkyAuthWatchOnlyApprove`, `PubkyAuthPaykitAccess`, `PubkyAuthAuthorize`, and `PubkyAuthOK` snapshots | Setup completion and the claimed xpub/account index, with no spending key | | Linked buyer | Enabled `ContactPaymentsToggle`, `Contact_`, and `Contact_` snapshots | Seller and buyer peer-link state | | Incoming request | `ReviewAmount`, `PaymentRequestsBell`, `PaymentRequestsSheet`, and `PaymentRequestRow--one-time` snapshots showing the automatic review, seller, amount, and note when present | Delivery record and exact Payment Request id | | Payment approval | `PaymentRequestPay-`, `ReviewAmount`, and `ReviewContactRecipient` snapshots | Derived regtest address and expected amount | diff --git a/journeys/pubky-marketplace/paykit-only-approval.xml b/journeys/pubky-marketplace/paykit-only-approval.xml index 1d92e3aba..caa41aa61 100644 --- a/journeys/pubky-marketplace/paykit-only-approval.xml +++ b/journeys/pubky-marketplace/paykit-only-approval.xml @@ -11,7 +11,7 @@ Open the fixture Paykit-only auth URL in the wallet Verify the authorization screen shows exactly /pub/paykit with READ, WRITE access Verify Paykit access (id "PubkyAuthPaykitAccess") describes access to private Paykit data and sending Paykit messages, without wallet spending keys - Verify no Earn introduction, watch-only consent (id "PubkyAuthWatchOnlyConsent"), or account picker is shown + Verify watch-only consent (id "PubkyAuthWatchOnlyConsent") is not shown Tap Cancel (id "PubkyAuthCancel") Verify the authorization sheet is dismissed Verify the current wallet's watch-only accounts and tracking settings are unchanged diff --git a/journeys/pubky-marketplace/paykit-reconnect.xml b/journeys/pubky-marketplace/paykit-reconnect.xml index 4a5fe26f5..c5fccaa76 100644 --- a/journeys/pubky-marketplace/paykit-reconnect.xml +++ b/journeys/pubky-marketplace/paykit-reconnect.xml @@ -9,7 +9,7 @@ Record the known service account's name, derivation path, xpub, and tracking setting Open the fixture Paykit-only reconnect auth URL in the wallet - Verify the authorization screen is visible without watch-only consent or an account picker + Verify the authorization screen is visible without watch-only consent Verify Paykit access (id "PubkyAuthPaykitAccess") describes private Paykit data and messages without sharing identity or spending keys Tap Cancel (id "PubkyAuthCancel") Verify the authorization sheet is dismissed From 9190f18a88ce301ae8ae22a162f8480f636f3d43 Mon Sep 17 00:00:00 2001 From: benk10 Date: Wed, 30 Sep 2026 23:41:11 -0500 Subject: [PATCH 04/68] fix: match paykit contacts to receiving addresses --- Bitkit/Services/CoreService.swift | 114 ++++++++++------- Bitkit/Services/LightningService.swift | 17 ++- .../PaykitReceivedPaymentContacts.swift | 4 +- .../AddressSearchCoordinatorTests.swift | 116 ++++++++++++++++++ .../PaykitPaymentRequestServiceTests.swift | 47 ++++++- 5 files changed, 247 insertions(+), 51 deletions(-) create mode 100644 BitkitTests/AddressSearchCoordinatorTests.swift diff --git a/Bitkit/Services/CoreService.swift b/Bitkit/Services/CoreService.swift index 831b7ba9d..aa0edf03d 100644 --- a/Bitkit/Services/CoreService.swift +++ b/Bitkit/Services/CoreService.swift @@ -1140,16 +1140,12 @@ class ActivityService { let currentWalletAddress = UserDefaults.standard.string(forKey: "onchainAddress") ?? "" let selectedAddressType = LDKNode.AddressType.fromStorage(UserDefaults.standard.string(forKey: "selectedAddressType")) - if let address = try await addressSearchCoordinator.runAddressSearch( + return try await addressSearchCoordinator.runAddressSearch( details: details, value: value, currentWalletAddress: currentWalletAddress, selectedAddressType: selectedAddressType - ) { - return address - } - - return details.outputs.first?.scriptpubkeyAddress + ) } func getActivity(id: String, walletId: String = WalletScope.default) async throws -> Activity? { @@ -1770,9 +1766,29 @@ class ActivityService { // MARK: - Address search (actor for single-flight concurrency) -private actor AddressSearchCoordinator { +actor AddressSearchCoordinator { private var isSearching = false private var waitQueue: [CheckedContinuation] = [] + private let defaults: UserDefaults + private let listAccounts: @Sendable () async throws -> [LDKNode.OnchainWalletAccount] + private let deriveAddresses: @Sendable (LDKNode.OnchainWalletAccount, LDKNode.KeychainKind, UInt32, UInt32) async throws -> [String] + + init( + defaults: UserDefaults = .standard, + listAccounts: @escaping @Sendable () async throws -> [LDKNode.OnchainWalletAccount] = { + try await LightningService.shared.listOnchainWalletAccounts() + }, + deriveAddresses: @escaping @Sendable (LDKNode.OnchainWalletAccount, LDKNode.KeychainKind, UInt32, UInt32) async throws -> [String] = { + account, keychain, startIndex, count in + try await LightningService.shared.addressInfosForType( + account.addressType, keychain: keychain, startIndex: startIndex, count: count, accountIndex: account.accountIndex + ).map(\.address) + } + ) { + self.defaults = defaults + self.listAccounts = listAccounts + self.deriveAddresses = deriveAddresses + } /// Runs the batch address search at most one at a time. Enqueues if a search is already in progress. func runAddressSearch( @@ -1830,47 +1846,61 @@ private actor AddressSearchCoordinator { (false, .external), (true, .internal), ] + let primaryAccounts = addressTypesToSearch.map { LDKNode.OnchainWalletAccount(addressType: $0, accountIndex: 0) } + let companionAccounts: [LDKNode.OnchainWalletAccount] + do { + companionAccounts = try await listAccounts().filter { $0.accountIndex > 0 }.sorted { + ($0.accountIndex, $0.addressType.stringValue) < ($1.accountIndex, $1.addressType.stringValue) + } + } catch { + Logger.warn("Failed to list companion accounts for address search: \(error)", context: "CoreService.AddressSearch") + companionAccounts = [] + } + // Preserve account-zero priority, including its change addresses, before searching companion accounts. + let searches = [primaryAccounts, companionAccounts].flatMap { accounts in + keychains.flatMap { isChange, keychain in + accounts.map { (account: $0, isChange: isChange, keychain: keychain) } + } + } - for (isChange, keychain) in keychains { - for addressType in addressTypesToSearch { - let key = isChange ? "addressSearch_lastUsedChangeIndex_\(addressType.stringValue)" : "addressSearch_lastUsedReceiveIndex_\(addressType.stringValue)" - let lastUsed: UInt32? = (UserDefaults.standard.object(forKey: key) as? Int).flatMap { - guard $0 >= 0, $0 <= Int(UInt32.max) else { return nil } - return UInt32($0) + for (account, isChange, keychain) in searches { + let addressType = account.addressType + let baseKey = isChange ? "addressSearch_lastUsedChangeIndex_\(addressType.stringValue)" : "addressSearch_lastUsedReceiveIndex_\(addressType.stringValue)" + let key = account.accountIndex == 0 ? baseKey : "\(baseKey)_account\(account.accountIndex)" + let lastUsed: UInt32? = (defaults.object(forKey: key) as? Int).flatMap { + guard $0 >= 0, $0 <= Int(UInt32.max) else { return nil } + return UInt32($0) + } + let endIndex = lastUsed.map { $0 > UInt32.max - searchWindow ? UInt32.max : $0 + searchWindow } ?? searchWindow + + var index: UInt32 = 0 + var currentAddressBatch: UInt32? + while index < endIndex { + let addresses: [String] + do { + addresses = try await deriveAddresses(account, keychain, index, batchSize) + } catch { + Logger.warn( + "Skipping \(addressType.stringValue) account \(account.accountIndex) \(isChange ? "change" : "receive") address search batch \(index): \(error)", + context: "CoreService.AddressSearch" + ) + break } - let endIndex = lastUsed.map { $0 > UInt32.max - searchWindow ? UInt32.max : $0 + searchWindow } ?? searchWindow - var index: UInt32 = 0 - var currentAddressBatch: UInt32? - while index < endIndex { - let addresses: [String] - do { - addresses = try await LightningService.shared - .addressInfosForType(addressType, keychain: keychain, startIndex: index, count: batchSize) - .map(\.address) - } catch { - Logger.warn( - "Skipping \(addressType.stringValue) \(isChange ? "change" : "receive") address search batch \(index): \(error)", - context: "CoreService.AddressSearch" - ) + if !currentWalletAddress.isEmpty, currentAddressBatch == nil, addresses.contains(currentWalletAddress) { + currentAddressBatch = index + } + if let match = findMatch(in: addresses) { + defaults.set(Int(index), forKey: key) + return match + } + if let found = currentAddressBatch { + let stopIndex = found > UInt32.max - batchSize ? UInt32.max : found + batchSize + if index >= stopIndex { break } - - if !currentWalletAddress.isEmpty, currentAddressBatch == nil, addresses.contains(currentWalletAddress) { - currentAddressBatch = index - } - if let match = findMatch(in: addresses) { - UserDefaults.standard.set(Int(index), forKey: key) - return match - } - if let found = currentAddressBatch { - let stopIndex = found > UInt32.max - batchSize ? UInt32.max : found + batchSize - if index >= stopIndex { - break - } - } - index += batchSize } + index += batchSize } } return nil diff --git a/Bitkit/Services/LightningService.swift b/Bitkit/Services/LightningService.swift index c87f88f3d..2da2e870d 100644 --- a/Bitkit/Services/LightningService.swift +++ b/Bitkit/Services/LightningService.swift @@ -678,11 +678,22 @@ class LightningService { } } + func listOnchainWalletAccounts() async throws -> [LDKNode.OnchainWalletAccount] { + guard let node else { + throw AppError(serviceError: .nodeNotSetup) + } + + return try await ServiceQueue.background(.ldk) { + node.listOnchainWalletAccounts() + } + } + func addressInfosForType( _ addressType: LDKNode.AddressType, keychain: LDKNode.KeychainKind, startIndex: UInt32, - count: UInt32 + count: UInt32, + accountIndex: UInt32 = 0 ) async throws -> [AddressDerivationInfo] { guard let node else { throw AppError(serviceError: .nodeNotSetup) @@ -690,7 +701,9 @@ class LightningService { return try await ServiceQueue.background(.ldk) { try node.onchainPayment() - .addressInfosForType(addressType: addressType, keychain: keychain, startIndex: startIndex, count: count) + .addressInfosForAccount( + addressType: addressType, accountIndex: accountIndex, keychain: keychain, startIndex: startIndex, count: count + ) .map { AddressDerivationInfo(address: $0.address, index: $0.index) } } } diff --git a/Bitkit/Services/PaykitReceivedPaymentContacts.swift b/Bitkit/Services/PaykitReceivedPaymentContacts.swift index 83becbf09..26a84557c 100644 --- a/Bitkit/Services/PaykitReceivedPaymentContacts.swift +++ b/Bitkit/Services/PaykitReceivedPaymentContacts.swift @@ -59,7 +59,9 @@ struct PaykitReceivedPaymentContacts: Equatable { switch activity { case var .onchain(payment): guard payment.txType == .received, payment.contact == nil, - let contact = contact(onchainAddresses: outputAddresses) + outputAddresses.contains(payment.address), + let contact = contact(onchainAddresses: [payment.address]), + self.contact(onchainAddresses: outputAddresses) == contact else { return nil } payment.contact = contact payment.updatedAt = UInt64(Date().timeIntervalSince1970) diff --git a/BitkitTests/AddressSearchCoordinatorTests.swift b/BitkitTests/AddressSearchCoordinatorTests.swift new file mode 100644 index 000000000..731f018ad --- /dev/null +++ b/BitkitTests/AddressSearchCoordinatorTests.swift @@ -0,0 +1,116 @@ +@testable import Bitkit +import BitkitCore +import LDKNode +import XCTest + +final class AddressSearchCoordinatorTests: XCTestCase { + private var defaults: UserDefaults! + private var suiteName: String! + + override func setUp() { + super.setUp() + suiteName = "AddressSearchCoordinatorTests.\(UUID().uuidString)" + defaults = UserDefaults(suiteName: suiteName) + } + + override func tearDown() { + defaults.removePersistentDomain(forName: suiteName) + super.tearDown() + } + + func testCompanionReceiveAddressAfterChangeUsesSeparateSearchWindow() async throws { + let key = "addressSearch_lastUsedReceiveIndex_nativeSegwit_account3" + defaults.set(400, forKey: key) + let search = AddressSearchCoordinator( + defaults: defaults, + listAccounts: { [OnchainWalletAccount(addressType: .nativeSegwit, accountIndex: 3)] }, + deriveAddresses: { account, keychain, start, count in + XCTAssertEqual(count, 200) + if account.accountIndex == 3, keychain == .external, start == 1200 { + return ["server-receive"] + } + return [] + } + ) + let result = try await search.runAddressSearch( + details: details(["unrelated-change", "server-receive"]), value: 15000, + currentWalletAddress: "ordinary-receive", selectedAddressType: .nativeSegwit + ) + XCTAssertEqual(result, "server-receive") + XCTAssertEqual(defaults.integer(forKey: key), 1200) + XCTAssertNil(defaults.object(forKey: "addressSearch_lastUsedReceiveIndex_nativeSegwit")) + } + + func testAccountZeroChangeKeepsPriorityAndExistingCacheKey() async throws { + let search = AddressSearchCoordinator( + defaults: defaults, + listAccounts: { [OnchainWalletAccount(addressType: .nativeSegwit, accountIndex: 3)] }, + deriveAddresses: { account, keychain, start, _ in + XCTAssertEqual(account.accountIndex, 0, "Account zero should resolve before companion derivation") + return account.addressType == .nativeSegwit && keychain == .internal && start == 200 ? ["owned-change"] : [] + } + ) + let result = try await search.runAddressSearch( + details: details(["server-receive", "owned-change"]), value: 15000, + currentWalletAddress: "", selectedAddressType: .nativeSegwit + ) + XCTAssertEqual(result, "owned-change") + XCTAssertEqual(defaults.integer(forKey: "addressSearch_lastUsedChangeIndex_nativeSegwit"), 200) + } + + func testCurrentReceiveAddressDoesNotRequireAccountLookup() async throws { + let search = AddressSearchCoordinator( + defaults: defaults, + listAccounts: { XCTFail("Current address should resolve without searching"); return [] }, + deriveAddresses: { _, _, _, _ in XCTFail("Current address should resolve without deriving"); return [] } + ) + let result = try await search.runAddressSearch( + details: details(["unrelated-change", "current"]), value: 15000, + currentWalletAddress: "current", selectedAddressType: .nativeSegwit + ) + XCTAssertEqual(result, "current") + } + + func testUnregisteredAddressDoesNotMatchAndSearchStaysBounded() async throws { + let search = AddressSearchCoordinator( + defaults: defaults, + listAccounts: { [] }, + deriveAddresses: { account, _, start, count in + XCTAssertEqual(account.accountIndex, 0) + XCTAssertLessThan(start, 1000) + XCTAssertEqual(count, 200) + return ["unrelated-owned-address"] + } + ) + let result = try await search.runAddressSearch( + details: details(["unverified-first-output", "unregistered-account-address"]), value: 15000, + currentWalletAddress: "", selectedAddressType: .nativeSegwit + ) + XCTAssertNil(result) + } + + func testDerivationFailureDoesNotSelectTransactionOutput() async throws { + let search = AddressSearchCoordinator( + defaults: defaults, + listAccounts: { [OnchainWalletAccount(addressType: .nativeSegwit, accountIndex: 3)] }, + deriveAddresses: { _, _, _, _ in throw AppError(serviceError: .nodeNotSetup) } + ) + let result = try await search.runAddressSearch( + details: details(["unverified-first-output"]), value: 15000, + currentWalletAddress: "", selectedAddressType: .nativeSegwit + ) + XCTAssertNil(result) + } + + private func details(_ addresses: [String]) -> BitkitCore.TransactionDetails { + BitkitCore.TransactionDetails( + walletId: WalletScope.default, txId: "tx", amountSats: 15000, inputs: [], + outputs: addresses.enumerated().map { index, address in + BitkitCore.TxOutput( + scriptpubkey: "", scriptpubkeyType: "p2wpkh", scriptpubkeyAddress: address, + value: index == addresses.count - 1 ? 15000 : 3000, n: UInt32(index) + ) + } + ) + } +} diff --git a/BitkitTests/PaykitPaymentRequestServiceTests.swift b/BitkitTests/PaykitPaymentRequestServiceTests.swift index 3422217e6..d333ab754 100644 --- a/BitkitTests/PaykitPaymentRequestServiceTests.swift +++ b/BitkitTests/PaykitPaymentRequestServiceTests.swift @@ -107,12 +107,7 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { func testReceivedPaymentAttributionPreservesExistingContactsAndMetadata() throws { let record = try receivedPaymentRecord() let contacts = PaykitReceivedPaymentContacts(records: [record], network: .regtest) - var payment = OnchainActivity( - walletId: WalletScope.default, id: "received", txType: .received, txId: "tx", value: 15000, - fee: 0, feeRate: 0, address: "bcrt1qfn50lqawrce0evh66qrnlt8j447lwmeyqp5gmd", confirmed: true, timestamp: 123, - isBoosted: false, boostTxIds: [], isTransfer: false, doesExist: true, confirmTimestamp: 124, - channelId: nil, transferTxId: nil, contact: nil, createdAt: 123, updatedAt: 124, seenAt: 125 - ) + var payment = receivedOnchainActivity() XCTAssertNil(contacts.attributing(.onchain(payment)), "Wait for complete transaction outputs") guard case let .onchain(updated) = contacts.attributing(.onchain(payment), outputAddresses: [payment.address]) else { return XCTFail("Expected received contact attribution") @@ -128,6 +123,46 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { XCTAssertNil(contacts.attributing(.onchain(payment), outputAddresses: [payment.address])) } + func testReceivedPaymentAttributionRequiresReceivingAddressInRequestAndOutputs() throws { + let contacts = try PaykitReceivedPaymentContacts(records: [receivedPaymentRecord()], network: .regtest) + var payment = receivedOnchainActivity() + let requestAddress = payment.address + let otherAddress = "bcrt1qpsps9chsjnnd3veems9phzlvw42em682rsj8hh" + XCTAssertNil(contacts.attributing(.onchain(payment), outputAddresses: [otherAddress])) + payment.address = otherAddress + XCTAssertNil(contacts.attributing(.onchain(payment), outputAddresses: [otherAddress, requestAddress])) + } + + func testReceivedPaymentAttributionKeepsAmbiguityVetoAcrossOutputs() throws { + let payment = receivedOnchainActivity() + var other = try receivedPaymentRecord(counterparty: "pubky7don8zi885feihpjsyx7t53srod6z1n4xjiyaaxucpqarm6sh85o") + let otherAddress = "bcrt1qpsps9chsjnnd3veems9phzlvw42em682rsj8hh" + other.terms?.paymentEndpoints = try ["btc-regtest-p2wpkh": PublicPaykitService.serializePayload(value: otherAddress)] + let contacts = try PaykitReceivedPaymentContacts(records: [receivedPaymentRecord(), other], network: .regtest) + XCTAssertNil(contacts.attributing(.onchain(payment), outputAddresses: [payment.address, otherAddress])) + } + + func testReceivedPaymentAttributionMatchesLateServerInvoiceAfterChangeOutput() throws { + var record = try receivedPaymentRecord() + record.proposalAppId = "paykit-server" + record.state = .canceled + let payment = receivedOnchainActivity() + let contacts = PaykitReceivedPaymentContacts(records: [record], network: .regtest) + guard case let .onchain(updated) = contacts.attributing( + .onchain(payment), outputAddresses: ["bcrt1qpsps9chsjnnd3veems9phzlvw42em682rsj8hh", payment.address] + ) else { return XCTFail("Expected the receiving server invoice to identify its contact") } + XCTAssertEqual(updated.contact, record.counterparty) + } + + private func receivedOnchainActivity() -> OnchainActivity { + OnchainActivity( + walletId: WalletScope.default, id: "received", txType: .received, txId: "tx", value: 15000, + fee: 0, feeRate: 0, address: "bcrt1qfn50lqawrce0evh66qrnlt8j447lwmeyqp5gmd", confirmed: true, timestamp: 123, + isBoosted: false, boostTxIds: [], isTransfer: false, doesExist: true, confirmTimestamp: 124, + channelId: nil, transferTxId: nil, contact: nil, createdAt: 123, updatedAt: 124, seenAt: 125 + ) + } + func testReceivedPaymentContactsRefreshAndClearWithIdentity() async throws { var record = try receivedPaymentRecord() record.proposalAppId = "paykit-server" From 8d81890c6def6e93944a41eb75ff15d007c68256 Mon Sep 17 00:00:00 2001 From: benk10 Date: Thu, 1 Oct 2026 00:15:13 -0500 Subject: [PATCH 05/68] fix: report private paykit cleanup failures --- Bitkit/Services/ContactPaymentsService.swift | 15 +++++++------ BitkitTests/ContactPaymentsServiceTests.swift | 21 ++++++++++++------- 2 files changed, 22 insertions(+), 14 deletions(-) diff --git a/Bitkit/Services/ContactPaymentsService.swift b/Bitkit/Services/ContactPaymentsService.swift index 51d7ae324..7b2d34255 100644 --- a/Bitkit/Services/ContactPaymentsService.swift +++ b/Bitkit/Services/ContactPaymentsService.swift @@ -90,6 +90,7 @@ enum ContactPaymentsService { privateCleanupPending: defaults.bool(forKey: PrivatePaykitService.cleanupPendingKey) ) + var privateCleanupError: Error? do { if enabled { try await enable( @@ -99,7 +100,7 @@ enum ContactPaymentsService { defaults: defaults ) } else { - try await disable(operations: operations, defaults: defaults) + privateCleanupError = try await disable(operations: operations, defaults: defaults) } } catch { await restore( @@ -111,6 +112,9 @@ enum ContactPaymentsService { ) throw error } + if let privateCleanupError { + throw privateCleanupError + } } @MainActor @@ -146,16 +150,14 @@ enum ContactPaymentsService { } @MainActor - private static func disable(operations: Operations, defaults: UserDefaults) async throws { + private static func disable(operations: Operations, defaults: UserDefaults) async throws -> Error? { + var privateCleanupError: Error? do { try await operations.removePrivateEndpoints() operations.setPrivateCleanupPending(false) } catch { operations.setPrivateCleanupPending(true) - Logger.warn( - "Deferred private Paykit endpoint cleanup after disable failed: \(error)", - context: "ContactPaymentsService" - ) + privateCleanupError = error } defaults.set(false, forKey: PublicPaykitService.publishingEnabledKey) @@ -169,6 +171,7 @@ enum ContactPaymentsService { operations.setPublicCleanupPending(true) throw error } + return privateCleanupError } @MainActor diff --git a/BitkitTests/ContactPaymentsServiceTests.swift b/BitkitTests/ContactPaymentsServiceTests.swift index 719c65202..8f43c2325 100644 --- a/BitkitTests/ContactPaymentsServiceTests.swift +++ b/BitkitTests/ContactPaymentsServiceTests.swift @@ -201,7 +201,7 @@ final class ContactPaymentsServiceTests: XCTestCase { } } - func testFailedPrivateDisableKeepsContactPaymentsDisabled() async throws { + func testFailedPrivateDisableReportsFailureAndKeepsContactPaymentsDisabled() async throws { try await withIsolatedDefaultsAsync { defaults in defaults.set(true, forKey: PublicPaykitService.publishingEnabledKey) defaults.set(true, forKey: PrivatePaykitService.publishingEnabledKey) @@ -210,13 +210,18 @@ final class ContactPaymentsServiceTests: XCTestCase { let operations = OperationsSpy() operations.privateRemovalFailures = [1] - try await ContactPaymentsService.setEnabled( - false, - contactPublicKeys: ["contact-a"], - canUsePrivatePayments: true, - operations: operations.makeOperations(), - defaults: defaults - ) + do { + try await ContactPaymentsService.setEnabled( + false, + contactPublicKeys: ["contact-a"], + canUsePrivatePayments: true, + operations: operations.makeOperations(), + defaults: defaults + ) + XCTFail("Expected private endpoint removal to fail") + } catch { + XCTAssertEqual(error as? TestError, .operationFailed) + } XCTAssertEqual(operations.publicPublicationValues, [false]) XCTAssertEqual(operations.privateRemovalCount, 1) From 6ec60b7945129c8ffddcbf5c2d09ed0d3fd03cea Mon Sep 17 00:00:00 2001 From: benk10 Date: Thu, 1 Oct 2026 06:44:36 -0500 Subject: [PATCH 06/68] fix: guard shared Paykit payments and contact attribution --- Bitkit/AppScene.swift | 34 ++- Bitkit/Services/CoreService.swift | 26 +- .../PaykitPaymentRequestService.swift | 53 +++- .../PaykitReceivedPaymentContacts.swift | 129 ++++++++-- ...PrivatePaykitAddressReservationStore.swift | 30 ++- .../PrivatePaykitService+Contacts.swift | 3 +- .../PrivatePaykitService+Payments.swift | 3 +- Bitkit/Utilities/Keychain.swift | 2 + .../PaykitPaymentRequestServiceTests.swift | 169 +++++++++++-- .../PaykitReceivedPaymentContactsTests.swift | 227 ++++++++++++++++++ BitkitTests/PrivatePaykitServiceTests.swift | 31 +++ journeys/pubky-marketplace/README.md | 2 +- journeys/pubky-marketplace/wallet-leg.xml | 6 +- 13 files changed, 639 insertions(+), 76 deletions(-) create mode 100644 BitkitTests/PaykitReceivedPaymentContactsTests.swift diff --git a/Bitkit/AppScene.swift b/Bitkit/AppScene.swift index 535182e5e..ec723d004 100644 --- a/Bitkit/AppScene.swift +++ b/Bitkit/AppScene.swift @@ -234,6 +234,9 @@ struct AppScene: View { @State private var hwWalletManager: HwWalletManager @State private var calculatorInputManager = CalculatorInputManager() @State private var paykitPaymentRequestManager = PaykitPaymentRequestManager() + @State private var receivedPaymentBackfillCache = PaykitReceivedPaymentBackfillCache( + activityChanges: CoreService.shared.activity.activitiesChangedPublisher + ) @State private var initialPaykitSyncGeneration = 0 @State private var hideSplash = false @@ -308,13 +311,27 @@ struct AppScene: View { _trezorManager = State(initialValue: trezorManager) _trezorViewModel = State(initialValue: trezorViewModel) _hwWalletManager = State(initialValue: hwWalletManager) + } + private func configurePrivatePaykitContactResolvers() { CoreService.shared.activity.setPrivatePaykitContactResolvers( invoice: { paymentHash in await PrivatePaykitService.shared.contactPublicKey(forPrivateInvoicePaymentHash: paymentHash) }, - onchainAddress: { address in - await PrivatePaykitAddressReservationStore.shared.contactPublicKey(forReservedAddress: address) + onchainAddresses: { @MainActor address, outputAddresses in + let identity = pubkyProfile.publicKey + let contacts = paykitPaymentRequestManager.receivedPaymentContacts + let reservations = PrivatePaykitAddressReservationStore.shared + let reservationRevision = await reservations.attributionRevision + guard let combined = try? await contacts.includingReservations(for: outputAddresses, lookup: { + try await reservations.contactPublicKeyForAttribution(forReservedAddress: $0) + }) else { return nil } + guard pubkyProfile.authState == .authenticated, + PubkyPublicKeyFormat.matches(identity, pubkyProfile.publicKey), + contacts == paykitPaymentRequestManager.receivedPaymentContacts, + await reservations.attributionRevision == reservationRevision + else { return nil } + return combined.contact(receivingAddress: address, outputAddresses: outputAddresses) } ) } @@ -417,6 +434,7 @@ struct AppScene: View { private var appEventContent: some View { configuredContent .onChange(of: pubkyProfile.authState, initial: true) { _, authState in + receivedPaymentBackfillCache.invalidate() if authState == .authenticated, let pk = pubkyProfile.publicKey { paykitPaymentRequestManager.activate(identity: pk) Task { @@ -841,6 +859,7 @@ struct AppScene: View { @Sendable private func setupTask() async { + configurePrivatePaykitContactResolvers() do { // Handle orphaned keychain before anything else handleOrphanedKeychain() @@ -1104,13 +1123,20 @@ struct AppScene: View { if refreshMaintenance { await PaykitPaymentProofService.shared.reconcile() } - let identity = pubkyProfile.publicKey + guard let identity = pubkyProfile.publicKey else { return } await paykitPaymentRequestManager.refresh() guard pubkyProfile.authState == .authenticated, PubkyPublicKeyFormat.matches(identity, pubkyProfile.publicKey) else { return } + let contacts = paykitPaymentRequestManager.receivedPaymentContacts do { - try await CoreService.shared.activity.backfillReceivedPaykitContacts(paykitPaymentRequestManager.receivedPaymentContacts) + try await CoreService.shared.activity.backfillReceivedPaykitContacts( + contacts, identity: identity, cache: receivedPaymentBackfillCache + ) { @MainActor in + pubkyProfile.authState == .authenticated && + PubkyPublicKeyFormat.matches(identity, pubkyProfile.publicKey) && + contacts == paykitPaymentRequestManager.receivedPaymentContacts + } } catch { Logger.warn("Failed to attribute received Paykit payments: \(error)", context: "AppScene") } diff --git a/Bitkit/Services/CoreService.swift b/Bitkit/Services/CoreService.swift index aa0edf03d..6290cc491 100644 --- a/Bitkit/Services/CoreService.swift +++ b/Bitkit/Services/CoreService.swift @@ -56,14 +56,14 @@ class ActivityService { } private var privateInvoiceContactResolver: (@Sendable (String) async -> String?)? - private var privateOnchainAddressContactResolver: (@Sendable (String) async -> String?)? + private var privateOnchainAddressContactResolver: (@Sendable (String, [String]) async -> String?)? func setPrivatePaykitContactResolvers( invoice: (@Sendable (String) async -> String?)?, - onchainAddress: (@Sendable (String) async -> String?)? + onchainAddresses: (@Sendable (String, [String]) async -> String?)? ) { privateInvoiceContactResolver = invoice - privateOnchainAddressContactResolver = onchainAddress + privateOnchainAddressContactResolver = onchainAddresses } // MARK: - Constants @@ -639,7 +639,14 @@ class ActivityService { } if contact == nil { - contact = await privatePaykitContactPublicKey(forReservedAddress: address) + let details: BitkitCore.TransactionDetails? = if let transactionDetails { + transactionDetails + } else { + await fetchTransactionDetails(txid: txid) + } + if let details { + contact = await privateOnchainAddressContactResolver?(address, details.outputs.compactMap(\.scriptpubkeyAddress)) + } } } @@ -933,10 +940,6 @@ class ActivityService { return await privateInvoiceContactResolver?(paymentHash) } - private func privatePaykitContactPublicKey(forReservedAddress address: String) async -> String? { - await privateOnchainAddressContactResolver?(address) - } - /// Sync all LDK node payments to activities /// Use for initial wallet load, manual refresh, or after operations that create new payments. /// Events handle individual payment updates, so this should not be called on every event. @@ -1865,7 +1868,9 @@ actor AddressSearchCoordinator { for (account, isChange, keychain) in searches { let addressType = account.addressType - let baseKey = isChange ? "addressSearch_lastUsedChangeIndex_\(addressType.stringValue)" : "addressSearch_lastUsedReceiveIndex_\(addressType.stringValue)" + let baseKey = isChange + ? "addressSearch_lastUsedChangeIndex_\(addressType.stringValue)" + : "addressSearch_lastUsedReceiveIndex_\(addressType.stringValue)" let key = account.accountIndex == 0 ? baseKey : "\(baseKey)_account\(account.accountIndex)" let lastUsed: UInt32? = (defaults.object(forKey: key) as? Int).flatMap { guard $0 >= 0, $0 <= Int(UInt32.max) else { return nil } @@ -1881,7 +1886,8 @@ actor AddressSearchCoordinator { addresses = try await deriveAddresses(account, keychain, index, batchSize) } catch { Logger.warn( - "Skipping \(addressType.stringValue) account \(account.accountIndex) \(isChange ? "change" : "receive") address search batch \(index): \(error)", + "Skipping \(addressType.stringValue) account \(account.accountIndex) " + + "\(isChange ? "change" : "receive") address search batch \(index): \(error)", context: "CoreService.AddressSearch" ) break diff --git a/Bitkit/Services/PaykitPaymentRequestService.swift b/Bitkit/Services/PaykitPaymentRequestService.swift index 3a1779240..56e220357 100644 --- a/Bitkit/Services/PaykitPaymentRequestService.swift +++ b/Bitkit/Services/PaykitPaymentRequestService.swift @@ -978,7 +978,7 @@ struct PaykitPaymentRequestService { } } -protocol PaykitPaymentRequestPresentationStoring { +protocol PaykitPaymentRequestIdStoring { func load(identity: String) throws -> Set func save(_ ids: Set, identity: String) throws } @@ -1018,13 +1018,15 @@ enum PaykitPaymentRequestPresentationCoordinator { } } -struct PaykitPaymentRequestPresentationStore: PaykitPaymentRequestPresentationStoring { +struct PaykitPaymentRequestIdStore: PaykitPaymentRequestIdStoring { + var key: KeychainEntryType = .paykitPresentedPaymentRequests + private struct State: Codable { var idsByIdentity: [String: [PaykitPaymentRequest.ID]] } func load(identity: String) throws -> Set { - guard let data = try Keychain.load(key: .paykitPresentedPaymentRequests) else { return [] } + guard let data = try Keychain.load(key: key) else { return [] } let state = try JSONDecoder().decode(State.self, from: data) guard let normalizedIdentity = PubkyPublicKeyFormat.normalized(identity) else { return [] } return Set(state.idsByIdentity[normalizedIdentity] ?? []) @@ -1032,13 +1034,13 @@ struct PaykitPaymentRequestPresentationStore: PaykitPaymentRequestPresentationSt func save(_ ids: Set, identity: String) throws { guard let normalizedIdentity = PubkyPublicKeyFormat.normalized(identity) else { return } - var state: State = if let data = try Keychain.load(key: .paykitPresentedPaymentRequests) { + var state: State = if let data = try Keychain.load(key: key) { try JSONDecoder().decode(State.self, from: data) } else { State(idsByIdentity: [:]) } state.idsByIdentity[normalizedIdentity] = Array(ids) - try Keychain.upsert(key: .paykitPresentedPaymentRequests, data: JSONEncoder().encode(state)) + try Keychain.upsert(key: key, data: JSONEncoder().encode(state)) } } @@ -1063,7 +1065,8 @@ final class PaykitPaymentRequestManager { private(set) var requestedPresentationUnavailableTrigger = 0 private let service: PaykitPaymentRequestService - private let presentationStore: any PaykitPaymentRequestPresentationStoring + private let presentationStore: any PaykitPaymentRequestIdStoring + private let acceptanceStore: any PaykitPaymentRequestIdStoring private let subscriptionStateStore: any PaykitSubscriptionStateStoring private let subscriptionNotificationScheduler: PaykitSubscriptionNotificationScheduler private let completedPaymentProofKinds: @Sendable (String) async -> [PaykitPaymentRequest.ID: PaykitPaymentProofKind] @@ -1078,6 +1081,7 @@ final class PaykitPaymentRequestManager { private let isAvailable: @MainActor () -> Bool private var processingRequestIds: Set = [] private var approvedPaymentRequestIds: Set = [] + private var acceptedRequestIds: Set = [] private var presentedRequestIds: Set = [] private var presentationRetryAttempts: [PaykitPaymentRequest.ID: Int] = [:] private var presentationRetryDeadlines: [PaykitPaymentRequest.ID: ContinuousClock.Instant] = [:] @@ -1117,7 +1121,8 @@ final class PaykitPaymentRequestManager { init( service: PaykitPaymentRequestService? = nil, - presentationStore: any PaykitPaymentRequestPresentationStoring = PaykitPaymentRequestPresentationStore(), + presentationStore: any PaykitPaymentRequestIdStoring = PaykitPaymentRequestIdStore(), + acceptanceStore: any PaykitPaymentRequestIdStoring = PaykitPaymentRequestIdStore(key: .paykitAcceptedPaymentRequests), subscriptionStateStore: any PaykitSubscriptionStateStoring = PaykitSubscriptionStateStore(), subscriptionNotificationScheduler: PaykitSubscriptionNotificationScheduler = PaykitSubscriptionNotificationScheduler(), completedPaymentProofKinds: @escaping @Sendable (String) async -> [PaykitPaymentRequest.ID: PaykitPaymentProofKind] = { identity in @@ -1144,6 +1149,7 @@ final class PaykitPaymentRequestManager { ) { self.service = service ?? PaykitPaymentRequestService(now: now, logWarning: logWarning) self.presentationStore = presentationStore + self.acceptanceStore = acceptanceStore self.subscriptionStateStore = subscriptionStateStore self.subscriptionNotificationScheduler = subscriptionNotificationScheduler self.completedPaymentProofKinds = completedPaymentProofKinds @@ -1161,6 +1167,13 @@ final class PaykitPaymentRequestManager { if activeIdentity != nil { clear() } + do { + acceptedRequestIds = try acceptanceStore.load(identity: normalizedIdentity) + } catch { + acceptedRequestIds = [] + logWarning("Failed to restore locally accepted Paykit payment requests: \(error)") + return + } do { presentedRequestIds = try presentationStore.load(identity: normalizedIdentity) persistedPresentedRequestIds = presentedRequestIds @@ -1465,8 +1478,16 @@ final class PaykitPaymentRequestManager { } func ensurePaymentAllowed(_ request: PaykitPaymentRequest) async throws { + let generation = stateGeneration do { + guard let identity = activeIdentity, isApprovedForPayment(request) else { + throw PaykitPaymentRequestError.requestUnavailable + } try await service.ensurePaymentAllowed(request) + guard generation == stateGeneration, + PubkyPublicKeyFormat.matches(activeIdentity, identity), + isApprovedForPayment(request) + else { throw PaykitPaymentRequestError.requestUnavailable } } catch { approvedPaymentRequestIds.remove(request.id) throw error @@ -1477,6 +1498,7 @@ final class PaykitPaymentRequestManager { _ request: PaykitPaymentRequest, consumePrivatePaymentList: () async throws -> Void = {} ) async throws { + guard let identity = activeIdentity else { throw PaykitPaymentRequestError.requestUnavailable } do { if isApprovedForPayment(request) { try await ensurePaymentAllowed(request) @@ -1488,11 +1510,20 @@ final class PaykitPaymentRequestManager { markApprovedForPayment: true, preservePending: !request.requiresAcceptance ) { - try await ensurePaymentAllowed($0) + guard $0.billingPeriod != nil || $0.requiresAcceptance || acceptedRequestIds.contains($0.id) else { + throw PaykitPaymentRequestError.requestUnavailable + } + try await service.ensurePaymentAllowed($0) try await service.claimForPayment($0) try await consumePrivatePaymentList() if $0.requiresAcceptance { try await service.accept($0) + var ids = try acceptanceStore.load(identity: identity) + ids.insert($0.id) + try acceptanceStore.save(ids, identity: identity) + if PubkyPublicKeyFormat.matches(activeIdentity, identity) { + acceptedRequestIds = ids + } } } } catch is CancellationError { @@ -1699,6 +1730,7 @@ final class PaykitPaymentRequestManager { eligibleTargets = [] processingRequestIds = [] approvedPaymentRequestIds = [] + acceptedRequestIds = [] activeIdentity = nil savedPublicKeys = [] presentedRequestIds = [] @@ -1776,6 +1808,7 @@ final class PaykitPaymentRequestManager { func finishPayment(_ request: PaykitPaymentRequest) async { approvedPaymentRequestIds.remove(request.id) guard request.billingPeriod == nil, + acceptedRequestIds.contains(request.id), let activeIdentity, let acceptedRequest = historyRequests.first(where: { $0.id == request.id && $0.direction == .incoming && $0.lifecycleState == .accepted @@ -1831,6 +1864,7 @@ final class PaykitPaymentRequestManager { let payableStates: [Paykit.PaymentRequestLifecycleState] = pendingRequest == nil ? [.accepted] : [.proposed, .accepted] guard let sdkLifecycleState, payableStates.contains(sdkLifecycleState), + sdkLifecycleState != .accepted || acceptedRequestIds.contains(id), actionGeneration == stateGeneration else { return nil } if let pendingRequest = pendingRequests.first(where: { $0.id == id }) { @@ -2005,7 +2039,8 @@ final class PaykitPaymentRequestManager { processingRequestIds.contains($0.id) && $0.id != excludingProtectedRequestId } let oneTimePending = snapshot.incoming.filter { - !locallyCompletedRequestIds.contains($0.id) && + ($0.requiresAcceptance || acceptedRequestIds.contains($0.id)) && + !locallyCompletedRequestIds.contains($0.id) && !locallyInFlightRequestIds.contains($0.id) && !approvedPaymentRequestIds.contains($0.id) } diff --git a/Bitkit/Services/PaykitReceivedPaymentContacts.swift b/Bitkit/Services/PaykitReceivedPaymentContacts.swift index 26a84557c..a837844c8 100644 --- a/Bitkit/Services/PaykitReceivedPaymentContacts.swift +++ b/Bitkit/Services/PaykitReceivedPaymentContacts.swift @@ -1,6 +1,8 @@ import BitkitCore +import Combine import Foundation import LDKNode +import os import Paykit struct PaykitReceivedPaymentContacts: Equatable { @@ -55,13 +57,33 @@ struct PaykitReceivedPaymentContacts: Equatable { return contacts.count == 1 ? contacts.first : nil } + func includingReservations( + for outputAddresses: [String], + lookup: (String) async throws -> String? + ) async rethrows -> Self { + var combined = self + for address in Set(outputAddresses) { + guard let reservedContact = try await lookup(address), + let contact = PubkyPublicKeyFormat.normalized(reservedContact) + else { continue } + combined.onchain[address, default: []].insert(contact) + } + return combined + } + + func contact(receivingAddress: String, outputAddresses: [String]) -> String? { + guard !receivingAddress.isEmpty, outputAddresses.contains(receivingAddress), + let contact = contact(onchainAddresses: [receivingAddress]), + self.contact(onchainAddresses: outputAddresses) == contact + else { return nil } + return contact + } + func attributing(_ activity: Activity, outputAddresses: [String] = []) -> Activity? { switch activity { case var .onchain(payment): guard payment.txType == .received, payment.contact == nil, - outputAddresses.contains(payment.address), - let contact = contact(onchainAddresses: [payment.address]), - self.contact(onchainAddresses: outputAddresses) == contact + let contact = contact(receivingAddress: payment.address, outputAddresses: outputAddresses) else { return nil } payment.contact = contact payment.updatedAt = UInt64(Date().timeIntervalSince1970) @@ -86,27 +108,102 @@ struct PaykitReceivedPaymentContacts: Equatable { } } +final class PaykitReceivedPaymentBackfillCache { + private struct Snapshot: Equatable { + let identity: String + let contacts: PaykitReceivedPaymentContacts + let activityRevision: UInt64 + let reservationRevision: UInt64 + } + + private let state = OSAllocatedUnfairLock(initialState: (revision: UInt64(0), snapshot: Snapshot?.none)) + private var activityChanges: AnyCancellable? + + init(activityChanges: AnyPublisher) { + self.activityChanges = activityChanges.sink { [weak self] in self?.invalidate() } + } + + func invalidate() { + state.withLock { + $0.revision &+= 1 + $0.snapshot = nil + } + } + + func scanIfNeeded( + identity: String, + contacts: PaykitReceivedPaymentContacts, + reservationRevision: UInt64, + scan: () async throws -> Bool + ) async rethrows { + let snapshot: Snapshot? = state.withLock { + let snapshot = Snapshot(identity: identity, contacts: contacts, activityRevision: $0.revision, reservationRevision: reservationRevision) + guard $0.snapshot != snapshot else { return nil } + $0.snapshot = nil + return snapshot + } + guard let snapshot, try await scan() else { return } + state.withLock { + guard $0.revision == snapshot.activityRevision else { return } + $0.snapshot = snapshot + } + } +} + extension ActivityService { - func backfillReceivedPaykitContacts(_ contacts: PaykitReceivedPaymentContacts) async throws { - guard !contacts.isEmpty else { return } - try await ServiceQueue.background(.core) { - let activities = try getActivities( - walletId: WalletScope.default, filter: .all, txType: .received, - tags: nil, search: nil, minDate: nil, maxDate: nil, limit: nil, sortDirection: nil - ) + func backfillReceivedPaykitContacts( + _ contacts: PaykitReceivedPaymentContacts, + identity: String, + cache: PaykitReceivedPaymentBackfillCache, + isCurrent: @Sendable () async -> Bool + ) async throws { + let reservations = PrivatePaykitAddressReservationStore.shared + let reservationRevision = await reservations.attributionRevision + try await cache.scanIfNeeded(identity: identity, contacts: contacts, reservationRevision: reservationRevision) { + let activities = try await ServiceQueue.background(.core) { + try getActivities( + walletId: WalletScope.default, filter: .all, txType: .received, + tags: nil, search: nil, minDate: nil, maxDate: nil, limit: nil, sortDirection: nil + ) + } var changed = false - defer { if changed { self.notifyActivitiesChanged() } } + var complete = true + defer { if changed { notifyActivitiesChanged() } } for activity in activities { + guard !Task.isCancelled, await isCurrent() else { return false } var outputAddresses: [String] = [] - if case let .onchain(payment) = activity { + var combined = contacts + switch activity { + case let .onchain(payment): guard payment.contact == nil else { continue } - guard let details = try BitkitCore.getTransactionDetails(walletId: payment.walletId, txId: payment.txId) else { continue } + guard let details = try await getTransactionDetails(txid: payment.txId, walletId: payment.walletId) else { + complete = false + continue + } outputAddresses = details.outputs.compactMap(\.scriptpubkeyAddress) + guard !outputAddresses.isEmpty else { + complete = false + continue + } + combined = try await contacts.includingReservations(for: outputAddresses) { + try await reservations.contactPublicKeyForAttribution(forReservedAddress: $0) + } + case let .lightning(payment): + guard payment.contact == nil else { continue } + } + guard let updated = combined.attributing(activity, outputAddresses: outputAddresses) else { continue } + guard !Task.isCancelled, await isCurrent(), await reservations.attributionRevision == reservationRevision else { return false } + let didUpdate = try await ServiceQueue.background(.core) { + // Async lookups must not overwrite a contact or metadata written in the meantime. + guard try getActivityById(walletId: WalletScope.default, activityId: activity.activityId) == activity else { return false } + try updateActivity(activityId: activity.activityId, activity: updated) + return true } - guard let updated = contacts.attributing(activity, outputAddresses: outputAddresses) else { continue } - try updateActivity(activityId: activity.activityId, activity: updated) - changed = true + changed = changed || didUpdate + complete = complete && didUpdate } + guard complete, !Task.isCancelled, await isCurrent(), await reservations.attributionRevision == reservationRevision else { return false } + return true } } } diff --git a/Bitkit/Services/PrivatePaykitAddressReservationStore.swift b/Bitkit/Services/PrivatePaykitAddressReservationStore.swift index 59277318f..351cfcb03 100644 --- a/Bitkit/Services/PrivatePaykitAddressReservationStore.swift +++ b/Bitkit/Services/PrivatePaykitAddressReservationStore.swift @@ -15,7 +15,10 @@ actor PrivatePaykitAddressReservationStore { private static let schemaVersion = 1 private let defaults: UserDefaults - private var ledger: Ledger + private(set) var attributionRevision: UInt64 = 0 + private var ledger: Ledger { + didSet { attributionRevision &+= 1 } + } // MARK: - Initialization @@ -70,24 +73,19 @@ actor PrivatePaykitAddressReservationStore { // MARK: - Contact Assignments func contactPublicKey(forReservedAddress address: String) async -> String? { - guard !address.isEmpty else { return nil } - - if let publicKey = await currentContactPublicKey(forReservedAddress: address) { - return publicKey - } - - 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), - let reservedAddress = try? await self.address(for: addressType, receiveIndex: assignment.receiveIndex), - reservedAddress == address - else { continue } + try? await contactPublicKeyForAttribution(forReservedAddress: address) + } - return assignmentKey + func contactPublicKeyForAttribution(forReservedAddress address: String) async throws -> String? { + guard !address.isEmpty else { return nil } + for (publicKey, assignment) in contactAssignmentsForAttribution() { + guard let addressType = LDKNode.AddressType.from(string: assignment.addressType), + addressType.matchesAddressFormat(address, network: Env.network) + else { continue } + if try await self.address(for: addressType, receiveIndex: assignment.receiveIndex) == address { + return publicKey } } - return nil } diff --git a/Bitkit/Services/PrivatePaykitService+Contacts.swift b/Bitkit/Services/PrivatePaykitService+Contacts.swift index d64757b05..5ff63742d 100644 --- a/Bitkit/Services/PrivatePaykitService+Contacts.swift +++ b/Bitkit/Services/PrivatePaykitService+Contacts.swift @@ -637,7 +637,8 @@ extension PrivatePaykitService { for change in report.failedToQueue { let publicKey = PubkyPublicKeyFormat.normalized(change.counterparty) ?? change.counterparty Logger.warn( - "Failed to queue private Paykit endpoints for \(PubkyPublicKeyFormat.redacted(publicKey)) during \(reason): \(change.error?.redactedContext() ?? "unknown error")", + "Failed to queue private Paykit endpoints for \(PubkyPublicKeyFormat.redacted(publicKey)) during \(reason): " + + "\(change.error?.redactedContext() ?? "unknown error")", context: "PrivatePaykit" ) firstError = firstError ?? PrivatePaykitError.privateUnavailable diff --git a/Bitkit/Services/PrivatePaykitService+Payments.swift b/Bitkit/Services/PrivatePaykitService+Payments.swift index 270e9e381..c57a74258 100644 --- a/Bitkit/Services/PrivatePaykitService+Payments.swift +++ b/Bitkit/Services/PrivatePaykitService+Payments.swift @@ -205,7 +205,8 @@ extension PrivatePaykitService { return acceptedEndpoints.isEmpty ? .noEndpoint : .notOpened } Logger.info( - "Opened private Paykit payment for \(PubkyPublicKeyFormat.redacted(publicKey)) using payment list version \(paymentListVersion.map(String.init) ?? "none")", + "Opened private Paykit payment for \(PubkyPublicKeyFormat.redacted(publicKey)) " + + "using payment list version \(paymentListVersion.map(String.init) ?? "none")", context: "PrivatePaykit" ) return .opened( diff --git a/Bitkit/Utilities/Keychain.swift b/Bitkit/Utilities/Keychain.swift index 2fb94b919..054abe1d6 100644 --- a/Bitkit/Utilities/Keychain.swift +++ b/Bitkit/Utilities/Keychain.swift @@ -9,6 +9,7 @@ enum KeychainEntryType { case paykitSession case paykitPendingPaymentProofs case paykitPresentedPaymentRequests + case paykitAcceptedPaymentRequests case paykitSubscriptionState case paykitKeyGeneration(publicKey: String) case paykitRecoveryBackup @@ -24,6 +25,7 @@ enum KeychainEntryType { case .paykitSession: "paykit_session" case .paykitPendingPaymentProofs: "paykit_pending_payment_proofs" case .paykitPresentedPaymentRequests: "paykit_presented_payment_requests" + case .paykitAcceptedPaymentRequests: "paykit_accepted_payment_requests" case .paykitSubscriptionState: "paykit_subscription_state" case let .paykitKeyGeneration(publicKey): "paykit_key_generation_\(publicKey)" case .paykitRecoveryBackup: "paykit_recovery_backup" diff --git a/BitkitTests/PaykitPaymentRequestServiceTests.swift b/BitkitTests/PaykitPaymentRequestServiceTests.swift index d333ab754..b576fa268 100644 --- a/BitkitTests/PaykitPaymentRequestServiceTests.swift +++ b/BitkitTests/PaykitPaymentRequestServiceTests.swift @@ -70,7 +70,10 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { } func testReceivedPaymentContactsMatchExactLightningInvoiceOnCorrectNetwork() throws { - let invoice = "lnbcrt200n1p5hn4c8dqqnp4qwrgh4a03djj2sl34465uwnxhva0gtpjm4u8kvzgc5jergrkm9syypp55lwcgfpkdwuknmekjgted72n0ddl5qtaha7knk7c9n7yrjr4auassp5jgqw0a9w33e2ta4j7gyjrvsvu0lv844w895305nd8spnknq3f2hq9qyysgqcqzp2xqyz5vqrzjq29gjy9sqjrrp48tz7hj2e5vm4l2dukc4csf2mn6qm32u3hted5leapyqqqqqqqtcsqqqqlgqqqqqqgq2qd2gk64eg2kfxtdaryrlh98hvu97jdaxz2ma7aeyuy2uy9vkn9x5qft47p9taju297xnrehva20xcfml7wacuv737xv3xjjzyrtplcxqpfpu9dt" + let invoice = "lnbcrt200n1p5hn4c8dqqnp4qwrgh4a03djj2sl34465uwnxhva0gtpjm4u8kvzgc5jergrkm9syypp55lwcgfpkdwuknmekjgte" + + "d72n0ddl5qtaha7knk7c9n7yrjr4auassp5jgqw0a9w33e2ta4j7gyjrvsvu0lv844w895305nd8spnknq3f2hq9qyysgqcqzp2x" + + "qyz5vqrzjq29gjy9sqjrrp48tz7hj2e5vm4l2dukc4csf2mn6qm32u3hted5leapyqqqqqqqtcsqqqqlgqqqqqqgq2qd2gk64eg2" + + "kfxtdaryrlh98hvu97jdaxz2ma7aeyuy2uy9vkn9x5qft47p9taju297xnrehva20xcfml7wacuv737xv3xjjzyrtplcxqpfpu9dt" var record = try receivedPaymentRecord() record.terms?.acceptedPaymentEndpointIdentifiers = ["btc-lightning-bolt11"] record.terms?.paymentEndpoints = try ["btc-lightning-bolt11": PublicPaykitService.serializePayload(value: invoice)] @@ -556,7 +559,7 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { state: .accepted ) let sdk = PaymentRequestSdkMock(records: [record]) - let manager = paymentRequestManager(sdk: sdk, clock: PaymentRequestTestClock(now)) + let manager = paymentRequestManager(sdk: sdk, clock: PaymentRequestTestClock(now), acceptedRecords: [record]) await manager.refresh() let request = try XCTUnwrap(manager.pendingRequests.first) await sdk.configureRecipients( @@ -888,7 +891,7 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { await manager.refresh() - XCTAssertEqual(manager.pendingRequests.map(\.paymentRequestId), ["incoming", "accepted"]) + XCTAssertEqual(manager.pendingRequests.map(\.paymentRequestId), ["incoming"]) XCTAssertEqual( Set(manager.historyRequests.map(\.paymentRequestId)), Set([ @@ -1391,7 +1394,7 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { state: .accepted, expiresAt: timestamp(now.addingTimeInterval(-60)) ) - let manager = paymentRequestManager(sdk: PaymentRequestSdkMock(records: [record]), clock: clock) + let manager = paymentRequestManager(sdk: PaymentRequestSdkMock(records: [record]), clock: clock, acceptedRecords: [record]) await manager.refresh() @@ -1525,6 +1528,7 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { logWarning: { _ in } ), presentationStore: PaymentRequestPresentationMemoryStore(), + acceptanceStore: PaymentRequestPresentationMemoryStore(), subscriptionStateStore: subscriptionStore, isAvailable: { true }, logWarning: { _ in } @@ -1559,6 +1563,7 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { let manager = PaykitPaymentRequestManager( service: PaykitPaymentRequestService(sdk: sdk, logWarning: { _ in }), presentationStore: PaymentRequestPresentationMemoryStore(), + acceptanceStore: PaymentRequestPresentationMemoryStore(), subscriptionStateStore: PaymentRequestSubscriptionStateMemoryStore(), isAvailable: { true }, logWarning: { _ in } @@ -1756,6 +1761,7 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { let manager = PaykitPaymentRequestManager( service: PaykitPaymentRequestService(sdk: sdk, now: { now }, logWarning: { _ in }), presentationStore: PaymentRequestPresentationMemoryStore(), + acceptanceStore: PaymentRequestPresentationMemoryStore(), subscriptionStateStore: PaymentRequestSubscriptionStateMemoryStore(), protectedRequestIdsForSubscriptionCancellation: { _, _ in await gate.wait() }, now: { now }, @@ -2376,6 +2382,7 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { let now: @Sendable () -> Date = { Date() } let manager = PaykitPaymentRequestManager( service: PaykitPaymentRequestService(sdk: sdk, now: now, logWarning: { _ in }), + acceptanceStore: PaymentRequestPresentationMemoryStore(), subscriptionStateStore: PaymentRequestSubscriptionStateMemoryStore(), completedPaymentProofKinds: { _ in [:] }, now: now, @@ -3056,12 +3063,14 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { } func testRetryDuringSuspendedNotificationSynchronizationStaysPresented() async throws { - let sdk = try PaymentRequestSdkMock(records: [paymentRequestRecord(state: .accepted)]) + let record = try paymentRequestRecord(state: .accepted) + let sdk = PaymentRequestSdkMock(records: [record]) let notificationCenter = PaykitSubscriptionNotificationCenterMock() let notificationScheduler = PaykitSubscriptionNotificationScheduler(center: notificationCenter) let manager = paymentRequestManager( sdk: sdk, - subscriptionNotificationScheduler: notificationScheduler + subscriptionNotificationScheduler: notificationScheduler, + acceptedRecords: [record] ) await manager.refresh() let acceptedRequest = try XCTUnwrap(manager.historyRequests.first) @@ -3420,8 +3429,9 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { } func testPaidPendingRequestIsNotReopenedForRetry() async throws { - let sdk = try PaymentRequestSdkMock(records: [paymentRequestRecord(state: .accepted)]) - let manager = paymentRequestManager(sdk: sdk) + let record = try paymentRequestRecord(state: .accepted) + let sdk = PaymentRequestSdkMock(records: [record]) + let manager = paymentRequestManager(sdk: sdk, acceptedRecords: [record]) await manager.refresh() let request = try XCTUnwrap(manager.pendingRequests.first) @@ -3434,8 +3444,9 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { } func testUnpaidPendingRequestIsReopenedForRetry() async throws { - let sdk = try PaymentRequestSdkMock(records: [paymentRequestRecord(state: .accepted)]) - let manager = paymentRequestManager(sdk: sdk) + let record = try paymentRequestRecord(state: .accepted) + let sdk = PaymentRequestSdkMock(records: [record]) + let manager = paymentRequestManager(sdk: sdk, acceptedRecords: [record]) await manager.refresh() let request = try XCTUnwrap(manager.pendingRequests.first) @@ -3446,6 +3457,112 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { XCTAssertFalse(manager.isApprovedForPayment(request)) } + func testOnlyAcceptingInstallCanResumeOneTimePayment() async throws { + var record = try paymentRequestRecord() + let sdk = PaymentRequestSdkMock(records: [record]) + let store = PaymentRequestPresentationMemoryStore() + let first = paymentRequestManager(sdk: sdk, acceptanceStore: store) + let second = paymentRequestManager(sdk: sdk) + await first.refresh() + await second.refresh() + let request = try XCTUnwrap(first.pendingRequests.first) + let staleRequest = try XCTUnwrap(second.pendingRequests.first) + + try await first.prepareForPayment(request) + record.state = .accepted + await sdk.setRecords([record]) + do { + try await second.prepareForPayment(staleRequest) + XCTFail("A stale proposal must not bypass SDK acceptance") + } catch {} + await second.refresh() + XCTAssertTrue(second.pendingRequests.isEmpty) + XCTAssertTrue(second.requestsForPresentation().isEmpty) + let accepted = try XCTUnwrap(second.historyRequests.first) + do { + try await second.ensurePaymentAllowed(accepted) + XCTFail("Another install's acceptance must not authorize payment") + } catch { + XCTAssertEqual(error as? PaykitPaymentRequestError, .requestUnavailable) + } + await second.finishPayment(accepted) + let retry = await second.paymentRequestForRetry(accepted.id) + XCTAssertNil(retry) + + let restored = paymentRequestManager(sdk: sdk, acceptanceStore: store) + await restored.refresh() + let resumable = try XCTUnwrap(restored.pendingRequests.first) + try await restored.prepareForPayment(resumable) + XCTAssertTrue(restored.isApprovedForPayment(resumable)) + let acceptedCalls = await sdk.snapshot().acceptedRequests + XCTAssertEqual(acceptedCalls.count, 1) + } + + func testIdentitySwitchPreventsExecutionOfPreparedProposal() async throws { + for switchDuringAuthorization in [false, true] { + let sdk = try PaymentRequestSdkMock(records: [paymentRequestRecord()]) + let manager = paymentRequestManager(sdk: sdk) + await manager.refresh() + let proposal = try XCTUnwrap(manager.pendingRequests.first) + try await manager.prepareForPayment(proposal) + try await manager.ensurePaymentAllowed(proposal) + + if switchDuringAuthorization { + await sdk.pauseNextLinkedPeers() + } else { + manager.activate(identity: "pubky\(String(repeating: "y", count: 52))") + } + let authorization = Task { try await manager.ensurePaymentAllowed(proposal) } + if switchDuringAuthorization { + try await waitUntil { await sdk.linkedPeersIsPaused() } + manager.activate(identity: "pubky\(String(repeating: "y", count: 52))") + await sdk.resumeLinkedPeers() + } + + do { + try await authorization.value + XCTFail("The previous identity's prepared proposal must not authorize payment") + } catch { + XCTAssertEqual(error as? PaykitPaymentRequestError, .requestUnavailable) + } + XCTAssertFalse(manager.isApprovedForPayment(proposal)) + } + } + + func testFailedAcceptancePersistenceDoesNotAuthorizePayment() async throws { + var record = try paymentRequestRecord() + let sdk = PaymentRequestSdkMock(records: [record]) + let store = PaymentRequestPresentationMemoryStore() + store.shouldFailSave = true + let manager = paymentRequestManager(sdk: sdk, acceptanceStore: store) + await manager.refresh() + let request = try XCTUnwrap(manager.pendingRequests.first) + + do { + try await manager.prepareForPayment(request) + XCTFail("Payment must wait for durable local acceptance") + } catch { + XCTAssertEqual(error as? PaymentRequestSdkMockError, .process) + } + XCTAssertFalse(manager.isApprovedForPayment(request)) + record.state = .accepted + await sdk.setRecords([record]) + await manager.refresh() + XCTAssertTrue(manager.pendingRequests.isEmpty) + } + + func testUnreadableAcceptanceStatePreventsActivation() async throws { + let sdk = try PaymentRequestSdkMock(records: [paymentRequestRecord()]) + let store = PaymentRequestPresentationMemoryStore() + store.shouldFailLoad = true + let manager = paymentRequestManager(sdk: sdk, acceptanceStore: store) + + await manager.refresh() + + XCTAssertTrue(manager.pendingRequests.isEmpty) + XCTAssertTrue(manager.requestsForPresentation().isEmpty) + } + func testClearingDuringRetryDoesNotReopenRequest() async throws { let sdk = try PaymentRequestSdkMock(records: [paymentRequestRecord()]) let manager = paymentRequestManager(sdk: sdk) @@ -3663,6 +3780,7 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { let firstManager = PaykitPaymentRequestManager( service: PaykitPaymentRequestService(sdk: sdk, logWarning: { _ in }), presentationStore: store, + acceptanceStore: PaymentRequestPresentationMemoryStore(), subscriptionStateStore: subscriptionStore, logWarning: { _ in } ) @@ -3674,6 +3792,7 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { let restoredManager = PaykitPaymentRequestManager( service: PaykitPaymentRequestService(sdk: sdk, logWarning: { _ in }), presentationStore: store, + acceptanceStore: PaymentRequestPresentationMemoryStore(), subscriptionStateStore: subscriptionStore, logWarning: { _ in } ) @@ -3701,6 +3820,7 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { let firstManager = PaykitPaymentRequestManager( service: PaykitPaymentRequestService(sdk: sdk, logWarning: { _ in }), presentationStore: presentationStore, + acceptanceStore: PaymentRequestPresentationMemoryStore(), subscriptionStateStore: subscriptionStore, logWarning: { _ in } ) @@ -3712,6 +3832,7 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { let restoredManager = PaykitPaymentRequestManager( service: PaykitPaymentRequestService(sdk: sdk, logWarning: { _ in }), presentationStore: presentationStore, + acceptanceStore: PaymentRequestPresentationMemoryStore(), subscriptionStateStore: subscriptionStore, logWarning: { _ in } ) @@ -4433,6 +4554,7 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { let manager = PaykitPaymentRequestManager( service: PaykitPaymentRequestService(sdk: sdk, logWarning: { _ in }), presentationStore: store, + acceptanceStore: PaymentRequestPresentationMemoryStore(), subscriptionStateStore: PaymentRequestSubscriptionStateMemoryStore(), isAvailable: { true }, logWarning: { _ in } @@ -4462,9 +4584,14 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { isPrivatePaymentPublishingEnabled: Bool = true, completedPaymentProofKinds: [PaykitPaymentRequest.ID: PaykitPaymentProofKind] = [:], inFlightPaymentRequestIds: Set = [], - protectedRequestIdsForSubscriptionCancellation: Set = [] + protectedRequestIdsForSubscriptionCancellation: Set = [], + acceptanceStore: PaymentRequestPresentationMemoryStore? = nil, + acceptedRecords: [PaymentRequestRecord] = [] ) -> PaykitPaymentRequestManager { let now: @Sendable () -> Date = { clock.now() } + let acceptanceStore = acceptanceStore ?? PaymentRequestPresentationMemoryStore(ids: Set(acceptedRecords.map { + PaykitPaymentRequest.ID(paymentRequestId: $0.paymentRequestId, counterparty: $0.counterparty) + })) let manager = PaykitPaymentRequestManager( service: PaykitPaymentRequestService( sdk: sdk, @@ -4473,6 +4600,7 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { logWarning: { _ in } ), presentationStore: PaymentRequestPresentationMemoryStore(), + acceptanceStore: acceptanceStore, subscriptionStateStore: subscriptionStateStore, subscriptionNotificationScheduler: subscriptionNotificationScheduler, completedPaymentProofKinds: { _ in completedPaymentProofKinds }, @@ -4591,14 +4719,22 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { } } -private final class PaymentRequestPresentationMemoryStore: PaykitPaymentRequestPresentationStoring { +private final class PaymentRequestPresentationMemoryStore: PaykitPaymentRequestIdStoring { private var states: [String: Set] = [:] + var shouldFailSave = false + var shouldFailLoad = false + + init(ids: Set = []) { + states["pubky\(String(repeating: "z", count: 52))"] = ids + } - func load(identity: String) -> Set { - states[identity] ?? [] + func load(identity: String) throws -> Set { + if shouldFailLoad { throw PaymentRequestSdkMockError.process } + return states[identity] ?? [] } - func save(_ ids: Set, identity: String) { + func save(_ ids: Set, identity: String) throws { + if shouldFailSave { throw PaymentRequestSdkMockError.process } states[identity] = ids } } @@ -4843,6 +4979,9 @@ private actor PaymentRequestSdkMock: PaykitPaymentRequestSdkHandling, PaykitPaym } let record: PaymentRequestRecord + guard records.contains(where: { + $0.counterparty == counterparty && $0.paymentRequestId == paymentRequestId && $0.state == .proposed + }) else { throw PaymentRequestSdkMockError.requestMissing } if let index = records.firstIndex(where: { $0.counterparty == counterparty && $0.paymentRequestId == paymentRequestId && diff --git a/BitkitTests/PaykitReceivedPaymentContactsTests.swift b/BitkitTests/PaykitReceivedPaymentContactsTests.swift new file mode 100644 index 000000000..4b8a49799 --- /dev/null +++ b/BitkitTests/PaykitReceivedPaymentContactsTests.swift @@ -0,0 +1,227 @@ +@testable import Bitkit +import BitkitCore +import Combine +import Foundation +import Paykit +import XCTest + +final class PaykitReceivedPaymentContactsTests: XCTestCase { + private let alice = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" + private let bob = "pubky7don8zi885feihpjsyx7t53srod6z1n4xjiyaaxucpqarm6sh85o" + private let receivingAddress = "bcrt1qfn50lqawrce0evh66qrnlt8j447lwmeyqp5gmd" + private let otherAddress = "bcrt1qpsps9chsjnnd3veems9phzlvw42em682rsj8hh" + + func testReservationOnlyAttributesLiveAndHistory() async throws { + try await assertAttribution( + contacts: PaykitReceivedPaymentContacts(), reservations: [receivingAddress: alice], expected: alice + ) + } + + func testConflictingRequestsAndReservationsRejectLiveAndHistoryAttribution() async throws { + let receivingRequest = try contacts(address: receivingAddress, counterparty: alice) + let otherRequest = try contacts(address: otherAddress, counterparty: bob) + let cases = [ + ("Receiving reservation, request on other output", otherRequest, [receivingAddress: alice]), + ("Reservations on both outputs", PaykitReceivedPaymentContacts(), [receivingAddress: alice, otherAddress: bob]), + ("Receiving request, reservation on other output", receivingRequest, [otherAddress: bob]), + ("Request and reservation on same output", receivingRequest, [receivingAddress: bob]), + ] + for (scenario, contacts, reservations) in cases { + try await assertAttribution(contacts: contacts, reservations: reservations, expected: nil, message: scenario) + } + } + + func testSameContactAcrossRequestAndReservationsIsUnambiguous() async throws { + try await assertAttribution( + contacts: contacts(address: otherAddress, counterparty: alice), + reservations: [receivingAddress: String(alice.dropFirst(5)), otherAddress: alice], expected: alice + ) + } + + func testOtherOutputNeverSuppliesPositiveAttribution() async throws { + try await assertAttribution( + contacts: contacts(address: otherAddress, counterparty: alice), + reservations: [otherAddress: alice], expected: nil + ) + } + + func testReceivingAddressMustBePresentInOutputs() async throws { + let contacts = try contacts(address: receivingAddress, counterparty: alice) + let combined = await contacts.includingReservations(for: [otherAddress]) { _ in self.alice } + XCTAssertNil(combined.contact(receivingAddress: receivingAddress, outputAddresses: [otherAddress])) + XCTAssertNil(combined.attributing(payment(), outputAddresses: [otherAddress])) + } + + func testAmbiguousRequestIsNotHiddenByMatchingReservation() async throws { + let contacts = try PaykitReceivedPaymentContacts( + records: [record(address: receivingAddress, counterparty: alice), record(address: receivingAddress, counterparty: bob)], + network: .regtest + ) + try await assertAttribution(contacts: contacts, reservations: [receivingAddress: alice], expected: nil) + } + + func testAttributionPreservesExistingContactAndRejectsSentPayments() async { + let combined = await PaykitReceivedPaymentContacts().includingReservations(for: [receivingAddress]) { _ in self.alice } + XCTAssertNil(combined.attributing(payment(contact: bob), outputAddresses: [receivingAddress])) + XCTAssertNil(combined.attributing(payment(txType: .sent), outputAddresses: [receivingAddress])) + } + + func testCompletedScanSkipsAllWorkWhenInputsAreUnchanged() async { + let changes = PassthroughSubject() + let cache = PaykitReceivedPaymentBackfillCache(activityChanges: changes.eraseToAnyPublisher()) + let contacts = PaykitReceivedPaymentContacts() + var scans = 0 + for _ in 0 ..< 2 { + await cache.scanIfNeeded(identity: alice, contacts: contacts, reservationRevision: 0) { + scans += 1 + return true + } + } + XCTAssertEqual(scans, 1, "Skip the entire scan, including transaction-detail and reservation lookups") + } + + func testActivityChangesInvalidateCompletedAndInFlightScans() async { + let changes = PassthroughSubject() + let cache = PaykitReceivedPaymentBackfillCache(activityChanges: changes.eraseToAnyPublisher()) + let contacts = PaykitReceivedPaymentContacts() + var scans = 0 + await cache.scanIfNeeded(identity: alice, contacts: contacts, reservationRevision: 0) { + scans += 1 + return true + } + changes.send() + await cache.scanIfNeeded(identity: alice, contacts: contacts, reservationRevision: 0) { + scans += 1 + changes.send() + return true + } + await cache.scanIfNeeded(identity: alice, contacts: contacts, reservationRevision: 0) { + scans += 1 + return true + } + XCTAssertEqual(scans, 3) + } + + func testContactIdentityAndReservationChangesRescan() async throws { + let changes = PassthroughSubject() + let cache = PaykitReceivedPaymentBackfillCache(activityChanges: changes.eraseToAnyPublisher()) + let empty = PaykitReceivedPaymentContacts() + let contacts = try contacts(address: receivingAddress, counterparty: alice) + var scans = 0 + let scan = { + scans += 1 + return true + } + await cache.scanIfNeeded(identity: alice, contacts: empty, reservationRevision: 0, scan: scan) + await cache.scanIfNeeded(identity: alice, contacts: contacts, reservationRevision: 0, scan: scan) + await cache.scanIfNeeded(identity: alice, contacts: contacts, reservationRevision: 1, scan: scan) + await cache.scanIfNeeded(identity: bob, contacts: contacts, reservationRevision: 1, scan: scan) + cache.invalidate() + await cache.scanIfNeeded(identity: bob, contacts: contacts, reservationRevision: 1, scan: scan) + XCTAssertEqual(scans, 5) + } + + func testIncompleteScanRetriesWithoutInputChanges() async { + let changes = PassthroughSubject() + let cache = PaykitReceivedPaymentBackfillCache(activityChanges: changes.eraseToAnyPublisher()) + let contacts = PaykitReceivedPaymentContacts() + var scans = 0 + for detailsAvailable in [false, true, true] { + await cache.scanIfNeeded(identity: alice, contacts: contacts, reservationRevision: 0) { + scans += 1 + return detailsAvailable + } + } + XCTAssertEqual(scans, 2, "Missing transaction details must be retried before caching completion") + } + + func testUnavailableReservationsCannotCacheNegativeMatch() async { + let changes = PassthroughSubject() + let cache = PaykitReceivedPaymentBackfillCache(activityChanges: changes.eraseToAnyPublisher()) + let contacts = PaykitReceivedPaymentContacts() + do { + try await cache.scanIfNeeded(identity: alice, contacts: contacts, reservationRevision: 0) { + _ = try await contacts.includingReservations(for: [self.receivingAddress]) { _ in + throw NSError(domain: "reservation-derivation", code: 1) + } + return true + } + XCTFail("Expected reservation lookup to fail") + } catch {} + var retried = false + await cache.scanIfNeeded(identity: alice, contacts: contacts, reservationRevision: 0) { + retried = true + let combined = await contacts.includingReservations(for: [self.receivingAddress]) { _ in self.alice } + XCTAssertEqual(combined.contact(receivingAddress: self.receivingAddress, outputAddresses: [self.receivingAddress]), self.alice) + return true + } + XCTAssertTrue(retried) + } + + func testReservationRevisionChangesOnRestoreAndClear() async throws { + let suiteName = "PaykitReceivedPaymentContactsTests.\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) + defer { defaults.removePersistentDomain(forName: suiteName) } + let store = PrivatePaykitAddressReservationStore(defaults: defaults) + let initial = await store.attributionRevision + await store.restoreBackup(nil) + let restored = await store.attributionRevision + await store.clear() + let cleared = await store.attributionRevision + XCTAssertGreaterThan(restored, initial) + XCTAssertGreaterThan(cleared, restored) + } + + private func assertAttribution( + contacts: PaykitReceivedPaymentContacts, + reservations: [String: String], + expected: String?, + message: String = "", + file: StaticString = #filePath, + line: UInt = #line + ) async throws { + let outputs = [otherAddress, receivingAddress, receivingAddress] + let combined = await contacts.includingReservations(for: outputs) { reservations[$0] } + XCTAssertEqual(combined.contact(receivingAddress: receivingAddress, outputAddresses: outputs), expected, message, file: file, line: line) + let attributed = combined.attributing(payment(), outputAddresses: outputs) + if let expected { + guard case let .onchain(updated) = attributed else { return XCTFail("Expected attributed history", file: file, line: line) } + XCTAssertEqual(updated.contact, expected, file: file, line: line) + XCTAssertEqual(updated.value, 15000, file: file, line: line) + XCTAssertEqual(updated.seenAt, 125, file: file, line: line) + } else { + XCTAssertNil(attributed, message, file: file, line: line) + } + } + + private func payment(id: String = "received", contact: String? = nil, txType: PaymentType = .received) -> Activity { + .onchain(OnchainActivity( + walletId: WalletScope.default, id: id, txType: txType, txId: "tx-\(id)", value: 15000, + fee: 0, feeRate: 0, address: receivingAddress, confirmed: true, timestamp: 123, + isBoosted: false, boostTxIds: [], isTransfer: false, doesExist: true, confirmTimestamp: 124, + channelId: nil, transferTxId: nil, contact: contact, createdAt: 123, updatedAt: 124, seenAt: 125 + )) + } + + private func contacts(address: String, counterparty: String) throws -> PaykitReceivedPaymentContacts { + try PaykitReceivedPaymentContacts(records: [record(address: address, counterparty: counterparty)], network: .regtest) + } + + private func record(address: String, counterparty: String) throws -> PaymentRequestRecord { + try PaymentRequestRecord( + counterparty: counterparty, paymentRequestId: "550e8400-e29b-41d4-a716-446655440000", localRole: .payee, + state: .proposed, proposalStreamItemId: 1, proposalOutboundMessageId: nil, proposalOutboundStatus: nil, + proposalEventId: "650e8400-e29b-41d4-a716-446655440000", proposalAppId: "paykit-server", payerAppId: nil, + executionClaimAppId: nil, + terms: PaymentRequestTerms( + amount: PaymentRequestAmount(value: "0.001", asset: "btc"), paymentReference: PaymentReference(text: "invoice-123"), + proposalExpiresAt: nil, recurrence: nil, acceptedPaymentEndpointIdentifiers: ["btc-regtest-p2wpkh"], + paymentEndpoints: ["btc-regtest-p2wpkh": PublicPaykitService.serializePayload(value: address)], + requiredAppId: nil, conversion: nil, paymentDeadline: nil, metadata: PrivateJsonObject(text: "{}") + ), + acceptedEventId: nil, acceptedOutboundStatus: nil, rejectedEventId: nil, rejectedOutboundStatus: nil, + canceledEventId: nil, canceledOutboundStatus: nil, conversionQuotes: [], paymentProofs: [], + lastStreamItemId: 1, lastOutboundMessageId: nil, lastOutboundStatus: nil, lastEventAt: "2027-01-15T08:00:00Z", invalidReason: nil + ) + } +} diff --git a/BitkitTests/PrivatePaykitServiceTests.swift b/BitkitTests/PrivatePaykitServiceTests.swift index 89d1cebf3..999436f5d 100644 --- a/BitkitTests/PrivatePaykitServiceTests.swift +++ b/BitkitTests/PrivatePaykitServiceTests.swift @@ -267,6 +267,37 @@ final class PrivatePaykitServiceTests: XCTestCase { XCTAssertEqual(decoded.paykitSdkBackupState, backup.paykitSdkBackupState) } + func testWalletRestoreRetainsRecoveryStateAndConsumedPaymentLists() async throws { + let keys: [KeychainEntryType] = [.paykitRecoveryBackup, .paykitSession, .pubkySecretKey] + let savedValues = try keys.map { try Keychain.load(key: $0) } + addTeardownBlock { + for (key, value) in zip(keys, savedValues) { + if let value { + try Keychain.upsert(key: key, data: value) + } else { + try Keychain.delete(key: key) + } + } + } + try Keychain.delete(key: .paykitSession) + try Keychain.delete(key: .pubkySecretKey) + let publicKey = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" + let backup = PrivatePaykitService.Backup( + sdkState: "opaque recovery state", + consumedPrivatePaymentListVersions: [publicKey: 7] + ) + let encoded = try String(decoding: JSONEncoder().encode(backup), as: UTF8.self) + let service = PrivatePaykitService() + + try await service.restoreBackup(encoded) + + XCTAssertEqual(try Keychain.loadString(key: .paykitRecoveryBackup), backup.sdkState) + let state = await service.testContactState(publicKey: publicKey) + XCTAssertEqual(state?.consumedPrivatePaymentListVersion, 7) + XCTAssertNil(try Keychain.load(key: .paykitSession)) + XCTAssertNil(try Keychain.load(key: .pubkySecretKey)) + } + func testReservationStoreBacksUpRestoredCeiling() async throws { let suiteName = "PrivatePaykitServiceTests.\(UUID().uuidString)" let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) diff --git a/journeys/pubky-marketplace/README.md b/journeys/pubky-marketplace/README.md index 29243e51b..d22255ff3 100644 --- a/journeys/pubky-marketplace/README.md +++ b/journeys/pubky-marketplace/README.md @@ -90,4 +90,4 @@ artifacts at each boundary: `SendSuccess` proves backend acceptance, not confirmation. The integration fixture's chain, signed Paykit state, and marketplace state are the confirmation authority. Both platforms open fresh requests in payment review automatically, dismiss that review, and open `PaymentRequestsSheet` from -`PaymentRequestsBell`; iOS uses the review's in-sheet back control instead of Android system back. +`PaymentRequestsBell`; iOS uses a downward swipe from the sheet drag indicator instead of Android system back. diff --git a/journeys/pubky-marketplace/wallet-leg.xml b/journeys/pubky-marketplace/wallet-leg.xml index 1be4983cf..326cddff5 100644 --- a/journeys/pubky-marketplace/wallet-leg.xml +++ b/journeys/pubky-marketplace/wallet-leg.xml @@ -2,7 +2,7 @@ Verifies a Bitkit seller grants a watch-only account claim and a separate linked Bitkit buyer receives, approves, pays, and confirms the resulting marketplace Payment Request on regtest. - iOS dismisses the automatic payment review with its in-sheet back control instead of Android's + iOS dismisses the automatic payment review by swiping down the sheet instead of Android's system back before opening the requests sheet from the header bell. Precondition: two clean simulator wallets and the integration fixture described in this suite's README. Publish fixture Fulcrum at tcp://127.0.0.1:60001 and build both wallets with the local @@ -16,7 +16,7 @@ Verify the watch-only consent screen (id "PubkyAuthWatchOnlyConsent") is visible Capture the watch-only consent evidence, then tap Approve (id "PubkyAuthWatchOnlyApprove") Verify the authorization screen shows exactly /pub/paykit with READ, WRITE access - Verify Paykit access (id "PubkyAuthPaykitAccess") explains the shared private messages and payment history + Verify Paykit access (id "PubkyAuthPaykitAccess") describes private Paykit data and messages without sharing identity or spending keys Tap Authorize (id "PubkyAuthAuthorize") Verify authorization succeeds (id "PubkyAuthOK") Tap OK (id "PubkyAuthOK") @@ -33,7 +33,7 @@ Have the fixture create one marketplace purchase for the buyer and record its Payment Request id Verify the request uses lowercase "btc", a regtest endpoint identifier, and a JSON endpoint value Verify the fresh request opens payment review automatically (id "ReviewAmount") - Tap back to dismiss the automatic review, verify the requests bell (id "PaymentRequestsBell") is visible, then tap it + Swipe down from the sheet drag indicator to dismiss the automatic review, verify the requests bell (id "PaymentRequestsBell") is visible, then tap it Verify the incoming requests sheet (id "PaymentRequestsSheet") is visible Using the seller public key, verify the request row (id "PaymentRequestRow-<payment-request-id>-one-time") shows the seller and expected amount, plus the fixture note when present Capture the incoming-request evidence, then tap Pay (id "PaymentRequestPay-<payment-request-id>") From 0982385494ff432f3a096af5e7f1388e7c04c4fb Mon Sep 17 00:00:00 2001 From: benk10 Date: Thu, 1 Oct 2026 06:56:07 -0500 Subject: [PATCH 07/68] test: cover one-time request ownership across installs --- changelog.d/next/856.changed.md | 2 +- journeys/payment-requests/README.md | 14 ++++++++++ .../accepted-device-ownership.xml | 27 +++++++++++++++++++ 3 files changed, 42 insertions(+), 1 deletion(-) create mode 100644 journeys/payment-requests/accepted-device-ownership.xml diff --git a/changelog.d/next/856.changed.md b/changelog.d/next/856.changed.md index 9f5ab6ca6..c6c333754 100644 --- a/changelog.d/next/856.changed.md +++ b/changelog.d/next/856.changed.md @@ -1 +1 @@ -Share Paykit contacts and payment state with authorized apps, identify received payments from shared requests by contact, and keep wallet keys private. +Share Paykit contacts and payment state with authorized apps while keeping wallet keys private, keep accepted one-time requests payable only on the accepting install, and label received payments with their Paykit payer contact. diff --git a/journeys/payment-requests/README.md b/journeys/payment-requests/README.md index 9f4d28ca6..bc5afc32b 100644 --- a/journeys/payment-requests/README.md +++ b/journeys/payment-requests/README.md @@ -29,6 +29,20 @@ The source wallet-leg run completed this path on regtest on 2026-08-22: Bitkit p That run established the issuer shapes captured by the fixture: lowercase `btc`, `btc-regtest-p2wpkh`, and a JSON object endpoint payload with a non-empty string `value`. The exact Debug binary SHA was not recorded, so the canonical fixture tests lock the same production gates on the current code. +## Accepting install + +`accepted-device-ownership.xml` uses two separate E2E Bitkit installs with Paykit UI enabled, +sharing one Pubky identity and App ID `bitkit`. Fund each regtest Lightning wallet for 21,000 sats +plus fees, and save and link the fixture issuer. Do not copy app-private storage between installs. + +Have the issuer publish a `btc-lightning-lnurl` endpoint backed by the local `bitkit-docker` +`lnurl-server`, reachable from both installs. Keep its metadata endpoint healthy and allow 21,000 +sats, but make its invoice callback fail before Lightning dispatch. Restore the callback's healthy +response for retry without changing the endpoint or republishing the Paykit payment list. +Acceptance completes before invoice fetching: confirm the shared request is accepted after A's +callback failure before testing B. After restart, only the accepting install A may retry; B keeps +the request in history without a Pay action. The iOS controls use `GRAB`, `SendFailure`, and `SendSuccess`. + ## Resolution-failure contract - Parse-time rejection emits a warning with `category=parse`, a stable reason code, and only the diff --git a/journeys/payment-requests/accepted-device-ownership.xml b/journeys/payment-requests/accepted-device-ownership.xml new file mode 100644 index 000000000..0779163d7 --- /dev/null +++ b/journeys/payment-requests/accepted-device-ownership.xml @@ -0,0 +1,27 @@ + + + Requires two separate E2E Bitkit installs A and B with Paykit UI enabled, authenticated as the + same Pubky identity and using App ID bitkit. Fund both regtest Lightning wallets for 21,000 sats + plus fees, and save and link the fixture issuer. The issuer publishes a btc-lightning-lnurl + endpoint backed by the local bitkit-docker lnurl-server, reachable from both installs. Keep its + LNURL-pay metadata endpoint healthy and allow 21,000 sats; configure the invoice callback to + fail before Lightning dispatch, then restore its healthy response without changing the endpoint + or publishing a new Paykit payment list. Acceptance completes before the invoice callback; + confirm the shared request state is accepted after A's callback failure before testing B. + This covers one-time requests only. Do not copy app-private storage between installs. + + + Configure the fixture LNURL-pay invoice callback to fail before Lightning dispatch + Have the issuer send a proposed one-time Payment Request for 21,000 sats and record its request id + Open the request payment review on both installs before either install swipes to send + On install A swipe the send control (id "GRAB") and verify the failure screen (id "SendFailure") + Using the fixture's shared-runtime request inspection, verify the recorded request is ACCEPTED with no payment proof and no wallet dispatch; stop if acceptance has not committed + Configure the same LNURL-pay invoice callback to succeed without publishing a new Paykit payment list + On install B swipe the stale review's send control and verify that no wallet payment is dispatched + Restart install B and wait for Paykit synchronization; verify the request is not automatically presented or offered as payable + Open Payment Requests on install B and verify the recorded request remains in history without a Pay action + Restart install A and wait for Paykit synchronization + Open Payment Requests on install A and pay the recorded request + Verify the payment success screen (id "SendSuccess") and exactly one wallet payment to the issuer + + From c11297610359d539a73442c560309c8e9ff113fb Mon Sep 17 00:00:00 2001 From: benk10 Date: Thu, 1 Oct 2026 08:03:22 -0500 Subject: [PATCH 08/68] fix: prune paykit acceptance records and test contact backfill --- .../PaykitPaymentRequestService.swift | 21 +++++++ .../PaykitReceivedPaymentContacts.swift | 2 +- .../PaykitPaymentRequestServiceTests.swift | 48 +++++++++++++++ .../PaykitReceivedPaymentContactsTests.swift | 59 +++++++++++++++---- 4 files changed, 118 insertions(+), 12 deletions(-) diff --git a/Bitkit/Services/PaykitPaymentRequestService.swift b/Bitkit/Services/PaykitPaymentRequestService.swift index 56e220357..796fc9a4c 100644 --- a/Bitkit/Services/PaykitPaymentRequestService.swift +++ b/Bitkit/Services/PaykitPaymentRequestService.swift @@ -1984,6 +1984,7 @@ final class PaykitPaymentRequestManager { guard generation == refreshGeneration, PubkyPublicKeyFormat.matches(self.activeIdentity, activeIdentity) else { return } + pruneAcceptedRequestIds(snapshot.history, identity: activeIdentity) let refreshDate = now() let handledRequestedExpirationId = recordRequestedPresentationExpiration(at: refreshDate) let previousPending = pendingRequests @@ -2196,6 +2197,26 @@ final class PaykitPaymentRequestManager { refreshTask = nil } + private func pruneAcceptedRequestIds(_ requests: [PaykitPaymentRequest], identity: String) { + let finishedIds = Set(requests.compactMap { request -> PaykitPaymentRequest.ID? in + guard request.direction == .incoming, request.billingPeriod == nil, + acceptedRequestIds.contains(request.id) + else { return nil } + switch request.lifecycleState { + case .proofSubmitted, .canceled, .rejected: return request.id + default: return nil + } + }) + guard !finishedIds.isEmpty else { return } + do { + let remaining = try acceptanceStore.load(identity: identity).subtracting(finishedIds) + try acceptanceStore.save(remaining, identity: identity) + acceptedRequestIds = remaining + } catch { + logWarning("Failed to prune accepted Paykit payment requests: \(error)") + } + } + private func discardExpiredRequests( at: Date? = nil, handledRequestedExpirationId: PaykitPaymentRequest.ID? = nil diff --git a/Bitkit/Services/PaykitReceivedPaymentContacts.swift b/Bitkit/Services/PaykitReceivedPaymentContacts.swift index a837844c8..236ecd3ca 100644 --- a/Bitkit/Services/PaykitReceivedPaymentContacts.swift +++ b/Bitkit/Services/PaykitReceivedPaymentContacts.swift @@ -155,9 +155,9 @@ extension ActivityService { _ contacts: PaykitReceivedPaymentContacts, identity: String, cache: PaykitReceivedPaymentBackfillCache, + reservations: PrivatePaykitAddressReservationStore = .shared, isCurrent: @Sendable () async -> Bool ) async throws { - let reservations = PrivatePaykitAddressReservationStore.shared let reservationRevision = await reservations.attributionRevision try await cache.scanIfNeeded(identity: identity, contacts: contacts, reservationRevision: reservationRevision) { let activities = try await ServiceQueue.background(.core) { diff --git a/BitkitTests/PaykitPaymentRequestServiceTests.swift b/BitkitTests/PaykitPaymentRequestServiceTests.swift index b576fa268..6f8cd46bd 100644 --- a/BitkitTests/PaykitPaymentRequestServiceTests.swift +++ b/BitkitTests/PaykitPaymentRequestServiceTests.swift @@ -3498,6 +3498,54 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { XCTAssertEqual(acceptedCalls.count, 1) } + func testAcceptanceCleanupOnlyRemovesConfirmedFinishedRequests() async throws { + let identity = "pubky\(String(repeating: "z", count: 52))" + let records = try [ + paymentRequestRecord(id: "paid", state: .proofSubmitted), + paymentRequestRecord(id: "canceled", state: .canceled), + paymentRequestRecord(id: "rejected", state: .rejected), + paymentRequestRecord(id: "retry", state: .accepted, expiresAt: "2020-01-01T00:00:00Z"), + paymentRequestRecord(id: "recovery", state: .recoveryRequired), + paymentRequestRecord(id: "conflict", state: .invalidConflict), + ] + let missingId = PaykitPaymentRequest.ID(paymentRequestId: "missing", counterparty: "pubkypayee") + let ids = Set(records.map { PaykitPaymentRequest.ID(paymentRequestId: $0.paymentRequestId, counterparty: $0.counterparty) }) + .union([missingId]) + let store = PaymentRequestPresentationMemoryStore(ids: ids) + let sdk = PaymentRequestSdkMock(records: []) + let manager = paymentRequestManager(sdk: sdk, acceptanceStore: store) + + await manager.refresh() + XCTAssertEqual(try store.load(identity: identity), ids, "An empty refresh must not remove execution ownership") + await sdk.setRecords(records) + await manager.refresh() + + let remaining = try store.load(identity: identity) + XCTAssertEqual(Set(remaining.map(\.paymentRequestId)), ["retry", "recovery", "conflict", "missing"]) + let restarted = paymentRequestManager(sdk: sdk, acceptanceStore: store) + await restarted.refresh() + let retry = try XCTUnwrap(restarted.pendingRequests.first) + XCTAssertEqual(retry.paymentRequestId, "retry") + try await restarted.prepareForPayment(retry) + try await restarted.ensurePaymentAllowed(retry) + } + + func testFailedAcceptanceCleanupRetainsIdsAndRetries() async throws { + let identity = "pubky\(String(repeating: "z", count: 52))" + let record = try paymentRequestRecord(state: .proofSubmitted) + let id = PaykitPaymentRequest.ID(paymentRequestId: record.paymentRequestId, counterparty: record.counterparty) + let store = PaymentRequestPresentationMemoryStore(ids: [id]) + store.shouldFailSave = true + let manager = paymentRequestManager(sdk: PaymentRequestSdkMock(records: [record]), acceptanceStore: store) + + await manager.refresh() + XCTAssertEqual(try store.load(identity: identity), [id]) + XCTAssertTrue(manager.pendingRequests.isEmpty) + store.shouldFailSave = false + await manager.refresh() + XCTAssertTrue(try store.load(identity: identity).isEmpty) + } + func testIdentitySwitchPreventsExecutionOfPreparedProposal() async throws { for switchDuringAuthorization in [false, true] { let sdk = try PaymentRequestSdkMock(records: [paymentRequestRecord()]) diff --git a/BitkitTests/PaykitReceivedPaymentContactsTests.swift b/BitkitTests/PaykitReceivedPaymentContactsTests.swift index 4b8a49799..6dc2bc973 100644 --- a/BitkitTests/PaykitReceivedPaymentContactsTests.swift +++ b/BitkitTests/PaykitReceivedPaymentContactsTests.swift @@ -121,18 +121,55 @@ final class PaykitReceivedPaymentContactsTests: XCTestCase { XCTAssertEqual(scans, 5) } - func testIncompleteScanRetriesWithoutInputChanges() async { - let changes = PassthroughSubject() - let cache = PaykitReceivedPaymentBackfillCache(activityChanges: changes.eraseToAnyPublisher()) - let contacts = PaykitReceivedPaymentContacts() - var scans = 0 - for detailsAvailable in [false, true, true] { - await cache.scanIfNeeded(identity: alice, contacts: contacts, reservationRevision: 0) { - scans += 1 - return detailsAvailable - } + func testBackfillRetriesMissingTransactionDetailsPersistsContactAndSkipsCompletedScan() async throws { + let service = Bitkit.CoreService.shared.activity + let testDbPath = FileManager.default.temporaryDirectory + .appendingPathComponent("PaykitReceivedPaymentContactsTests-\(UUID().uuidString)", isDirectory: true).path + try FileManager.default.createDirectory(atPath: testDbPath, withIntermediateDirectories: true) + addTeardownBlock { + await self.drainCoreServiceQueue() + await self.repointCoreToAppStorage() + try? FileManager.default.removeItem(atPath: testDbPath) + } + await drainCoreServiceQueue() + _ = try await Bitkit.ServiceQueue.background(.core) { try initDb(basePath: testDbPath) } + + let reservations = try PrivatePaykitAddressReservationStore(defaults: makeIsolatedDefaults()) + let contacts = try contacts(address: receivingAddress, counterparty: alice) + let activity = payment() + try await service.insert(activity) + let inserted = try await service.getActivity(id: activity.activityId) + let baseline = try XCTUnwrap(inserted) + guard case let .onchain(baselinePayment) = baseline else { return XCTFail("Expected the inserted received payment") } + let cache = PaykitReceivedPaymentBackfillCache(activityChanges: service.activitiesChangedPublisher) + let missingDetails = try await service.getTransactionDetails(txid: "tx-received") + XCTAssertNil(missingDetails) + + try await service.backfillReceivedPaykitContacts(contacts, identity: alice, cache: cache, reservations: reservations) { true } + let unattributed = try await service.getActivity(id: activity.activityId) + XCTAssertEqual(unattributed, baseline, "Missing details must leave the received payment unchanged") + + let details = BitkitCore.TransactionDetails( + walletId: WalletScope.default, txId: "tx-received", amountSats: 15000, inputs: [], + outputs: [TxOutput(scriptpubkey: "", scriptpubkeyType: "v0_p2wpkh", scriptpubkeyAddress: receivingAddress, value: 15000, n: 0)] + ) + // No activity notification: incomplete scans must retry even when all cache inputs stay unchanged. + try await Bitkit.ServiceQueue.background(.core) { try upsertTransactionDetails(detailsList: [details]) } + try await service.backfillReceivedPaykitContacts(contacts, identity: alice, cache: cache, reservations: reservations) { true } + let persisted = try await service.getActivity(id: activity.activityId) + guard case let .onchain(payment) = persisted else { return XCTFail("Expected the stored received payment") } + XCTAssertEqual(payment.contact, alice, "Retry must persist the attributed contact in BitkitCore") + XCTAssertEqual(payment.value, baselinePayment.value) + XCTAssertEqual(payment.seenAt, baselinePayment.seenAt) + + // The write publishes an activity change; complete one scan at that new revision before checking the skip. + try await service.backfillReceivedPaykitContacts(contacts, identity: alice, cache: cache, reservations: reservations) { true } + try await service.backfillReceivedPaykitContacts(contacts, identity: alice, cache: cache, reservations: reservations) { + XCTFail("An unchanged completed backfill must skip the scan") + return false } - XCTAssertEqual(scans, 2, "Missing transaction details must be retried before caching completion") + let afterSkippedScan = try await service.getActivity(id: activity.activityId) + XCTAssertEqual(afterSkippedScan, persisted) } func testUnavailableReservationsCannotCacheNegativeMatch() async { From 0231e812570a3c19929d231652b3dc3036bb8f83 Mon Sep 17 00:00:00 2001 From: benk10 Date: Thu, 1 Oct 2026 08:52:00 -0500 Subject: [PATCH 09/68] test: cover recovery of locally accepted paykit requests --- .../PaykitPaymentRequestServiceTests.swift | 34 +++++++++++++++---- 1 file changed, 28 insertions(+), 6 deletions(-) diff --git a/BitkitTests/PaykitPaymentRequestServiceTests.swift b/BitkitTests/PaykitPaymentRequestServiceTests.swift index 6f8cd46bd..ca1f3396f 100644 --- a/BitkitTests/PaykitPaymentRequestServiceTests.swift +++ b/BitkitTests/PaykitPaymentRequestServiceTests.swift @@ -2445,12 +2445,19 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { func testPendingPrivateLinkRecoverySurvivesRecoveryRefreshAndManagerRecreation() async throws { let identity = "pubky\(String(repeating: "y", count: 52))" - let sdk = try PaymentRequestSdkMock(records: [paymentRequestRecord()]) + let record = try paymentRequestRecord(state: .accepted, acceptedEventId: "accepted") + let sdk = PaymentRequestSdkMock(records: [record]) let presentationStore = PaymentRequestPresentationMemoryStore() + let acceptanceStore = PaymentRequestPresentationMemoryStore() + try acceptanceStore.save( + [PaykitPaymentRequest.ID(paymentRequestId: record.paymentRequestId, counterparty: record.counterparty)], + identity: identity + ) let subscriptionStore = PaymentRequestSubscriptionStateMemoryStore() let manager = PaykitPaymentRequestManager( service: PaykitPaymentRequestService(sdk: sdk, logWarning: { _ in }), presentationStore: presentationStore, + acceptanceStore: acceptanceStore, subscriptionStateStore: subscriptionStore, logWarning: { _ in } ) @@ -2472,6 +2479,7 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { let restoredManager = PaykitPaymentRequestManager( service: PaykitPaymentRequestService(sdk: sdk, logWarning: { _ in }), presentationStore: presentationStore, + acceptanceStore: acceptanceStore, subscriptionStateStore: subscriptionStore, logWarning: { _ in } ) @@ -2488,18 +2496,23 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { func testRecoveryRequiredIncomingRequestHonorsLocalPaymentProtection() async throws { let record = try paymentRequestRecord(state: .recoveryRequired, acceptedEventId: "accepted") let request = try XCTUnwrap(PaykitPaymentRequest(record: record, now: Date())) + let unpaidManager = paymentRequestManager(sdk: PaymentRequestSdkMock(records: [record]), acceptedRecords: [record]) let completedManager = paymentRequestManager( sdk: PaymentRequestSdkMock(records: [record]), - completedPaymentProofKinds: [request.id: .lightning] + completedPaymentProofKinds: [request.id: .lightning], + acceptedRecords: [record] ) let inFlightManager = paymentRequestManager( sdk: PaymentRequestSdkMock(records: [record]), - inFlightPaymentRequestIds: [request.id] + inFlightPaymentRequestIds: [request.id], + acceptedRecords: [record] ) + await unpaidManager.refresh() await completedManager.refresh() await inFlightManager.refresh() + XCTAssertEqual(unpaidManager.pendingRequests, [request]) XCTAssertTrue(completedManager.pendingRequests.isEmpty) XCTAssertTrue(inFlightManager.pendingRequests.isEmpty) } @@ -3226,7 +3239,12 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { } catch PrivatePaykitError.paymentListAlreadyConsumed {} let restartedProofService = PaykitPaymentProofService(sdk: sdk, store: proofStore, logInfo: { _ in }, logWarning: { _ in }) do { - try await restartedProofService.prepare(request: request, paymentAppId: "bitkit", paymentEndpointIdentifier: endpoint, kind: .onchain) + try await restartedProofService.prepare( + request: request, + paymentAppId: "bitkit", + paymentEndpointIdentifier: endpoint, + kind: .onchain + ) XCTFail("An uncertain started proof must prevent a new preparation") } catch PaykitPaymentRequestError.operationInProgress {} } else { @@ -3240,7 +3258,12 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { XCTAssertEqual(retriedContext.privatePaymentContext, context.privatePaymentContext) XCTAssertEqual(retriedContext.isInitialSubscriptionPayment, context.isInitialSubscriptionPayment) XCTAssertEqual(retriedRequest.lifecycleState, .accepted) - try await proofService.prepare(request: retriedRequest, paymentAppId: "bitkit", paymentEndpointIdentifier: endpoint, kind: .onchain) + try await proofService.prepare( + request: retriedRequest, + paymentAppId: "bitkit", + paymentEndpointIdentifier: endpoint, + kind: .onchain + ) try await manager.prepareForPayment(retriedRequest) { try await privateService.consumePrivatePaymentList( publicKey: counterparty, context: privateContext, attemptId: retriedContext.id @@ -4915,7 +4938,6 @@ private actor PaymentRequestSdkMock: PaykitPaymentRequestSdkHandling, PaykitPaym func submitPaymentProof( counterparty _: String, - counterpartyReceiverPath _: String, paymentRequestId _: String, proof _: PaymentProofSubmission ) throws -> PaymentRequestRecord { From b34d564d5ea381e46a018c93b8da3ca203d1b455 Mon Sep 17 00:00:00 2001 From: benk10 Date: Thu, 1 Oct 2026 09:45:09 -0500 Subject: [PATCH 10/68] fix: preserve paykit payment recovery and contact choices --- Bitkit/Managers/PubkyProfileManager.swift | 16 +++++-- Bitkit/Models/PaykitPaymentStateBackup.swift | 1 + Bitkit/Services/BackupService.swift | 6 ++- Bitkit/Services/CoreService.swift | 44 +++++++++++++++-- .../Services/PaykitPaymentProofService.swift | 10 ++++ .../PaykitPaymentRequestService.swift | 43 ++++++++++++++++- .../PaykitReceivedPaymentContacts.swift | 5 +- .../PrivatePaykitService+Backup.swift | 2 +- .../PrivatePaykitService+Contacts.swift | 4 ++ Bitkit/Services/PubkyService.swift | 19 ++------ .../PaykitPaymentProofServiceTests.swift | 19 ++++++++ .../PaykitPaymentRequestServiceTests.swift | 33 +++++++++++++ .../PaykitPaymentStateBackupTests.swift | 12 ++++- .../PaykitReceivedPaymentContactsTests.swift | 9 ++++ BitkitTests/PaykitSdkClientConfigTests.swift | 13 ++++- BitkitTests/PrivatePaykitServiceTests.swift | 47 ++++++++++--------- journeys/payment-requests/README.md | 5 ++ journeys/pubky-marketplace/wallet-leg.xml | 3 ++ 18 files changed, 238 insertions(+), 53 deletions(-) diff --git a/Bitkit/Managers/PubkyProfileManager.swift b/Bitkit/Managers/PubkyProfileManager.swift index 9540e9f31..9f94aee2c 100644 --- a/Bitkit/Managers/PubkyProfileManager.swift +++ b/Bitkit/Managers/PubkyProfileManager.swift @@ -825,10 +825,8 @@ class PubkyProfileManager: ObservableObject { await PrivatePaykitService.shared.closeAndClear() await PrivatePaykitAddressReservationStore.shared.clearContactAssignments() await PubkyImageCache.shared.clear() - UserDefaults.standard.removeObject(forKey: cachedNameKey) - UserDefaults.standard.removeObject(forKey: cachedImageUriKey) + clearCachedIdentityMetadata() UserDefaults.standard.removeObject(forKey: profileSetupPendingKey) - ContactsManager.restoreContactProfileOverrides(nil) clearPublicPaykitSharingState() notifyAppStateBackupChanged() } @@ -971,6 +969,7 @@ class PubkyProfileManager: ObservableObject { // MARK: - Cached Profile Metadata private static let cachedNameKey = "pubky_profile_name" + private static let cachedIdentityKey = "pubky_profile_identity" private static let cachedImageUriKey = "pubky_profile_image_uri" private static let profileSetupPendingKey = "pubky_profile_setup_pending" @@ -987,9 +986,19 @@ class PubkyProfileManager: ObservableObject { cachedImageUri = profile.imageUrl UserDefaults.standard.set(profile.name, forKey: Self.cachedNameKey) UserDefaults.standard.set(profile.imageUrl, forKey: Self.cachedImageUriKey) + UserDefaults.standard.set(publicKey, forKey: Self.cachedIdentityKey) + } + + static func activateCachedIdentity(publicKey: String, previousPublicKey: String?) { + let owner = UserDefaults.standard.string(forKey: cachedIdentityKey) ?? previousPublicKey + if let owner, !PubkyPublicKeyFormat.matches(owner, publicKey) { + clearCachedIdentityMetadata() + } + UserDefaults.standard.set(publicKey, forKey: cachedIdentityKey) } static func clearCachedIdentityMetadata() { + UserDefaults.standard.removeObject(forKey: cachedIdentityKey) UserDefaults.standard.removeObject(forKey: cachedNameKey) UserDefaults.standard.removeObject(forKey: cachedImageUriKey) ContactsManager.restoreContactProfileOverrides(nil) @@ -1006,6 +1015,7 @@ class PubkyProfileManager: ObservableObject { cachedImageUri = nil UserDefaults.standard.removeObject(forKey: Self.cachedNameKey) UserDefaults.standard.removeObject(forKey: Self.cachedImageUriKey) + UserDefaults.standard.removeObject(forKey: Self.cachedIdentityKey) } private func setProfileSetupPending(_ pending: Bool) { diff --git a/Bitkit/Models/PaykitPaymentStateBackup.swift b/Bitkit/Models/PaykitPaymentStateBackup.swift index cc6279b16..e262098c8 100644 --- a/Bitkit/Models/PaykitPaymentStateBackup.swift +++ b/Bitkit/Models/PaykitPaymentStateBackup.swift @@ -4,6 +4,7 @@ import Paykit struct PaykitPaymentStateBackup: Codable { let subscriptions: [String: Subscription] let pendingProofs: [Proof] + var acceptedOneTimeRequests: [String: [RequestID]]? = nil struct RequestID: Codable { let paymentRequestId: String diff --git a/Bitkit/Services/BackupService.swift b/Bitkit/Services/BackupService.swift index 8c3edecfd..14d5b237e 100644 --- a/Bitkit/Services/BackupService.swift +++ b/Bitkit/Services/BackupService.swift @@ -319,6 +319,8 @@ class BackupService { if let paymentState = payload.paykitPaymentState { try PaykitSubscriptionStateStore().restoreBackup(paymentState.subscriptions) try await PaykitPaymentProofService.shared.restoreBackup(paymentState.pendingProofs) + try PaykitPaymentRequestIdStore(key: .paykitAcceptedPaymentRequests) + .restoreBackup(paymentState.acceptedOneTimeRequests ?? [:]) } try TransferStorage.shared.upsertList(payload.transfers) await PrivatePaykitAddressReservationStore.shared.restoreBackup(payload.privatePaykitHighestReservedReceiveIndexByAddressType) @@ -507,6 +509,7 @@ class BackupService { .store(in: &cancellables) PaykitSubscriptionStateStore.walletBackupDataChangedPublisher + .merge(with: PaykitPaymentRequestIdStore.walletBackupDataChangedPublisher) .merge(with: PaykitPaymentProofService.proofStateChangedPublisher) .debounce(for: .milliseconds(500), scheduler: DispatchQueue.main) .sink { [weak self] _ in @@ -879,7 +882,8 @@ class BackupService { watchOnlyAccountAllocationState: watchOnlyAccountSnapshot.allocationState, paykitPaymentState: PaykitPaymentStateBackup( subscriptions: PaykitSubscriptionStateStore().backupSnapshot(), - pendingProofs: PaykitPaymentProofService.shared.backupSnapshot() + pendingProofs: PaykitPaymentProofService.shared.backupSnapshot(), + acceptedOneTimeRequests: PaykitPaymentRequestIdStore(key: .paykitAcceptedPaymentRequests).backupSnapshot() ) ) return try JSONEncoder().encode(payload) diff --git a/Bitkit/Services/CoreService.swift b/Bitkit/Services/CoreService.swift index 6290cc491..6ec324f92 100644 --- a/Bitkit/Services/CoreService.swift +++ b/Bitkit/Services/CoreService.swift @@ -36,6 +36,20 @@ struct AccountAddresses { class ActivityService { private let coreService: CoreService + private static let detachedContactsKey = "activityDetachedContacts" + + func isContactDetached(activityId: String, walletId: String) -> Bool { + let detached = UserDefaults.standard.dictionary(forKey: Self.detachedContactsKey) as? [String: [String]] ?? [:] + return detached[walletId]?.contains(activityId) == true + } + + func setContactDetached(_ detached: Bool, activityId: String, walletId: String) { + var entries = UserDefaults.standard.dictionary(forKey: Self.detachedContactsKey) as? [String: [String]] ?? [:] + var ids = Set(entries[walletId] ?? []) + if detached { ids.insert(activityId) } else { ids.remove(activityId) } + entries[walletId] = ids.isEmpty ? nil : Array(ids) + UserDefaults.standard.set(entries, forKey: Self.detachedContactsKey) + } private let activitiesChangedSubject = PassthroughSubject() @@ -638,7 +652,7 @@ class ActivityService { Logger.error("Failed to find address for txid \(txid): \(error)", context: "CoreService.processOnchainPayment") } - if contact == nil { + if contact == nil, !isContactDetached(activityId: payment.id, walletId: WalletScope.default) { let details: BitkitCore.TransactionDetails? = if let transactionDetails { transactionDetails } else { @@ -687,7 +701,18 @@ class ActivityService { ) if let existingActivity, case let .onchain(existing) = existingActivity { - try await update(id: existing.id, activity: .onchain(onchain)) + try await ServiceQueue.background(.core) { + var updated = onchain + if case let .onchain(latest)? = try getActivityById(walletId: existing.walletId, activityId: existing.id), + latest.contact != existing.contact + { + updated.contact = latest.contact + } + if self.isContactDetached(activityId: existing.id, walletId: existing.walletId) { updated.contact = nil } + try updateActivity(activityId: existing.id, activity: .onchain(updated)) + self.updateBoostTxIdsCache(for: .onchain(updated)) + self.activitiesChangedSubject.send() + } } else { try await upsert(.onchain(onchain)) } @@ -929,14 +954,24 @@ class ActivityService { ) if existingActivity != nil { - try await update(id: payment.id, activity: .lightning(ln)) + try await ServiceQueue.background(.core) { + var updated = ln + if case let .lightning(latest)? = try getActivityById(walletId: ln.walletId, activityId: payment.id), + latest.contact != existingLightning?.contact + { + updated.contact = latest.contact + } + if self.isContactDetached(activityId: payment.id, walletId: ln.walletId) { updated.contact = nil } + try updateActivity(activityId: payment.id, activity: .lightning(updated)) + self.activitiesChangedSubject.send() + } } else { try await upsert(.lightning(ln)) } } private func privatePaykitContactPublicKey(forReceivedInvoicePaymentHash paymentHash: String, direction: PaymentDirection) async -> String? { - guard direction == .inbound else { return nil } + guard direction == .inbound, !isContactDetached(activityId: paymentHash, walletId: WalletScope.default) else { return nil } return await privateInvoiceContactResolver?(paymentHash) } @@ -1403,6 +1438,7 @@ class ActivityService { throw AppError(message: "Activity not found", debugMessage: "Activity with ID \(id) not found") } + self.setContactDetached(normalizedContact == nil, activityId: ActivityScope.id(of: activity), walletId: walletId) switch activity { case var .lightning(lightning): guard lightning.contact != normalizedContact else { return } diff --git a/Bitkit/Services/PaykitPaymentProofService.swift b/Bitkit/Services/PaykitPaymentProofService.swift index b74e080f8..7ec70dec6 100644 --- a/Bitkit/Services/PaykitPaymentProofService.swift +++ b/Bitkit/Services/PaykitPaymentProofService.swift @@ -372,6 +372,16 @@ actor PaykitPaymentProofService { paymentAppId: String, paymentEndpointIdentifier: String ) async { + let prepared = try? await loadProofs().filter { + $0.requestId == request.id && $0.paymentAppId == paymentAppId && + $0.paymentEndpointIdentifier == paymentEndpointIdentifier && + $0.kind == .onchain && $0.paymentStarted && !$0.hasUnsupportedOnchainWallet && + $0.paymentIdentifier == nil && $0.proofData == nil + } + if let prepared, prepared.count == 1, let proof = prepared.first { + await completeOnchainPayment(requestId: request.id, identity: proof.identity, txid: txid, fallbackProof: proof) + return + } guard let identity = try? await currentIdentity() else { return } let fallbackProof = try? await pendingProof( request: request, diff --git a/Bitkit/Services/PaykitPaymentRequestService.swift b/Bitkit/Services/PaykitPaymentRequestService.swift index 796fc9a4c..d11e9aa33 100644 --- a/Bitkit/Services/PaykitPaymentRequestService.swift +++ b/Bitkit/Services/PaykitPaymentRequestService.swift @@ -1,3 +1,4 @@ +import Combine import Foundation import ImageIO import LDKNode @@ -1019,6 +1020,11 @@ enum PaykitPaymentRequestPresentationCoordinator { } struct PaykitPaymentRequestIdStore: PaykitPaymentRequestIdStoring { + private static let backupChanged = PassthroughSubject() + static var walletBackupDataChangedPublisher: AnyPublisher { + backupChanged.eraseToAnyPublisher() + } + var key: KeychainEntryType = .paykitPresentedPaymentRequests private struct State: Codable { @@ -1041,6 +1047,21 @@ struct PaykitPaymentRequestIdStore: PaykitPaymentRequestIdStoring { } state.idsByIdentity[normalizedIdentity] = Array(ids) try Keychain.upsert(key: key, data: JSONEncoder().encode(state)) + if case .paykitAcceptedPaymentRequests = key { + Self.backupChanged.send() + } + } + + func backupSnapshot() throws -> [String: [PaykitPaymentStateBackup.RequestID]] { + guard let data = try Keychain.load(key: key) else { return [:] } + return try JSONDecoder().decode(State.self, from: data).idsByIdentity.mapValues { + $0.map { PaykitPaymentStateBackup.RequestID($0, billingPeriod: nil) } + } + } + + func restoreBackup(_ snapshot: [String: [PaykitPaymentStateBackup.RequestID]]) throws { + let state = State(idsByIdentity: snapshot.mapValues { $0.map { $0.restored(billingPeriod: nil) } }) + try Keychain.upsert(key: key, data: JSONEncoder().encode(state)) } } @@ -1489,7 +1510,9 @@ final class PaykitPaymentRequestManager { isApprovedForPayment(request) else { throw PaykitPaymentRequestError.requestUnavailable } } catch { - approvedPaymentRequestIds.remove(request.id) + if error as? PaykitPaymentRequestError == .requestUnavailable { + approvedPaymentRequestIds.remove(request.id) + } throw error } } @@ -1517,13 +1540,29 @@ final class PaykitPaymentRequestManager { try await service.claimForPayment($0) try await consumePrivatePaymentList() if $0.requiresAcceptance { - try await service.accept($0) var ids = try acceptanceStore.load(identity: identity) + let alreadySaved = ids.contains($0.id) ids.insert($0.id) try acceptanceStore.save(ids, identity: identity) if PubkyPublicKeyFormat.matches(activeIdentity, identity) { acceptedRequestIds = ids } + do { + try await service.accept($0) + } catch { + // An interrupted response may follow a committed acceptance. Keep its local owner for reconciliation. + switch error { + case is CancellationError, PaykitError.Transport, PaykitError.Storage, PaykitError.Identity: + break + default: + if !alreadySaved { + ids.remove($0.id) + try acceptanceStore.save(ids, identity: identity) + if PubkyPublicKeyFormat.matches(activeIdentity, identity) { acceptedRequestIds = ids } + } + } + throw error + } } } } catch is CancellationError { diff --git a/Bitkit/Services/PaykitReceivedPaymentContacts.swift b/Bitkit/Services/PaykitReceivedPaymentContacts.swift index 236ecd3ca..c5ff3ca73 100644 --- a/Bitkit/Services/PaykitReceivedPaymentContacts.swift +++ b/Bitkit/Services/PaykitReceivedPaymentContacts.swift @@ -171,6 +171,7 @@ extension ActivityService { defer { if changed { notifyActivitiesChanged() } } for activity in activities { guard !Task.isCancelled, await isCurrent() else { return false } + guard !isContactDetached(activityId: ActivityScope.id(of: activity), walletId: ActivityScope.walletId(of: activity)) else { continue } var outputAddresses: [String] = [] var combined = contacts switch activity { @@ -195,7 +196,9 @@ extension ActivityService { guard !Task.isCancelled, await isCurrent(), await reservations.attributionRevision == reservationRevision else { return false } let didUpdate = try await ServiceQueue.background(.core) { // Async lookups must not overwrite a contact or metadata written in the meantime. - guard try getActivityById(walletId: WalletScope.default, activityId: activity.activityId) == activity else { return false } + guard !self.isContactDetached(activityId: activity.activityId, walletId: WalletScope.default), + try getActivityById(walletId: WalletScope.default, activityId: activity.activityId) == activity + else { return false } try updateActivity(activityId: activity.activityId, activity: updated) return true } diff --git a/Bitkit/Services/PrivatePaykitService+Backup.swift b/Bitkit/Services/PrivatePaykitService+Backup.swift index 08fe0efcc..987cfb91e 100644 --- a/Bitkit/Services/PrivatePaykitService+Backup.swift +++ b/Bitkit/Services/PrivatePaykitService+Backup.swift @@ -4,7 +4,7 @@ import Foundation extension PrivatePaykitService { func backupSnapshot() async throws -> String? { - guard await PubkyService.currentPublicKey() != nil else { + guard try await PaykitSdkService.shared.currentPublicKey() != nil else { return nil } let backup = try await Backup( diff --git a/Bitkit/Services/PrivatePaykitService+Contacts.swift b/Bitkit/Services/PrivatePaykitService+Contacts.swift index 5ff63742d..5d7d2638d 100644 --- a/Bitkit/Services/PrivatePaykitService+Contacts.swift +++ b/Bitkit/Services/PrivatePaykitService+Contacts.swift @@ -171,6 +171,7 @@ extension PrivatePaykitService { } private func removePublishedEndpointsLocked(for publicKeys: [String]) async throws { + try await PublicPaykitService.syncPaykitApp(privateSharingEnabled: true) let linkedPublicKeys = try await Set(PaykitSdkService.shared.linkedPeers() .filter { $0.state != .notLinked } .compactMap { PubkyPublicKeyFormat.normalized($0.counterparty) }) @@ -228,6 +229,7 @@ extension PrivatePaykitService { if let firstError { throw firstError } + try await PublicPaykitService.syncPaykitApp() } func privatePaymentListCleanupKeys(_ publicKeys: [String], linkedPublicKeys: Set) -> [String] { @@ -437,6 +439,8 @@ extension PrivatePaykitService { "Failed to prepare private Paykit link for \(PubkyPublicKeyFormat.redacted(publicKey)) during \(reason): \(error)", context: "PrivatePaykit" ) + linkRetryKeys.append(publicKey) + continue } linkRetryKeys.append(publicKey) do { diff --git a/Bitkit/Services/PubkyService.swift b/Bitkit/Services/PubkyService.swift index 661a9098f..99b0ed2cd 100644 --- a/Bitkit/Services/PubkyService.swift +++ b/Bitkit/Services/PubkyService.swift @@ -610,7 +610,7 @@ actor PaykitSdkService { func fetchPubkyFollows(publicKey: String) async throws -> [String] { try await operationLock.withLock { - try await handle().fetchPubkyFollows(publicKey: publicKey, maxEntries: 1000) + try await handle().fetchPubkyFollows(publicKey: publicKey, maxEntries: 10000) } } @@ -1105,7 +1105,6 @@ actor PaykitSdkService { ) async throws { let keys: [KeychainEntryType] = [.paykitSession, .pubkySecretKey] let previousValues = try keys.map { try Keychain.load(key: $0) } - let isDifferentIdentity = !Self.publicKeysMatch(previousPublicKey, result.publicKey) let sdk: PaykitSdk do { try persistSessionAccess(result.sessionAccess) @@ -1131,9 +1130,7 @@ actor PaykitSdkService { resetRuntime() throw rollbackError ?? error } - if isDifferentIdentity, previousPublicKey != nil { - await PubkyProfileManager.clearCachedIdentityMetadata() - } + await PubkyProfileManager.activateCachedIdentity(publicKey: result.publicKey, previousPublicKey: previousPublicKey) if AdoptedPubkyReference.current != nil || result.sessionAccess.exportLocalSecretKey() == nil { SharedPubkyKeychain.removeAllOwn() } @@ -1170,16 +1167,6 @@ actor PaykitSdkService { return try await handle().identityStatus()?.publicKey } - private nonisolated static func publicKeysMatch(_ lhs: String?, _ rhs: String) -> Bool { - guard let lhs, - let normalizedLhs = try? Paykit.normalizePubkyPublicKey(value: lhs), - let normalizedRhs = try? Paykit.normalizePubkyPublicKey(value: rhs) - else { - return false - } - return normalizedLhs == normalizedRhs - } - nonisolated static func shouldDeferStaleSession(error: Error, hasStoredSession: Bool) -> Bool { guard hasStoredSession, case let PaykitError.Identity(_, context) = error @@ -1500,7 +1487,7 @@ final class PaykitSdkSessionProvider: SdkPubkySessionProvider, @unchecked Sendab func setPaykitIdentitySecretKey(_ key: PaykitIdentitySecretKey) { lock.lock() defer { lock.unlock() } - if (paykitIdentitySecretKey?.keyGeneration() ?? 1) != key.keyGeneration() { + if paykitIdentitySecretKey?.keyGeneration() != key.keyGeneration() { liveSessionAccess = nil } paykitIdentitySecretKey = key diff --git a/BitkitTests/PaykitPaymentProofServiceTests.swift b/BitkitTests/PaykitPaymentProofServiceTests.swift index 5df5e4a05..36a733969 100644 --- a/BitkitTests/PaykitPaymentProofServiceTests.swift +++ b/BitkitTests/PaykitPaymentProofServiceTests.swift @@ -427,6 +427,25 @@ final class PaykitPaymentProofServiceTests: XCTestCase { await completionTask.value } + func testOnchainCompletionRetainsTransactionIdWithoutLiveIdentity() async throws { + let endpoint = PublicPaykitService.MethodId.regtestOnchainP2wpkh.rawValue + let record = try paymentRequestRecord(endpoints: [endpoint]) + let request = try XCTUnwrap(PaykitPaymentRequest(record: record, now: Date())) + let store = PaymentProofMemoryStore() + let sdk = PaymentProofSdkMock(identity: identity, records: [record]) + let service = paymentProofService(sdk: sdk, store: store) + let txid = String(repeating: "ab", count: 32) + try await service.prepare(request: request, paymentAppId: "bitkit", paymentEndpointIdentifier: endpoint, kind: .onchain) + try await service.markOnchainPaymentStarted(request, address: onchainAddress) + await sdk.setIdentityAvailable(false) + + await service.completeOnchainPayment(request, txid: txid, paymentAppId: "bitkit", paymentEndpointIdentifier: endpoint) + + let proof = await store.snapshot().first + XCTAssertEqual(proof?.paymentIdentifier, txid) + XCTAssertEqual(proof?.proofData, txid) + } + func testStartedOnchainPaymentSurvivesPreparationCancellation() async throws { let endpoint = PublicPaykitService.MethodId.regtestOnchainP2wpkh.rawValue let record = try paymentRequestRecord(endpoints: [endpoint]) diff --git a/BitkitTests/PaykitPaymentRequestServiceTests.swift b/BitkitTests/PaykitPaymentRequestServiceTests.swift index ca1f3396f..0e4783bb8 100644 --- a/BitkitTests/PaykitPaymentRequestServiceTests.swift +++ b/BitkitTests/PaykitPaymentRequestServiceTests.swift @@ -3211,6 +3211,7 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { XCTAssertEqual(error as? PaymentRequestSdkMockError, .linkedPeers) } await sdk.setLinkedPeersError(nil) + try await manager.ensurePaymentAllowed(request) } if !testCase.ownsContext { app.contactPaymentContext = ContactPaymentContext(publicKey: counterparty) @@ -3616,6 +3617,8 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { XCTAssertEqual(error as? PaymentRequestSdkMockError, .process) } XCTAssertFalse(manager.isApprovedForPayment(request)) + let calls = await sdk.snapshot().acceptedRequests + XCTAssertTrue(calls.isEmpty, "Do not accept remotely until the local intent is durable") record.state = .accepted await sdk.setRecords([record]) await manager.refresh() @@ -3634,6 +3637,29 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { XCTAssertTrue(manager.requestsForPresentation().isEmpty) } + func testInterruptedAcceptanceCanResumeAfterRefresh() async throws { + let record = try paymentRequestRecord() + let sdk = PaymentRequestSdkMock(records: [record]) + let store = PaymentRequestPresentationMemoryStore() + let manager = paymentRequestManager(sdk: sdk, acceptanceStore: store) + await manager.refresh() + let request = try XCTUnwrap(manager.pendingRequests.first) + await sdk.setAcceptanceResponseError(PaykitError.Transport(code: "transport_error", context: "response lost")) + + do { + try await manager.prepareForPayment(request) + XCTFail("The interrupted call must not authorize execution") + } catch {} + XCTAssertFalse(manager.isApprovedForPayment(request)) + var accepted = record + accepted.state = .accepted + await sdk.setRecords([accepted]) + await manager.refresh() + let retry = try XCTUnwrap(manager.pendingRequests.first) + try await manager.prepareForPayment(retry) + try await manager.ensurePaymentAllowed(retry) + } + func testClearingDuringRetryDoesNotReopenRequest() async throws { let sdk = try PaymentRequestSdkMock(records: [paymentRequestRecord()]) let manager = paymentRequestManager(sdk: sdk) @@ -4873,6 +4899,8 @@ private actor PaymentRequestSdkMock: PaykitPaymentRequestSdkHandling, PaykitPaym private var acceptedRequests: [PaymentRequestInvocation] = [] private var rejectedRequests: [PaymentRequestInvocation] = [] private var acceptFailuresAfterRemoval = 0 + private var acceptanceResponseError: Error? + private var rejectFailuresAfterRemoval = 0 private var proposedRequests: [ProposedPaymentRequestInvocation] = [] private var shouldPauseNextPaymentRequestList = false @@ -5065,6 +5093,7 @@ private actor PaymentRequestSdkMock: PaykitPaymentRequestSdkHandling, PaykitPaym id: paymentRequestId ) } + if let acceptanceResponseError { throw acceptanceResponseError } if acceptFailuresAfterRemoval > 0 { acceptFailuresAfterRemoval -= 1 throw PaymentRequestSdkMockError.process @@ -5111,6 +5140,10 @@ private actor PaymentRequestSdkMock: PaykitPaymentRequestSdkHandling, PaykitPaym processFailuresRemaining += 1 } + func setAcceptanceResponseError(_ error: Error) { + acceptanceResponseError = error + } + func pauseNextProcess() { shouldPauseNextProcess = true } diff --git a/BitkitTests/PaykitPaymentStateBackupTests.swift b/BitkitTests/PaykitPaymentStateBackupTests.swift index f5a9f9fc0..53e080c3f 100644 --- a/BitkitTests/PaykitPaymentStateBackupTests.swift +++ b/BitkitTests/PaykitPaymentStateBackupTests.swift @@ -24,6 +24,7 @@ final class PaykitPaymentStateBackupTests: XCTestCase { try Keychain.delete(key: .paykitSubscriptionState) try Keychain.delete(key: .paykitPendingPaymentProofs) try Keychain.delete(key: .paykitPendingBackupRestore) + try Keychain.delete(key: .paykitAcceptedPaymentRequests) } func testPaymentStateBackupRoundTrip() async throws { @@ -56,15 +57,22 @@ final class PaykitPaymentStateBackupTests: XCTestCase { onchainWalletId: "trezor:android", onchainMatchingTransactionIdsBeforeAttempt: ["previous-transaction"] ) - let backup = PaykitPaymentStateBackup( + let acceptedId = PaykitPaymentRequest.ID(paymentRequestId: "one-time", counterparty: identity) + let acceptanceStore = PaykitPaymentRequestIdStore(key: .paykitAcceptedPaymentRequests) + try acceptanceStore.save([acceptedId], identity: identity) + let backup = try PaykitPaymentStateBackup( subscriptions: [identity: .init(subscriptions)], - pendingProofs: [.init(proof)] + pendingProofs: [.init(proof)], + acceptedOneTimeRequests: acceptanceStore.backupSnapshot() ) let data = try JSONEncoder().encode(backup) let decoded = try JSONDecoder().decode(PaykitPaymentStateBackup.self, from: data) XCTAssertEqual(decoded.pendingProofs.first?.requestId.billingPeriodStartsAt, "2026-09-24T10:00:00.100Z") try PaykitSubscriptionStateStore().restoreBackup(decoded.subscriptions) try await PaykitPaymentProofService.shared.restoreBackup(decoded.pendingProofs) + try Keychain.delete(key: .paykitAcceptedPaymentRequests) + try acceptanceStore.restoreBackup(XCTUnwrap(decoded.acceptedOneTimeRequests)) + XCTAssertEqual(try acceptanceStore.load(identity: identity), [acceptedId]) XCTAssertEqual(try PaykitSubscriptionStateStore().load(identity: identity), subscriptions) let loaded = try await PaykitPaymentProofStore().load() diff --git a/BitkitTests/PaykitReceivedPaymentContactsTests.swift b/BitkitTests/PaykitReceivedPaymentContactsTests.swift index 6dc2bc973..10fb882a9 100644 --- a/BitkitTests/PaykitReceivedPaymentContactsTests.swift +++ b/BitkitTests/PaykitReceivedPaymentContactsTests.swift @@ -170,6 +170,15 @@ final class PaykitReceivedPaymentContactsTests: XCTestCase { } let afterSkippedScan = try await service.getActivity(id: activity.activityId) XCTAssertEqual(afterSkippedScan, persisted) + + defer { service.setContactDetached(false, activityId: activity.activityId, walletId: WalletScope.default) } + try await service.setContact(nil, forActivity: activity.activityId) + try await service.backfillReceivedPaykitContacts(contacts, identity: alice, cache: cache, reservations: reservations) { true } + let detached = try await service.getActivity(id: activity.activityId) + guard case let .onchain(detachedPayment) = detached else { return XCTFail("Expected the detached payment") } + XCTAssertNil(detachedPayment.contact) + try await service.setContact(bob, forActivity: activity.activityId) + XCTAssertFalse(service.isContactDetached(activityId: activity.activityId, walletId: WalletScope.default)) } func testUnavailableReservationsCannotCacheNegativeMatch() async { diff --git a/BitkitTests/PaykitSdkClientConfigTests.swift b/BitkitTests/PaykitSdkClientConfigTests.swift index 2e288f551..318fd1e49 100644 --- a/BitkitTests/PaykitSdkClientConfigTests.swift +++ b/BitkitTests/PaykitSdkClientConfigTests.swift @@ -151,9 +151,9 @@ final class PaykitSdkClientConfigTests: XCTestCase { } @MainActor - func testIdentityActivationSeparatesCacheAndPreservesSameOwnerOrLegacyBackup() async throws { + func testIdentityActivationSeparatesCacheAndPreservesSameOwner() async throws { let defaults = UserDefaults.standard - let metadataKeys = ["pubky_profile_name", "pubky_profile_image_uri"] + let metadataKeys = ["pubky_profile_name", "pubky_profile_image_uri", "pubky_profile_identity"] let savedMetadata = metadataKeys.map { defaults.object(forKey: $0) } let savedOverrides = ContactsManager.backupContactProfileOverrides() let savedReference = AdoptedPubkyReference.current @@ -183,6 +183,7 @@ final class PaykitSdkClientConfigTests: XCTestCase { let differentKey = try PubkyProfileManager.publicKeyFromSecretKey(String(repeating: "02", count: 32)) let overrides = [originalKey: PubkyProfileData(name: "Private label", bio: "", image: nil, links: [], tags: [])] for previousKey in [originalKey, "pubky\(originalKey)", differentKey, nil] { + defaults.removeObject(forKey: metadataKeys[2]) defaults.set("Original profile", forKey: metadataKeys[0]) defaults.set("pubky://original/avatar", forKey: metadataKeys[1]) ContactsManager.restoreContactProfileOverrides(overrides) @@ -214,6 +215,14 @@ final class PaykitSdkClientConfigTests: XCTestCase { XCTAssertEqual(ContactsManager.backupContactProfileOverrides(), overrides) } } + + defaults.set(differentKey, forKey: metadataKeys[2]) + defaults.set("Other identity", forKey: metadataKeys[0]) + ContactsManager.restoreContactProfileOverrides(overrides) + PubkyProfileManager.activateCachedIdentity(publicKey: publicKey, previousPublicKey: nil) + XCTAssertNil(defaults.string(forKey: metadataKeys[0])) + XCTAssertNil(ContactsManager.backupContactProfileOverrides()) + XCTAssertEqual(defaults.string(forKey: metadataKeys[2]), publicKey) } @MainActor diff --git a/BitkitTests/PrivatePaykitServiceTests.swift b/BitkitTests/PrivatePaykitServiceTests.swift index 999436f5d..0ea366010 100644 --- a/BitkitTests/PrivatePaykitServiceTests.swift +++ b/BitkitTests/PrivatePaykitServiceTests.swift @@ -578,29 +578,34 @@ final class PrivatePaykitServiceTests: XCTestCase { XCTAssertEqual(keys, ["linked", "published"]) } - func testImmediatePublicationSkipsContactsWithoutPaykit() async { - let service = PrivatePaykitService() - let operations = PrivatePaykitService.EndpointPublicationOperations( - currentPublicKey: { "pubkylocal" }, - ensureLink: { _ in throw PaykitError.NotFound(code: "not_found", context: "No App Registry") }, - buildEndpoints: { _ in - XCTFail("An unsupported contact should not reserve wallet addresses") - return [] - }, - syncPaymentLists: { _ in - XCTFail("An unsupported contact should not receive a private list") - return PrivatePaymentListDeliveryReport(queued: [], cleared: [], failedToQueue: [], failedToDeliver: []) - } - ) + func testImmediatePublicationSkipsContactsWithoutPaykitOrUnavailableLinks() async { + for linkError in [ + PaykitError.NotFound(code: "not_found", context: "No App Registry"), + PaykitError.Transport(code: "offline", context: "Unavailable homeserver"), + ] { + let service = PrivatePaykitService() + let operations = PrivatePaykitService.EndpointPublicationOperations( + currentPublicKey: { "pubkylocal" }, + ensureLink: { _ in throw linkError }, + buildEndpoints: { _ in + XCTFail("An unsupported contact should not reserve wallet addresses") + return [] + }, + syncPaymentLists: { _ in + XCTFail("An unsupported contact should not receive a private list") + return PrivatePaymentListDeliveryReport(queued: [], cleared: [], failedToQueue: [], failedToDeliver: []) + } + ) - let error = await service.syncLocalEndpointPublication( - for: ["pubky1rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg"], - reason: "test", - requireImmediatePublication: true, - operations: operations - ) + let error = await service.syncLocalEndpointPublication( + for: ["pubky1rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg"], + reason: "test", + requireImmediatePublication: true, + operations: operations + ) - XCTAssertNil(error) + XCTAssertNil(error) + } } func testImmediatePublicationContinuesAfterEndpointPreparationFailure() async throws { diff --git a/journeys/payment-requests/README.md b/journeys/payment-requests/README.md index bc5afc32b..ae5781b9c 100644 --- a/journeys/payment-requests/README.md +++ b/journeys/payment-requests/README.md @@ -31,6 +31,11 @@ That run established the issuer shapes captured by the fixture: lowercase `btc`, ## Accepting install +Acceptance intent is saved before the remote operation and included in wallet backups. +An interrupted response is reconciled against the shared request before payment. Restoring +the wallet restores its pending acceptances; this does not support running the same wallet +on multiple devices concurrently. + `accepted-device-ownership.xml` uses two separate E2E Bitkit installs with Paykit UI enabled, sharing one Pubky identity and App ID `bitkit`. Fund each regtest Lightning wallet for 21,000 sats plus fees, and save and link the fixture issuer. Do not copy app-private storage between installs. diff --git a/journeys/pubky-marketplace/wallet-leg.xml b/journeys/pubky-marketplace/wallet-leg.xml index 326cddff5..8010f155d 100644 --- a/journeys/pubky-marketplace/wallet-leg.xml +++ b/journeys/pubky-marketplace/wallet-leg.xml @@ -52,5 +52,8 @@ Tap the seller's latest received activity (id "ActivityShort-0"), then tap transaction details (id "ActivityTxDetails") Verify the transaction id (id "TXID") matches the fixture transaction Capture the final activity, transaction id, confirmation height, and completed purchase evidence + Return to the received activity details and tap Detach + Wait for Paykit synchronization, then restart the seller app and reopen the received activity + Verify the activity no longer identifies the buyer and offers Assign instead of Detach From bdd9344111d6b3f27c634e2ec6a38a68c87a5954 Mon Sep 17 00:00:00 2001 From: benk10 Date: Thu, 1 Oct 2026 09:50:40 -0500 Subject: [PATCH 11/68] test: align contact import with shared paykit storage --- BitkitTests/ContactsManagerTests.swift | 42 +++++++------------------- 1 file changed, 11 insertions(+), 31 deletions(-) diff --git a/BitkitTests/ContactsManagerTests.swift b/BitkitTests/ContactsManagerTests.swift index 87a9c34ac..cf5355c1d 100644 --- a/BitkitTests/ContactsManagerTests.swift +++ b/BitkitTests/ContactsManagerTests.swift @@ -12,8 +12,8 @@ final class ContactsManagerTests: XCTestCase { UserDefaults.standard.set(false, forKey: PaykitFeatureFlags.uiEnabledKey) } - func testImportPersistsPreparedContactThroughDefaultSDKWithoutSession() async throws { - let keys: [KeychainEntryType] = [.paykitSdkState, .paykitSession] + func testImportRequiresSessionForSharedContactStorage() async throws { + let keys: [KeychainEntryType] = [.paykitSession, .pubkySecretKey] let originals = try keys.map { try Keychain.load(key: $0) } addTeardownBlock { await PaykitSdkService.shared.clearState() @@ -26,37 +26,17 @@ final class ContactsManagerTests: XCTestCase { } } await PaykitSdkService.shared.clearState() - try Keychain.delete(key: .paykitSession) - // Generated with Paykit rc56's StorageStateEnvelope v1 and postcard::to_allocvec: - // one public identity initialized at 2026-01-01T00:00:00Z, generation 0, no Noise key or other records. - let fixture = try XCTUnwrap(Data(base64Encoded: - "AQEBNDNyc2R1aGN4cHc3NHNud3ljdDg2bTM4YzYzajNwcTh4NHljcWlreGc2NHJvaWs4eXc1eHkAFDIwMjYtMDEtMDFUMDA6MDA6MDBaAAAAAAAAAAAAAAAAAAAAAAA=")) - let snapshot = SdkStateBlobSnapshot(blob: SdkStateBlob(bytes: fixture), revision: "contact-import-fixture") - try Keychain.upsert(key: .paykitSdkState, data: encodeSdkStateBlobSnapshot(snapshot: snapshot)) - + for key in keys { + try Keychain.delete(key: key) + } let prepared = makeContact(publicKey: "pubky5jsjx1o6fzu6aeeo697r3i5rx15zq41kikcye8wtwdqm4nb4tryo") let manager = ContactsManager() - try await manager.importContacts(contacts: [prepared]) - let stored = try await PubkyService.contactRecords() - XCTAssertEqual(stored.count, 1) - XCTAssertEqual(stored.first?.publicKey, prepared.publicKey) - XCTAssertEqual(stored.first?.label, prepared.displayName) - XCTAssertEqual(stored.first?.receiverPaths, [PaykitReceiverPath.wallet]) - XCTAssertEqual(manager.contacts, [prepared]) - - _ = try await PubkyService.saveContact( - publicKey: prepared.publicKey, label: prepared.displayName, - receiverPaths: [PaykitReceiverPath.wallet, PaykitReceiverPath.server] - ) - try await ContactsManager().importContacts(contacts: [prepared]) - let persisted = try XCTUnwrap(Keychain.load(key: .paykitSdkState)) - await PaykitSdkService.shared.clearState() - try Keychain.upsert(key: .paykitSdkState, data: persisted) - let reloaded = try await PubkyService.contactRecords() - XCTAssertEqual(reloaded.count, 1) - XCTAssertEqual(reloaded.first?.publicKey, prepared.publicKey) - XCTAssertEqual(reloaded.first?.label, prepared.displayName) - XCTAssertEqual(Set(reloaded.first?.receiverPaths ?? []), [PaykitReceiverPath.wallet, PaykitReceiverPath.server]) + do { + try await manager.importContacts(contacts: [prepared]) + XCTFail("Shared contact storage requires an active session") + } catch { + XCTAssertTrue(manager.contacts.isEmpty) + } XCTAssertNil(try Keychain.load(key: .paykitSession)) } From cf55252b1d3188c6abe3b38101bfc9de3d3eddb8 Mon Sep 17 00:00:00 2001 From: benk10 Date: Thu, 1 Oct 2026 10:05:46 -0500 Subject: [PATCH 12/68] chore: update Paykit to rc59 --- Bitkit.xcodeproj/project.pbxproj | 2 +- .../project.xcworkspace/xcshareddata/swiftpm/Package.resolved | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Bitkit.xcodeproj/project.pbxproj b/Bitkit.xcodeproj/project.pbxproj index 288f76d68..151c3ee8f 100644 --- a/Bitkit.xcodeproj/project.pbxproj +++ b/Bitkit.xcodeproj/project.pbxproj @@ -1178,7 +1178,7 @@ repositoryURL = "https://github.com/pubky/paykit-rs"; requirement = { kind = exactVersion; - version = "0.1.0-rc58"; + version = "0.1.0-rc59"; }; }; 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 d06d64a81..53cd3483f 100644 --- a/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -33,8 +33,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pubky/paykit-rs", "state" : { - "revision" : "258e3cd2329b275c9148c86e8ef540fc78c8d7f8", - "version" : "0.1.0-rc58" + "revision" : "7b75d28c2679656a0d1c6e1ef6d8ee9881c9be07", + "version" : "0.1.0-rc59" } }, { From 17e73a09f3466cd29d368ca45f6d18e384b2f26e Mon Sep 17 00:00:00 2001 From: benk10 Date: Thu, 1 Oct 2026 10:28:24 -0500 Subject: [PATCH 13/68] fix: preserve concurrent paykit acceptance intents --- .../PaykitPaymentRequestService.swift | 16 ++--- .../PaykitPaymentRequestServiceTests.swift | 61 +++++++++++++++++++ 2 files changed, 70 insertions(+), 7 deletions(-) diff --git a/Bitkit/Services/PaykitPaymentRequestService.swift b/Bitkit/Services/PaykitPaymentRequestService.swift index d11e9aa33..50d7899e0 100644 --- a/Bitkit/Services/PaykitPaymentRequestService.swift +++ b/Bitkit/Services/PaykitPaymentRequestService.swift @@ -1522,6 +1522,7 @@ final class PaykitPaymentRequestManager { consumePrivatePaymentList: () async throws -> Void = {} ) async throws { guard let identity = activeIdentity else { throw PaykitPaymentRequestError.requestUnavailable } + let actionGeneration = stateGeneration do { if isApprovedForPayment(request) { try await ensurePaymentAllowed(request) @@ -1540,13 +1541,14 @@ final class PaykitPaymentRequestManager { try await service.claimForPayment($0) try await consumePrivatePaymentList() if $0.requiresAcceptance { + guard actionGeneration == stateGeneration, PubkyPublicKeyFormat.matches(activeIdentity, identity) else { + throw PaykitPaymentRequestError.requestUnavailable + } var ids = try acceptanceStore.load(identity: identity) let alreadySaved = ids.contains($0.id) ids.insert($0.id) try acceptanceStore.save(ids, identity: identity) - if PubkyPublicKeyFormat.matches(activeIdentity, identity) { - acceptedRequestIds = ids - } + acceptedRequestIds = ids do { try await service.accept($0) } catch { @@ -1555,10 +1557,10 @@ final class PaykitPaymentRequestManager { case is CancellationError, PaykitError.Transport, PaykitError.Storage, PaykitError.Identity: break default: - if !alreadySaved { - ids.remove($0.id) - try acceptanceStore.save(ids, identity: identity) - if PubkyPublicKeyFormat.matches(activeIdentity, identity) { acceptedRequestIds = ids } + if !alreadySaved, actionGeneration == stateGeneration, PubkyPublicKeyFormat.matches(activeIdentity, identity) { + let remaining = try acceptanceStore.load(identity: identity).subtracting([$0.id]) + try acceptanceStore.save(remaining, identity: identity) + acceptedRequestIds = remaining } } throw error diff --git a/BitkitTests/PaykitPaymentRequestServiceTests.swift b/BitkitTests/PaykitPaymentRequestServiceTests.swift index 0e4783bb8..d3a2f1855 100644 --- a/BitkitTests/PaykitPaymentRequestServiceTests.swift +++ b/BitkitTests/PaykitPaymentRequestServiceTests.swift @@ -3660,6 +3660,67 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { try await manager.ensurePaymentAllowed(retry) } + func testFailedAcceptancePreservesAnotherRequestsIntent() async throws { + let identity = "pubky\(String(repeating: "z", count: 52))" + let firstRecord = try paymentRequestRecord(id: "first") + var secondRecord = try paymentRequestRecord(id: "second") + let sdk = PaymentRequestSdkMock(records: [firstRecord, secondRecord]) + let store = PaymentRequestPresentationMemoryStore() + let manager = paymentRequestManager(sdk: sdk, acceptanceStore: store) + await manager.refresh() + let first = try XCTUnwrap(manager.pendingRequests.first { $0.paymentRequestId == "first" }) + let second = try XCTUnwrap(manager.pendingRequests.first { $0.paymentRequestId == "second" }) + await sdk.pauseNextAccept() + + let acceptance = Task { try await manager.prepareForPayment(first) } + try await waitUntil { await sdk.acceptIsPaused() } + try await manager.prepareForPayment(second) + await sdk.failNextAcceptAfterRemoval() + await sdk.resumeAccept() + do { + try await acceptance.value + XCTFail("Expected the first acceptance to fail") + } catch { + XCTAssertEqual(error as? PaymentRequestSdkMockError, .process) + } + + XCTAssertEqual(try store.load(identity: identity), [second.id]) + secondRecord.state = .accepted + await sdk.setRecords([secondRecord]) + let restarted = paymentRequestManager(sdk: sdk, acceptanceStore: store) + await restarted.refresh() + XCTAssertEqual(restarted.pendingRequests.map(\.id), [second.id]) + } + + func testFailedAcceptanceDoesNotRemoveIntentFromNewManagerGeneration() async throws { + let identity = "pubky\(String(repeating: "z", count: 52))" + let record = try paymentRequestRecord() + let sdk = PaymentRequestSdkMock(records: [record]) + let store = PaymentRequestPresentationMemoryStore() + let manager = paymentRequestManager(sdk: sdk, acceptanceStore: store) + await manager.refresh() + let request = try XCTUnwrap(manager.pendingRequests.first) + await sdk.pauseNextAccept() + + let acceptance = Task { try await manager.prepareForPayment(request) } + try await waitUntil { await sdk.acceptIsPaused() } + manager.clear() + manager.activate(identity: identity) + await manager.refresh() + let current = try XCTUnwrap(manager.pendingRequests.first) + try await manager.prepareForPayment(current) + await sdk.resumeAccept() + do { + try await acceptance.value + XCTFail("Expected the superseded acceptance to fail") + } catch { + XCTAssertEqual(error as? PaymentRequestSdkMockError, .requestMissing) + } + + XCTAssertEqual(try store.load(identity: identity), [current.id]) + XCTAssertTrue(manager.isApprovedForPayment(current)) + } + func testClearingDuringRetryDoesNotReopenRequest() async throws { let sdk = try PaymentRequestSdkMock(records: [paymentRequestRecord()]) let manager = paymentRequestManager(sdk: sdk) From 38dee7f9c03b01b2833fc69777bd2866477b688a Mon Sep 17 00:00:00 2001 From: benk10 Date: Thu, 1 Oct 2026 12:12:59 -0500 Subject: [PATCH 14/68] fix: preserve private sharing settings during contact cleanup --- .../PrivatePaykitService+Contacts.swift | 40 ++++++++++++---- Bitkit/Services/PubkyService.swift | 12 +++-- BitkitTests/PaykitContactLifecycleTests.swift | 42 +++++++++++++++- BitkitTests/PrivatePaykitServiceTests.swift | 48 +++++++++++++++++++ 4 files changed, 128 insertions(+), 14 deletions(-) diff --git a/Bitkit/Services/PrivatePaykitService+Contacts.swift b/Bitkit/Services/PrivatePaykitService+Contacts.swift index 5d7d2638d..a59a67f7d 100644 --- a/Bitkit/Services/PrivatePaykitService+Contacts.swift +++ b/Bitkit/Services/PrivatePaykitService+Contacts.swift @@ -20,6 +20,14 @@ extension PrivatePaykitService { let syncPaymentLists: (_ updates: [PrivatePaymentListReservationUpdateInput]) async throws -> PrivatePaymentListDeliveryReport } + struct EndpointCleanupOperations { + let linkedPeers: () async throws -> [LinkedPeerRecord] + let clearPaymentList: (_ publicKey: String) async throws -> PrivatePaymentListDeliveryReport? + let drainMessages: (_ publicKeys: [String]) async -> Void + let pendingDrainKeys: (_ publicKeys: [String]) async -> Set + let syncApp: () async throws -> Void + } + @discardableResult func prepareSavedContacts( _ publicKeys: [String], @@ -162,17 +170,31 @@ extension PrivatePaykitService { } func removePublishedEndpoints(for publicKeys: [String]) async throws { + try await removePublishedEndpoints(for: publicKeys, operations: EndpointCleanupOperations( + linkedPeers: { try await PaykitSdkService.shared.linkedPeers() }, + clearPaymentList: { try await PaykitSdkService.shared.clearPrivatePaymentList(to: $0) }, + drainMessages: { await self.drainPendingPrivateMessages(reason: "cleanup", advancing: $0) }, + pendingDrainKeys: { await self.pendingPrivateMessageDrainKeys($0) }, + syncApp: { try await PublicPaykitService.syncPaykitApp() } + )) + } + + func removePublishedEndpoints(for publicKeys: [String], operations: EndpointCleanupOperations) async throws { let publicKeys = normalizedSavedContactKeys(publicKeys) guard !publicKeys.isEmpty else { return } - try await withPublicationLock { - try await removePublishedEndpointsLocked(for: publicKeys) + do { + try await withPublicationLock { + try await removePublishedEndpointsLocked(for: publicKeys, operations: operations) + } + } catch { + PublicPaykitService.setCleanupPending(true) + throw error } } - private func removePublishedEndpointsLocked(for publicKeys: [String]) async throws { - try await PublicPaykitService.syncPaykitApp(privateSharingEnabled: true) - let linkedPublicKeys = try await Set(PaykitSdkService.shared.linkedPeers() + private func removePublishedEndpointsLocked(for publicKeys: [String], operations: EndpointCleanupOperations) async throws { + let linkedPublicKeys = try await Set(operations.linkedPeers() .filter { $0.state != .notLinked } .compactMap { PubkyPublicKeyFormat.normalized($0.counterparty) }) let cleanupKeys = privatePaymentListCleanupKeys(publicKeys, linkedPublicKeys: linkedPublicKeys) @@ -186,7 +208,7 @@ extension PrivatePaykitService { var clearedRetryKeys = [String]() for publicKey in cleanupKeys { do { - guard let report = try await PaykitSdkService.shared.clearPrivatePaymentList(to: publicKey) else { continue } + guard let report = try await operations.clearPaymentList(publicKey) else { continue } if !report.failedToQueue.isEmpty || !report.failedToDeliver.isEmpty { throw PrivatePaykitError.privateUnavailable } @@ -198,8 +220,8 @@ extension PrivatePaykitService { } if !clearedRetryKeys.isEmpty { - await drainPendingPrivateMessages(reason: "cleanup", advancing: clearedRetryKeys) - let pendingRetryKeys = await pendingPrivateMessageDrainKeys(clearedRetryKeys) + await operations.drainMessages(clearedRetryKeys) + let pendingRetryKeys = await operations.pendingDrainKeys(clearedRetryKeys) if !pendingRetryKeys.isEmpty { failedPublicKeys.formUnion(pendingRetryKeys) firstError = firstError ?? PrivatePaykitError.privateUnavailable @@ -229,7 +251,7 @@ extension PrivatePaykitService { if let firstError { throw firstError } - try await PublicPaykitService.syncPaykitApp() + try await operations.syncApp() } func privatePaymentListCleanupKeys(_ publicKeys: [String], linkedPublicKeys: Set) -> [String] { diff --git a/Bitkit/Services/PubkyService.swift b/Bitkit/Services/PubkyService.swift index 99b0ed2cd..3b7d65875 100644 --- a/Bitkit/Services/PubkyService.swift +++ b/Bitkit/Services/PubkyService.swift @@ -717,8 +717,7 @@ actor PaykitSdkService { func syncPaykitApp(privatePaymentsEnabled: Bool) async throws { try await withStateRevisionTracking { sdk in var capabilities = try await appCapabilities(using: sdk) - capabilities.privatePayments = capabilities.privatePayments && - (privatePaymentsEnabled || UserDefaults.standard.bool(forKey: PrivatePaykitService.cleanupPendingKey)) + capabilities.privatePayments = capabilities.privatePayments && privatePaymentsEnabled _ = try await sdk.publishPaykitApp(displayName: "Bitkit", capabilities: capabilities) } } @@ -759,6 +758,12 @@ actor PaykitSdkService { }) { return nil } + if let publicKey = try await sdk.identityStatus()?.publicKey, + let app = try await sdk.paykitAppRegistry(publicKey: publicKey)?.apps.first(where: { $0.appId == "bitkit" }), + !app.capabilities.privatePayments + { + return nil + } return try await sdk.clearPrivatePaymentListAndProcessOutbound(counterparty: counterparty) } } @@ -1142,8 +1147,7 @@ actor PaykitSdkService { do { var capabilities = try await appCapabilities(using: sdk) guard capabilities.privatePayments else { return } - capabilities.privatePayments = UserDefaults.standard.bool(forKey: PrivatePaykitService.publishingEnabledKey) || - UserDefaults.standard.bool(forKey: PrivatePaykitService.cleanupPendingKey) + capabilities.privatePayments = UserDefaults.standard.bool(forKey: PrivatePaykitService.publishingEnabledKey) _ = try await sdk.publishPaykitApp(displayName: "Bitkit", capabilities: capabilities) } catch { Logger.warn("Failed to publish Paykit app: \(error)", context: "PaykitSdkService") diff --git a/BitkitTests/PaykitContactLifecycleTests.swift b/BitkitTests/PaykitContactLifecycleTests.swift index aa8d196ef..53a449d95 100644 --- a/BitkitTests/PaykitContactLifecycleTests.swift +++ b/BitkitTests/PaykitContactLifecycleTests.swift @@ -198,6 +198,30 @@ final class PaykitContactLifecycleTests: XCTestCase { XCTAssertNil(report) XCTAssertEqual(sdk.events, eventsBeforeCleanup) } + + func testDisabledPrivateCapabilityDoesNotQueueWithdrawal() async throws { + let sdk = ContactLifecycleSdk(noPointer: .init()) + sdk.capabilities.privatePayments = false + let service = PaykitSdkService(sdkFactory: { sdk }) + + let report = try await service.clearPrivatePaymentList(to: sdk.publicKey) + + XCTAssertNil(report) + XCTAssertTrue(sdk.events.isEmpty) + XCTAssertFalse(sdk.capabilities.privatePayments) + } + + func testCleanupPendingDoesNotEnablePrivateCapability() async throws { + snapshotAppDefaultsDomain() + UserDefaults.standard.set(true, forKey: PrivatePaykitService.cleanupPendingKey) + let sdk = ContactLifecycleSdk(noPointer: .init()) + sdk.capabilities.privatePayments = false + let service = PaykitSdkService(sdkFactory: { sdk }) + + try await service.syncPaykitApp(privatePaymentsEnabled: false) + + XCTAssertFalse(sdk.capabilities.privatePayments) + } } private final class ContactLifecycleSdk: PaykitSdk, @unchecked Sendable { @@ -209,6 +233,7 @@ private final class ContactLifecycleSdk: PaykitSdk, @unchecked Sendable { var failLinkedPeers = false var failUnblock = false var failSaveContact = false + var capabilities = PaykitAppCapabilities(privatePayments: true, paymentRequests: true, receipts: false, outgoingPayments: true) lazy var record: ContactRecord? = ContactRecord( publicKey: publicKey, label: "Contact", profile: nil, profileFetchedAt: nil, createdAt: "2026-09-29T00:00:00Z", updatedAt: "2026-09-29T00:00:00Z", @@ -223,7 +248,22 @@ private final class ContactLifecycleSdk: PaykitSdk, @unchecked Sendable { ] override func paykitAppRegistry(publicKey _: String) async throws -> PaykitAppRegistry? { - nil + registry + } + + private var registry: PaykitAppRegistry { + PaykitAppRegistry(keyGeneration: 1, noisePublicKey: nil, + apps: [PaykitApp(appId: "bitkit", displayName: "Bitkit", capabilities: capabilities)], + defaultAppId: nil, defaultAppsByEndpoint: [:]) + } + + override func identityStatus() async throws -> IdentityStatus? { + IdentityStatus(publicKey: publicKey, capability: .privateLinkCapable) + } + + override func publishPaykitApp(displayName _: String, capabilities: PaykitAppCapabilities) async throws -> PaykitAppRegistry { + self.capabilities = capabilities + return registry } override func backupStateRevision() async throws -> String { diff --git a/BitkitTests/PrivatePaykitServiceTests.swift b/BitkitTests/PrivatePaykitServiceTests.swift index 0ea366010..c90abaf7f 100644 --- a/BitkitTests/PrivatePaykitServiceTests.swift +++ b/BitkitTests/PrivatePaykitServiceTests.swift @@ -564,6 +564,54 @@ final class PrivatePaykitServiceTests: XCTestCase { XCTAssertEqual(PrivatePaykitService.pendingDeletedContactCleanupKeys(), [failedPublicKey]) } + func testCleanupFailuresKeepRegistryReconciliationPending() async throws { + let publicKey = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" + UserDefaults.standard.set(false, forKey: PrivatePaykitService.publishingEnabledKey) + for failureStage in ["lookup", "withdraw", "registry"] { + let service = PrivatePaykitService() + var contactState = PrivatePaykitService.ContactState() + contactState.hasPublishedPrivatePaymentList = true + await service.setTestContactState(contactState, publicKey: publicKey) + PrivatePaykitService.markDeletedContactCleanupPending([publicKey]) + PublicPaykitService.setCleanupPending(false) + var shouldFail = true + var registryUpdates = 0 + let operations = PrivatePaykitService.EndpointCleanupOperations( + linkedPeers: { + if shouldFail, failureStage == "lookup" { throw PrivatePaykitError.privateUnavailable } + return [] + }, + clearPaymentList: { _ in + if shouldFail, failureStage == "withdraw" { throw PrivatePaykitError.privateUnavailable } + return PrivatePaymentListDeliveryReport(queued: [], cleared: [], failedToQueue: [], failedToDeliver: []) + }, + drainMessages: { _ in }, + pendingDrainKeys: { _ in [] }, + syncApp: { + registryUpdates += 1 + if shouldFail, failureStage == "registry" { throw PrivatePaykitError.privateUnavailable } + } + ) + + do { + try await service.removePublishedEndpoints(for: [publicKey], operations: operations) + XCTFail("Expected \(failureStage) failure") + } catch { + XCTAssertTrue(PublicPaykitService.isCleanupPending) + } + XCTAssertEqual(registryUpdates, failureStage == "registry" ? 1 : 0) + let retainedState = await service.testContactState(publicKey: publicKey) + XCTAssertEqual(retainedState?.hasPublishedPrivatePaymentList == true, failureStage != "registry") + + shouldFail = false + try await service.removePublishedEndpoints(for: [publicKey], operations: operations) + let clearedState = await service.testContactState(publicKey: publicKey) + XCTAssertFalse(clearedState?.hasPublishedPrivatePaymentList == true) + XCTAssertFalse(PrivatePaykitService.pendingDeletedContactCleanupKeys().contains(publicKey)) + XCTAssertEqual(registryUpdates, failureStage == "registry" ? 2 : 1) + } + } + func testCleanupSkipsNeverLinkedContactsWithoutPublishedDetails() async { let service = PrivatePaykitService() var publishedState = PrivatePaykitService.ContactState() From 1ac9f07fdb6e5cdb01098d55d2030bab1a78fc81 Mon Sep 17 00:00:00 2001 From: benk10 Date: Thu, 1 Oct 2026 12:34:43 -0500 Subject: [PATCH 15/68] docs: align request journey with shared paykit --- journeys/payment-requests/automatic-presentation.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/journeys/payment-requests/automatic-presentation.xml b/journeys/payment-requests/automatic-presentation.xml index 55252a289..df959bbf5 100644 --- a/journeys/payment-requests/automatic-presentation.xml +++ b/journeys/payment-requests/automatic-presentation.xml @@ -1,6 +1,6 @@ - Verifies that a newly received request opens automatically in the foreground, waits for another sheet to close, and does not reopen a request the payer already reviewed. Requires two Bitkit instances saved as each other's contacts and linked on receiver path "bitkit/wallet", with the payer funded for 21,000 sats; see README.md. + Verifies that a newly received request opens automatically in the foreground, waits for another sheet to close, and does not reopen a request the payer already reviewed. Requires two Bitkit instances with separate Pubky identities, saved as each other's contacts and linked, with the payer funded for 21,000 sats; see README.md. On the payer, leave Bitkit unlocked and in the foreground on Home From 61203c8581c47c0afc490d86db79b4401a188fc1 Mon Sep 17 00:00:00 2001 From: benk10 Date: Thu, 1 Oct 2026 20:40:26 +0100 Subject: [PATCH 16/68] fix: retain disabled contact sharing and cache Paykit reads --- Bitkit/Services/ContactPaymentsService.swift | 37 +++++----- Bitkit/Services/PubkyService.swift | 62 ++++++++++++++-- BitkitTests/ContactPaymentsServiceTests.swift | 64 +++++++++++------ .../PaykitBackupStateTrackingTests.swift | 67 +++++++++++++++++ BitkitTests/PaykitContactLifecycleTests.swift | 4 ++ BitkitTests/PaykitSdkClientConfigTests.swift | 71 ++++++++++++++++++- changelog.d/next/856.changed.md | 2 +- journeys/contacts/README.md | 12 ++++ journeys/contacts/contact-payment-sharing.xml | 20 ++++++ 9 files changed, 289 insertions(+), 50 deletions(-) create mode 100644 journeys/contacts/contact-payment-sharing.xml diff --git a/Bitkit/Services/ContactPaymentsService.swift b/Bitkit/Services/ContactPaymentsService.swift index 7b2d34255..a3b7d8b75 100644 --- a/Bitkit/Services/ContactPaymentsService.swift +++ b/Bitkit/Services/ContactPaymentsService.swift @@ -82,6 +82,13 @@ enum ContactPaymentsService { ) async throws { enableAllPaymentOptions(defaults: defaults) + if !enabled { + if let error = await disable(operations: operations, defaults: defaults) { + throw error + } + return + } + let previousState = StoredState( sharesPublicEndpoints: defaults.bool(forKey: PublicPaykitService.publishingEnabledKey), sharesPrivateEndpoints: defaults.bool(forKey: PrivatePaykitService.publishingEnabledKey), @@ -90,18 +97,13 @@ enum ContactPaymentsService { privateCleanupPending: defaults.bool(forKey: PrivatePaykitService.cleanupPendingKey) ) - var privateCleanupError: Error? do { - if enabled { - try await enable( - contactPublicKeys: contactPublicKeys, - canUsePrivatePayments: canUsePrivatePayments, - operations: operations, - defaults: defaults - ) - } else { - privateCleanupError = try await disable(operations: operations, defaults: defaults) - } + try await enable( + contactPublicKeys: contactPublicKeys, + canUsePrivatePayments: canUsePrivatePayments, + operations: operations, + defaults: defaults + ) } catch { await restore( previousState, @@ -112,9 +114,6 @@ enum ContactPaymentsService { ) throw error } - if let privateCleanupError { - throw privateCleanupError - } } @MainActor @@ -150,14 +149,14 @@ enum ContactPaymentsService { } @MainActor - private static func disable(operations: Operations, defaults: UserDefaults) async throws -> Error? { - var privateCleanupError: Error? + private static func disable(operations: Operations, defaults: UserDefaults) async -> Error? { + var cleanupError: Error? do { try await operations.removePrivateEndpoints() operations.setPrivateCleanupPending(false) } catch { operations.setPrivateCleanupPending(true) - privateCleanupError = error + cleanupError = error } defaults.set(false, forKey: PublicPaykitService.publishingEnabledKey) @@ -169,9 +168,9 @@ enum ContactPaymentsService { operations.setPublicCleanupPending(false) } catch { operations.setPublicCleanupPending(true) - throw error + cleanupError = cleanupError ?? error } - return privateCleanupError + return cleanupError } @MainActor diff --git a/Bitkit/Services/PubkyService.swift b/Bitkit/Services/PubkyService.swift index 3b7d65875..6eda6b68e 100644 --- a/Bitkit/Services/PubkyService.swift +++ b/Bitkit/Services/PubkyService.swift @@ -317,6 +317,11 @@ enum PubkyService { actor PaykitSdkService { typealias BootstrapFactory = (String, PubkyClientConfig) throws -> PubkySessionBootstrap + struct BackupStateSnapshot { + let stateRevision: String + let backupRevision: String + } + static let shared = PaykitSdkService() private static let walletBackupDataChangedSubject = PassthroughSubject() @@ -336,6 +341,8 @@ actor PaykitSdkService { private var nextIdentityRepublishAt = Date.distantPast private var lastIdentityRepublishAt = Date.distantPast private var sdk: PaykitSdk? + private var cachedPaykitKey: (publicKey: String, generation: UInt64)? + private var cachedBackupState: BackupStateSnapshot? init( sdkFactory: (() throws -> PaykitSdk)? = nil, @@ -353,7 +360,7 @@ actor PaykitSdkService { } private func initializeLocked() async throws { - try await refreshPaykitKey() + try await refreshPaykitKey(force: true) var sdk = try handle() do { _ = try await sdk.initialize() @@ -1028,6 +1035,9 @@ actor PaykitSdkService { try await withSdk { sdk in return try await Self.withBackupStateRevisionTracking( readRevision: { try await sdk.backupStateRevision() }, + readStateRevision: { try sdk.stateRevision() }, + cachedSnapshot: self.cachedBackupState, + onSnapshot: { self.cachedBackupState = $0 }, onChange: { self.markWalletBackupDataChanged() }, operation: { try await operation(sdk) } ) @@ -1036,17 +1046,42 @@ actor PaykitSdkService { static func withBackupStateRevisionTracking( readRevision: () async throws -> String, + readStateRevision: () throws -> String? = { nil }, + cachedSnapshot: BackupStateSnapshot? = nil, + onSnapshot: (BackupStateSnapshot?) -> Void = { _ in }, onChange: () async -> Void, operation: () async throws -> T ) async throws -> T { - let previousRevision = try? await readRevision() + let observedStateRevision = try? readStateRevision() + let previousRevision: String? = if let cachedSnapshot, observedStateRevision == cachedSnapshot.stateRevision { + cachedSnapshot.backupRevision + } else { + try? await readRevision() + } + let previousStateRevision = try? readStateRevision() let result: Result do { result = try await .success(operation()) } catch { result = .failure(error) } + let nextStateRevision = try? readStateRevision() + if case .success = result, + let previousStateRevision, + previousStateRevision == nextStateRevision, + let previousRevision + { + onSnapshot(BackupStateSnapshot(stateRevision: previousStateRevision, backupRevision: previousRevision)) + return try result.get() + } + + // An unconfirmed remote write can fail without advancing the local revision. let nextRevision = try? await readRevision() + if case .success = result, let stateRevision = try? readStateRevision(), let nextRevision { + onSnapshot(BackupStateSnapshot(stateRevision: stateRevision, backupRevision: nextRevision)) + } else { + onSnapshot(nil) + } if previousRevision == nil || nextRevision == nil || previousRevision != nextRevision { await onChange() } @@ -1059,11 +1094,28 @@ actor PaykitSdkService { private func resetRuntime() { sdk = nil + cachedPaykitKey = nil + cachedBackupState = nil } - private func refreshPaykitKey() async throws { - guard let root = try sessionProvider.loadLocalSecretKey() else { return } - try await sessionProvider.setPaykitIdentitySecretKey(paykitKey(for: root)) + private func refreshPaykitKey(force: Bool = false) async throws { + if force { + cachedPaykitKey = nil + } + guard let root = try sessionProvider.loadLocalSecretKey() else { + cachedPaykitKey = nil + return + } + let publicKey = try Paykit.pubkyPublicKeyFromSecret(localSecretKey: root) + let savedGeneration = try Keychain.load(key: .paykitKeyGeneration(publicKey: publicKey)) + .map { try JSONDecoder().decode(UInt64.self, from: $0) } + if let cachedPaykitKey, cachedPaykitKey.publicKey == publicKey, cachedPaykitKey.generation == savedGeneration { + return + } + cachedPaykitKey = nil + let key = try await paykitKey(for: root) + sessionProvider.setPaykitIdentitySecretKey(key) + cachedPaykitKey = (publicKey, key.keyGeneration()) } func paykitKeyForAuthorization(secretKeyHex: String) async throws -> PaykitIdentitySecretKey { diff --git a/BitkitTests/ContactPaymentsServiceTests.swift b/BitkitTests/ContactPaymentsServiceTests.swift index 8f43c2325..bbac39dac 100644 --- a/BitkitTests/ContactPaymentsServiceTests.swift +++ b/BitkitTests/ContactPaymentsServiceTests.swift @@ -201,16 +201,41 @@ final class ContactPaymentsServiceTests: XCTestCase { } } - func testFailedPrivateDisableReportsFailureAndKeepsContactPaymentsDisabled() async throws { - try await withIsolatedDefaultsAsync { defaults in - defaults.set(true, forKey: PublicPaykitService.publishingEnabledKey) - defaults.set(true, forKey: PrivatePaykitService.publishingEnabledKey) - defaults.set(true, forKey: ContactPaymentsService.confirmedPreferenceKey) - defaults.set(true, forKey: PublicPaykitService.cleanupPendingKey) - let operations = OperationsSpy() - operations.privateRemovalFailures = [1] + func testFailedDisableKeepsContactPaymentsDisabledAndRetriesCleanup() async throws { + for (privateFails, publicFails) in [(true, false), (false, true), (true, true)] { + try await withIsolatedDefaultsAsync { defaults in + defaults.set(true, forKey: PublicPaykitService.publishingEnabledKey) + defaults.set(true, forKey: PrivatePaykitService.publishingEnabledKey) + defaults.set(true, forKey: ContactPaymentsService.confirmedPreferenceKey) + defaults.set(true, forKey: PublicPaykitService.cleanupPendingKey) + let operations = OperationsSpy() + operations.privateRemovalFailures = privateFails ? [1] : [] + operations.publicPublicationFailures = publicFails ? [1] : [] + + do { + try await ContactPaymentsService.setEnabled( + false, + contactPublicKeys: ["contact-a"], + canUsePrivatePayments: true, + operations: operations.makeOperations(), + defaults: defaults + ) + XCTFail("Expected endpoint cleanup to fail") + } catch { + XCTAssertEqual(error as? TestError, .operationFailed) + } + + XCTAssertEqual(operations.publicPublicationValues, [false]) + XCTAssertEqual(operations.privateRemovalCount, 1) + XCTAssertTrue(operations.privatePublications.isEmpty) + XCTAssertEqual(operations.calls, ["private:remove", "public:false"]) + XCTAssertEqual(operations.publicCleanupValues, [publicFails]) + XCTAssertEqual(operations.privateCleanupValues, [privateFails]) + XCTAssertFalse(defaults.bool(forKey: PublicPaykitService.publishingEnabledKey)) + XCTAssertFalse(defaults.bool(forKey: PrivatePaykitService.publishingEnabledKey)) + XCTAssertTrue(defaults.bool(forKey: ContactPaymentsService.confirmedPreferenceKey)) + XCTAssertFalse(ContactPaymentsService.isEnabled(defaults: defaults)) - do { try await ContactPaymentsService.setEnabled( false, contactPublicKeys: ["contact-a"], @@ -218,21 +243,14 @@ final class ContactPaymentsServiceTests: XCTestCase { operations: operations.makeOperations(), defaults: defaults ) - XCTFail("Expected private endpoint removal to fail") - } catch { - XCTAssertEqual(error as? TestError, .operationFailed) - } - XCTAssertEqual(operations.publicPublicationValues, [false]) - XCTAssertEqual(operations.privateRemovalCount, 1) - XCTAssertTrue(operations.privatePublications.isEmpty) - XCTAssertEqual(operations.calls, ["private:remove", "public:false"]) - XCTAssertEqual(operations.publicCleanupValues, [false]) - XCTAssertEqual(operations.privateCleanupValues, [true]) - XCTAssertFalse(defaults.bool(forKey: PublicPaykitService.publishingEnabledKey)) - XCTAssertFalse(defaults.bool(forKey: PrivatePaykitService.publishingEnabledKey)) - XCTAssertTrue(defaults.bool(forKey: ContactPaymentsService.confirmedPreferenceKey)) - XCTAssertFalse(ContactPaymentsService.isEnabled(defaults: defaults)) + XCTAssertEqual(operations.publicPublicationValues, [false, false]) + XCTAssertEqual(operations.privateRemovalCount, 2) + XCTAssertTrue(operations.privatePublications.isEmpty) + XCTAssertEqual(operations.publicCleanupValues, [publicFails, false]) + XCTAssertEqual(operations.privateCleanupValues, [privateFails, false]) + XCTAssertFalse(ContactPaymentsService.isEnabled(defaults: defaults)) + } } } diff --git a/BitkitTests/PaykitBackupStateTrackingTests.swift b/BitkitTests/PaykitBackupStateTrackingTests.swift index 17c096bcb..f17202d68 100644 --- a/BitkitTests/PaykitBackupStateTrackingTests.swift +++ b/BitkitTests/PaykitBackupStateTrackingTests.swift @@ -75,4 +75,71 @@ final class PaykitBackupStateTrackingTests: XCTestCase { XCTAssertEqual(revision, "after") XCTAssertEqual(changes, 1) } + + func testUnchangedOperationsReuseBackupFingerprintWithoutRemoteReads() async throws { + var snapshot: PaykitSdkService.BackupStateSnapshot? + var reads = 0 + var changes = 0 + for _ in 0 ..< 3 { + try await PaykitSdkService.withBackupStateRevisionTracking( + readRevision: { reads += 1; return "content" }, + readStateRevision: { "state" }, + cachedSnapshot: snapshot, + onSnapshot: { snapshot = $0 }, + onChange: { changes += 1 }, + operation: {} + ) + } + XCTAssertEqual(reads, 1) + XCTAssertEqual(changes, 0) + XCTAssertEqual(snapshot?.backupRevision, "content") + } + + func testStorageRevisionChangesStillCompareBackupContent() async throws { + for (cachedState, finalContent, expectedReads, expectedChanges) in [ + ("before", "content", 1, 0), + ("before", "changed", 1, 1), + ("stale", "changed", 2, 1), + ] { + var state = "before" + var content = "content" + var reads = 0 + var changes = 0 + var snapshot: PaykitSdkService.BackupStateSnapshot? + try await PaykitSdkService.withBackupStateRevisionTracking( + readRevision: { reads += 1; return content }, + readStateRevision: { state }, + cachedSnapshot: .init(stateRevision: cachedState, backupRevision: "content"), + onSnapshot: { snapshot = $0 }, + onChange: { changes += 1 }, + operation: { state = "after"; content = finalContent } + ) + XCTAssertEqual(reads, expectedReads) + XCTAssertEqual(changes, expectedChanges) + XCTAssertEqual(snapshot?.stateRevision, "after") + XCTAssertEqual(snapshot?.backupRevision, finalContent) + } + } + + func testFailedWriteChecksBackupDespiteUnchangedLocalRevision() async { + var snapshot: PaykitSdkService.BackupStateSnapshot? = .init(stateRevision: "state", backupRevision: "before") + var reads = 0 + var changes = 0 + do { + try await PaykitSdkService.withBackupStateRevisionTracking( + readRevision: { reads += 1; return "after" }, + readStateRevision: { "state" }, + cachedSnapshot: snapshot, + onSnapshot: { snapshot = $0 }, + onChange: { changes += 1 }, + operation: { throw Failure.operation } + ) + XCTFail("Expected unconfirmed write failure") + } catch { + XCTAssertEqual(error as? Failure, .operation) + } + XCTAssertEqual(reads, 1) + XCTAssertEqual(changes, 1) + XCTAssertNil(snapshot) + } } diff --git a/BitkitTests/PaykitContactLifecycleTests.swift b/BitkitTests/PaykitContactLifecycleTests.swift index 53a449d95..72f1f782c 100644 --- a/BitkitTests/PaykitContactLifecycleTests.swift +++ b/BitkitTests/PaykitContactLifecycleTests.swift @@ -270,6 +270,10 @@ private final class ContactLifecycleSdk: PaykitSdk, @unchecked Sendable { "revision" } + override func stateRevision() throws -> String? { + nil + } + override func paymentRequests() async throws -> [PaymentRequestRecord] { requests } diff --git a/BitkitTests/PaykitSdkClientConfigTests.swift b/BitkitTests/PaykitSdkClientConfigTests.swift index 318fd1e49..0d2fd4a7e 100644 --- a/BitkitTests/PaykitSdkClientConfigTests.swift +++ b/BitkitTests/PaykitSdkClientConfigTests.swift @@ -13,8 +13,11 @@ final class PaykitSdkClientConfigTests: XCTestCase { let saved = try keys.map { try Keychain.load(key: $0) } defer { for (key, data) in zip(keys, saved) { - if let data { try? Keychain.upsert(key: key, data: data) } - else { try? Keychain.delete(key: key) } + if let data { + try? Keychain.upsert(key: key, data: data) + } else { + try? Keychain.delete(key: key) + } } } let bootstrap = CacheActivationBootstrap(noPointer: .init()) @@ -115,6 +118,62 @@ final class PaykitSdkClientConfigTests: XCTestCase { } } + @MainActor + func testSessionKeyCacheRefreshesForIdentityGenerationAndRuntimeChanges() async throws { + let secrets = [String(repeating: "21", count: 32), String(repeating: "22", count: 32)] + let publicKeys = try secrets.map { try PubkyProfileManager.publicKeyFromSecretKey($0) } + let keys: [KeychainEntryType] = [.pubkySecretKey] + publicKeys.map { .paykitKeyGeneration(publicKey: $0) } + let saved = try keys.map { try Keychain.load(key: $0) } + let savedReference = AdoptedPubkyReference.current + defer { + AdoptedPubkyReference.current = savedReference + for (key, value) in zip(keys, saved) { + if let value { + try? Keychain.upsert(key: key, data: value) + } else { + try? Keychain.delete(key: key) + } + } + } + AdoptedPubkyReference.current = nil + for key in keys { + try Keychain.delete(key: key) + } + try Keychain.upsert(key: .pubkySecretKey, data: Data(secrets[0].utf8)) + let sdk = CacheActivationSdk(noPointer: .init()) + let service = PaykitSdkService(sdkFactory: { sdk }) { _, _ in CacheActivationBootstrap(noPointer: .init()) } + + _ = try await service.contactRecords() + _ = try await service.contactRecords() + XCTAssertEqual(sdk.registryPublicKeys, [publicKeys[0]]) + + sdk.registry = PaykitAppRegistry(keyGeneration: 2, noisePublicKey: nil, apps: [], defaultAppId: nil, defaultAppsByEndpoint: [:]) + let authorizationKey = try await service.paykitKeyForAuthorization(secretKeyHex: secrets[0]) + XCTAssertEqual(authorizationKey.keyGeneration(), 2) + _ = try await service.contactRecords() + XCTAssertEqual(sdk.registryPublicKeys.count, 3) + + try Keychain.upsert(key: .pubkySecretKey, data: Data(secrets[1].utf8)) + _ = try await service.contactRecords() + XCTAssertEqual(sdk.registryPublicKeys.last, publicKeys[1]) + XCTAssertEqual(sdk.registryPublicKeys.count, 4) + + await service.clearState() + _ = try await service.contactRecords() + XCTAssertEqual(sdk.registryPublicKeys.count, 5) + + sdk.registryError = PubkyServiceError.authFailed("Registry unavailable") + do { + try await service.initialize() + XCTFail("Forced validation must report the registry failure") + } catch {} + do { + _ = try await service.contactRecords() + XCTFail("Failed forced validation must not reuse the previous key cache") + } catch {} + XCTAssertEqual(sdk.registryPublicKeys.count, 7) + } + func testIdentityReadFailurePreservesSavedCredentials() async throws { let keys: [KeychainEntryType] = [.paykitSession, .pubkySecretKey] let saved = try keys.map { try Keychain.load(key: $0) } @@ -487,6 +546,10 @@ private final class CacheActivationSdk: PaykitSdk, @unchecked Sendable { } return IdentityStatus(publicKey: previousKey, capability: .signedOut) } + + override func contactRecords() async throws -> [ContactRecord] { + [] + } } private final class CacheActivationBootstrap: PubkySessionBootstrap, @unchecked Sendable { @@ -543,6 +606,10 @@ private final class RecoverySdk: PaykitSdk, @unchecked Sendable { "unchanged" } + override func stateRevision() throws -> String? { + nil + } + override func forgetSessionAccess() async throws -> IdentityStatus { onForget() try Keychain.delete(key: .paykitSession) diff --git a/changelog.d/next/856.changed.md b/changelog.d/next/856.changed.md index c6c333754..dcc96142d 100644 --- a/changelog.d/next/856.changed.md +++ b/changelog.d/next/856.changed.md @@ -1 +1 @@ -Share Paykit contacts and payment state with authorized apps while keeping wallet keys private, keep accepted one-time requests payable only on the accepting install, and label received payments with their Paykit payer contact. +Share Paykit contacts and payment state with authorized apps while keeping wallet keys private, preserve disabled contact sharing during cleanup failures, reduce repeated shared-state reads, and label received payments with their Paykit payer contact. diff --git a/journeys/contacts/README.md b/journeys/contacts/README.md index 0ba951396..26a8e7ed9 100644 --- a/journeys/contacts/README.md +++ b/journeys/contacts/README.md @@ -17,3 +17,15 @@ This controlled fixture is separate from the identity-backed journeys above. Repeat both import journeys with an identity that follows itself. The preview count and selection list must exclude your own profile, while other followed profiles can still be imported. Your own profile must also be absent from the saved contacts. + +## Contact payment sharing + +`contact-payment-sharing.xml` checks disabling sharing and keeping it off after returning to +Settings. It is an iOS journey for the contact-payment preference. + +Network and storage fault injection are outside journey-runner capabilities. With contact sharing +on, make one private-list withdrawal fail and let the following public endpoint or app-registry +update fail as well. Turn off contact payments. The app must report the failure, keep the toggle off, +and retain both cleanup jobs without republishing cleared private lists. Restore connectivity and +foreground the app. Cleanup must finish while sharing stays off. Repeat with only the trailing +public endpoint or app-registry update failing. diff --git a/journeys/contacts/contact-payment-sharing.xml b/journeys/contacts/contact-payment-sharing.xml new file mode 100644 index 000000000..afd3a3aeb --- /dev/null +++ b/journeys/contacts/contact-payment-sharing.xml @@ -0,0 +1,20 @@ + + + Verifies the contact-payment preference stays off when leaving and returning to Settings. + Requires an authenticated disposable Pubky identity with contact payments enabled and a saved, + linked contact. This iOS journey covers the preference. Cleanup failure injection is a manual + check because network and storage fault injection are not journey capabilities, as documented + in README.md. Use only one active install of this wallet. + + + Open the menu and tap Settings (id "DrawerSettings") + Open the General tab (id "Tab-general") + Verify the contact payments toggle (id "ContactPaymentsToggle") is on + Tap the contact payments toggle (id "ContactPaymentsToggle") + Wait for the update to finish and verify the contact payments toggle is off + Return to the wallet home screen + Open the menu and tap Settings (id "DrawerSettings") + Open the General tab (id "Tab-general") + Verify the contact payments toggle (id "ContactPaymentsToggle") is still off + + From 05c3234dadef234e5c99d058447e4c6b3c982401 Mon Sep 17 00:00:00 2001 From: benk10 Date: Thu, 1 Oct 2026 16:47:40 -0500 Subject: [PATCH 17/68] fix: refresh cached Paykit keys after identity failures --- Bitkit/Services/PubkyService.swift | 33 +++- BitkitTests/PaykitSdkClientConfigTests.swift | 182 +++++++++++++++++- journeys/contacts/README.md | 2 +- journeys/contacts/contact-payment-sharing.xml | 6 +- 4 files changed, 212 insertions(+), 11 deletions(-) diff --git a/Bitkit/Services/PubkyService.swift b/Bitkit/Services/PubkyService.swift index 6eda6b68e..58fa569e4 100644 --- a/Bitkit/Services/PubkyService.swift +++ b/Bitkit/Services/PubkyService.swift @@ -365,6 +365,7 @@ actor PaykitSdkService { do { _ = try await sdk.initialize() } catch { + invalidatePaykitKeyIfNeeded(after: error) guard try sessionProvider.canDeferStaleSession(error: error) else { throw error } Logger.warn("Deferring stale Paykit session restoration until SDK setup completes", context: "PaykitSdkService") @@ -656,6 +657,7 @@ actor PaykitSdkService { do { _ = try await sdk.blockPeer(counterparty: peer.counterparty) } catch { + invalidatePaykitKeyIfNeeded(after: error) Logger.error("Failed to restore peer block after contact save failed: \(error)", context: "PaykitSdkService") } } @@ -693,6 +695,7 @@ actor PaykitSdkService { } catch is CancellationError { throw CancellationError() } catch { + invalidatePaykitKeyIfNeeded(after: error) Logger.warn("Failed to withdraw private endpoints before contact deletion: \(error)", context: "PaykitSdkService") } } @@ -1025,16 +1028,33 @@ actor PaykitSdkService { private func withSdk(_ operation: (PaykitSdk) async throws -> T) async throws -> T { try await operationLock.withLock { - try await refreshPaykitKey() - let sdk = try handle() - return try await operation(sdk) + try await withSdkErrorHandling { + try await refreshPaykitKey() + return try await operation(handle()) + } } } + private func withSdkErrorHandling(_ operation: () async throws -> T) async throws -> T { + do { + return try await operation() + } catch { + invalidatePaykitKeyIfNeeded(after: error) + throw error + } + } + + private func invalidatePaykitKeyIfNeeded(after error: Error) { + guard case PaykitError.Identity = error else { return } + // Refresh on the next authenticated operation; never replay a possibly completed write or lower the generation floor. + cachedPaykitKey = nil + cachedBackupState = nil + } + private func withStateRevisionTracking(_ operation: (PaykitSdk) async throws -> T) async throws -> T { try await withSdk { sdk in return try await Self.withBackupStateRevisionTracking( - readRevision: { try await sdk.backupStateRevision() }, + readRevision: { try await self.withSdkErrorHandling { try await sdk.backupStateRevision() } }, readStateRevision: { try sdk.stateRevision() }, cachedSnapshot: self.cachedBackupState, onSnapshot: { self.cachedBackupState = $0 }, @@ -1120,7 +1140,9 @@ actor PaykitSdkService { func paykitKeyForAuthorization(secretKeyHex: String) async throws -> PaykitIdentitySecretKey { try await operationLock.withLock { - try await paykitKey(for: Self.localSecretKey(fromHex: secretKeyHex)) + try await withSdkErrorHandling { + try await paykitKey(for: Self.localSecretKey(fromHex: secretKeyHex)) + } } } @@ -1202,6 +1224,7 @@ actor PaykitSdkService { capabilities.privatePayments = UserDefaults.standard.bool(forKey: PrivatePaykitService.publishingEnabledKey) _ = try await sdk.publishPaykitApp(displayName: "Bitkit", capabilities: capabilities) } catch { + invalidatePaykitKeyIfNeeded(after: error) Logger.warn("Failed to publish Paykit app: \(error)", context: "PaykitSdkService") } } diff --git a/BitkitTests/PaykitSdkClientConfigTests.swift b/BitkitTests/PaykitSdkClientConfigTests.swift index 0d2fd4a7e..bb17b03b2 100644 --- a/BitkitTests/PaykitSdkClientConfigTests.swift +++ b/BitkitTests/PaykitSdkClientConfigTests.swift @@ -174,6 +174,156 @@ final class PaykitSdkClientConfigTests: XCTestCase { XCTAssertEqual(sdk.registryPublicKeys.count, 7) } + @MainActor + func testSessionKeyCacheRefreshesAfterRemoteGenerationFailureWithoutReplayingOperations() async throws { + let operations: [(PaykitSdkService) async throws -> Void] = [ + { _ = try await $0.contactRecords() }, + { _ = try await $0.cancelPaymentRequest(counterparty: "peer", paymentRequestId: "request") }, + ] + for operation in operations { + try await withCachedSessionKey { service, sdk, generationKey in + sdk.registry = PaykitAppRegistry(keyGeneration: 3, noisePublicKey: nil, apps: [], defaultAppId: nil, defaultAppsByEndpoint: [:]) + sdk.operationError = PaykitError.Identity( + code: "identity_error", + context: "Paykit key generation 1 does not match shared-state generation 3" + ) + do { + try await operation(service) + XCTFail("The failed operation must not be retried") + } catch { + XCTAssertEqual(String(describing: error), String(describing: sdk.operationError!)) + } + XCTAssertEqual(sdk.operationCalls, 2) + XCTAssertEqual(sdk.registryPublicKeys.count, 1) + XCTAssertEqual(try Keychain.load(key: generationKey), try JSONEncoder().encode(UInt64(1))) + + sdk.operationError = nil + _ = try await service.contactRecords() + XCTAssertEqual(sdk.registryPublicKeys.count, 2) + XCTAssertEqual(try Keychain.load(key: generationKey), try JSONEncoder().encode(UInt64(3))) + _ = try await service.contactRecords() + XCTAssertEqual(sdk.registryPublicKeys.count, 2) + } + } + } + + @MainActor + func testRemoteGenerationRecoveryPreservesFloorWhenRegistryIsStaleMissingOrUnavailable() async throws { + try await withCachedSessionKey { service, sdk, generationKey in + for generation: UInt64 in [3, 2] { + sdk.operationError = PaykitError.Identity(code: "identity_error", context: "Paykit key generation mismatch") + do { + _ = try await service.contactRecords() + XCTFail("Expected an identity failure") + } catch {} + sdk.operationError = nil + sdk.registry = PaykitAppRegistry( + keyGeneration: generation, + noisePublicKey: nil, + apps: [], + defaultAppId: nil, + defaultAppsByEndpoint: [:] + ) + if generation == 3 { + _ = try await service.contactRecords() + continue + } + let previousCalls = sdk.operationCalls + for attempt in 0 ..< 3 { + if attempt > 0 { sdk.registry = nil } + if attempt == 2 { sdk.registryError = PaykitError.Storage(code: "registry_unavailable", context: "unavailable") } + do { + _ = try await service.contactRecords() + XCTFail("An invalid registry must not reuse the cached key") + } catch let PaykitError.Identity(code, _) { + XCTAssertEqual(code, "stale_paykit_key_generation") + } catch let PaykitError.Storage(code, _) { + XCTAssertEqual(code, "registry_unavailable") + } + XCTAssertEqual(sdk.operationCalls, previousCalls) + XCTAssertEqual(try Keychain.load(key: generationKey), try JSONEncoder().encode(UInt64(3))) + } + XCTAssertEqual(sdk.registryPublicKeys.count, 5) + sdk.registryError = nil + sdk.registry = PaykitAppRegistry(keyGeneration: 4, noisePublicKey: nil, apps: [], defaultAppId: nil, defaultAppsByEndpoint: [:]) + _ = try await service.contactRecords() + XCTAssertEqual(try Keychain.load(key: generationKey), try JSONEncoder().encode(UInt64(4))) + } + } + } + + @MainActor + func testUnrelatedSdkFailuresKeepSessionKeyCache() async throws { + try await withCachedSessionKey { service, sdk, _ in + for failure in [PaykitError.Storage(code: "unavailable", context: "offline"), CancellationError()] as [Error] { + sdk.operationError = failure + do { + _ = try await service.contactRecords() + XCTFail("Expected the operation failure") + } catch { + XCTAssertEqual(String(describing: error), String(describing: failure)) + } + sdk.operationError = nil + _ = try await service.contactRecords() + XCTAssertEqual(sdk.registryPublicKeys.count, 1) + } + } + } + + @MainActor + func testBestEffortSdkIdentityFailuresInvalidateSessionKeyCache() async throws { + for publicationFails in [true, false] { + try await withCachedSessionKey { service, sdk, generationKey in + let failure = PaykitError.Identity(code: "identity_error", context: "Paykit key generation mismatch") + sdk.capability = .privateLinkCapable + if publicationFails { + sdk.publicationError = failure + try await service.initialize() + } else { + sdk.backupError = failure + try await service.syncPaykitApp(privatePaymentsEnabled: true) + } + XCTAssertEqual(sdk.publicationCalls, 1) + let previousReads = sdk.registryPublicKeys.count + sdk.registry = PaykitAppRegistry(keyGeneration: 2, noisePublicKey: nil, apps: [], defaultAppId: nil, defaultAppsByEndpoint: [:]) + sdk.publicationError = nil + sdk.backupError = nil + _ = try await service.contactRecords() + XCTAssertEqual(sdk.registryPublicKeys.count, previousReads + 1) + XCTAssertEqual(try Keychain.load(key: generationKey), try JSONEncoder().encode(UInt64(2))) + } + } + } + + @MainActor + private func withCachedSessionKey( + _ operation: (PaykitSdkService, CacheActivationSdk, KeychainEntryType) async throws -> Void + ) async throws { + let secret = String(repeating: "23", count: 32) + let publicKey = try PubkyProfileManager.publicKeyFromSecretKey(secret) + let generationKey = KeychainEntryType.paykitKeyGeneration(publicKey: publicKey) + let keys: [KeychainEntryType] = [.pubkySecretKey, generationKey] + let saved = try keys.map { try Keychain.load(key: $0) } + let savedReference = AdoptedPubkyReference.current + defer { + AdoptedPubkyReference.current = savedReference + for (key, value) in zip(keys, saved) { + if let value { + try? Keychain.upsert(key: key, data: value) + } else { + try? Keychain.delete(key: key) + } + } + } + AdoptedPubkyReference.current = nil + try Keychain.delete(key: generationKey) + try Keychain.upsert(key: .pubkySecretKey, data: Data(secret.utf8)) + let sdk = CacheActivationSdk(noPointer: .init()) + let service = PaykitSdkService(sdkFactory: { sdk }) { _, _ in CacheActivationBootstrap(noPointer: .init()) } + _ = try await service.contactRecords() + try await operation(service, sdk, generationKey) + } + func testIdentityReadFailurePreservesSavedCredentials() async throws { let keys: [KeychainEntryType] = [.paykitSession, .pubkySecretKey] let saved = try keys.map { try Keychain.load(key: $0) } @@ -520,14 +670,20 @@ private final class UnavailableProfileManager: PubkyProfileManager { private final class CacheActivationSdk: PaykitSdk, @unchecked Sendable { var previousKey: String? + var capability: PubkyIdentityCapability = .signedOut var registry: PaykitAppRegistry? var registryPublicKeys: [String] = [] var registryError: Error? var initializationError: Error? var activationEvents: [String] = [] + var operationError: Error? + var operationCalls = 0 + var publicationError: Error? + var publicationCalls = 0 + var backupError: Error? override func identityStatus() async throws -> IdentityStatus? { - IdentityStatus(publicKey: previousKey, capability: .signedOut) + IdentityStatus(publicKey: previousKey, capability: capability) } override func paykitAppRegistry(publicKey: String) async throws -> PaykitAppRegistry? { @@ -548,7 +704,29 @@ private final class CacheActivationSdk: PaykitSdk, @unchecked Sendable { } override func contactRecords() async throws -> [ContactRecord] { - [] + operationCalls += 1 + if let operationError { throw operationError } + return [] + } + + override func cancelPaymentRequest(counterparty _: String, paymentRequestId _: String, reason _: String?) async throws -> PaymentRequestRecord { + operationCalls += 1 + throw operationError ?? PubkyServiceError.sessionNotActive + } + + override func publishPaykitApp(displayName _: String, capabilities _: PaykitAppCapabilities) async throws -> PaykitAppRegistry { + publicationCalls += 1 + if let publicationError { throw publicationError } + return registry ?? PaykitAppRegistry(keyGeneration: 1, noisePublicKey: nil, apps: [], defaultAppId: nil, defaultAppsByEndpoint: [:]) + } + + override func backupStateRevision() async throws -> String { + if let backupError { throw backupError } + return "unchanged" + } + + override func stateRevision() throws -> String? { + "state" } } diff --git a/journeys/contacts/README.md b/journeys/contacts/README.md index 26a8e7ed9..e5d65e372 100644 --- a/journeys/contacts/README.md +++ b/journeys/contacts/README.md @@ -21,7 +21,7 @@ profile must also be absent from the saved contacts. ## Contact payment sharing `contact-payment-sharing.xml` checks disabling sharing and keeping it off after returning to -Settings. It is an iOS journey for the contact-payment preference. +Settings. Network and storage fault injection are outside journey-runner capabilities. With contact sharing on, make one private-list withdrawal fail and let the following public endpoint or app-registry diff --git a/journeys/contacts/contact-payment-sharing.xml b/journeys/contacts/contact-payment-sharing.xml index afd3a3aeb..f9272f688 100644 --- a/journeys/contacts/contact-payment-sharing.xml +++ b/journeys/contacts/contact-payment-sharing.xml @@ -2,9 +2,9 @@ Verifies the contact-payment preference stays off when leaving and returning to Settings. Requires an authenticated disposable Pubky identity with contact payments enabled and a saved, - linked contact. This iOS journey covers the preference. Cleanup failure injection is a manual - check because network and storage fault injection are not journey capabilities, as documented - in README.md. Use only one active install of this wallet. + linked contact. Cleanup failure injection is a manual check because network and storage fault + injection are not journey capabilities, as documented in README.md. Use only one active install + of this wallet. Open the menu and tap Settings (id "DrawerSettings") From ef1f2ab379dd6018bcfa372448f96411f7da2821 Mon Sep 17 00:00:00 2001 From: benk10 Date: Fri, 2 Oct 2026 06:26:03 -0500 Subject: [PATCH 18/68] fix: keep private message sync off frequent request polls --- Bitkit/AppScene.swift | 2 +- .../PaykitPaymentRequestService.swift | 44 ++++++++----- .../PaykitPaymentRequestServiceTests.swift | 66 +++++++++++++++++++ journeys/payment-requests/README.md | 6 ++ .../automatic-presentation.xml | 2 +- 5 files changed, 101 insertions(+), 19 deletions(-) diff --git a/Bitkit/AppScene.swift b/Bitkit/AppScene.swift index dfbbc98da..c8cac3949 100644 --- a/Bitkit/AppScene.swift +++ b/Bitkit/AppScene.swift @@ -1130,7 +1130,7 @@ struct AppScene: View { await PaykitPaymentProofService.shared.reconcile() } guard let identity = pubkyProfile.publicKey else { return } - await paykitPaymentRequestManager.refresh() + await paykitPaymentRequestManager.refresh(syncPrivateMessages: refreshMaintenance) guard pubkyProfile.authState == .authenticated, PubkyPublicKeyFormat.matches(identity, pubkyProfile.publicKey) else { return } diff --git a/Bitkit/Services/PaykitPaymentRequestService.swift b/Bitkit/Services/PaykitPaymentRequestService.swift index b0b8dfae0..6417d0740 100644 --- a/Bitkit/Services/PaykitPaymentRequestService.swift +++ b/Bitkit/Services/PaykitPaymentRequestService.swift @@ -545,10 +545,12 @@ struct PaykitPaymentRequestService { self.logWarning = logWarning } - func synchronize() async throws -> PaykitPaymentRequestSnapshot { - try await processPendingMessages() - let intakeReports = try await sdk.receivePrivateMessagesFromLinkedPeers() - logIntakeFailures(intakeReports) + func synchronize(syncPrivateMessages: Bool = true) async throws -> PaykitPaymentRequestSnapshot { + if syncPrivateMessages { + try await processPendingMessages() + let intakeReports = try await sdk.receivePrivateMessagesFromLinkedPeers() + logIntakeFailures(intakeReports) + } let synchronizationDate = now() let sharedRecords = try await sdk.sharedPaymentRequests() let records = sharedRecords.filter(PaykitSdkService.isBitkitPaymentRequest) @@ -1128,7 +1130,7 @@ final class PaykitPaymentRequestManager { private var expiredRequestedPresentations: [PaykitPaymentRequest] = [] private var unavailableRequestedPresentations: [PaykitPaymentRequest] = [] private var isPresentingRequests = false - private var refreshTask: Task? + private var refreshTask: (syncPrivateMessages: Bool, task: Task)? private var expirationTask: Task? private var presentationRetryTask: Task? private var refreshGeneration = 0 @@ -1480,8 +1482,8 @@ final class PaykitPaymentRequestManager { return subscription } - func refresh() async { - await refresh(excludingProtectedRequestId: nil) + func refresh(syncPrivateMessages: Bool = true) async { + await refresh(excludingProtectedRequestId: nil, syncPrivateMessages: syncPrivateMessages) } func synchronizeSubscriptionNotifications(enabled: Bool) async { @@ -1496,9 +1498,13 @@ final class PaykitPaymentRequestManager { ) } - private func refresh(excludingProtectedRequestId: PaykitPaymentRequest.ID?) async { + private func refresh(excludingProtectedRequestId: PaykitPaymentRequest.ID?, syncPrivateMessages: Bool = true) async { if let refreshTask { - await refreshTask.value + let generation = stateGeneration + await refreshTask.task.value + if syncPrivateMessages, !refreshTask.syncPrivateMessages, generation == stateGeneration, !Task.isCancelled { + await refresh(excludingProtectedRequestId: excludingProtectedRequestId, syncPrivateMessages: true) + } return } @@ -1506,13 +1512,16 @@ final class PaykitPaymentRequestManager { let generation = refreshGeneration let task = Task { [weak self] in guard let self else { return } - await performRefresh(generation: generation, excludingProtectedRequestId: excludingProtectedRequestId) + await performRefresh( + generation: generation, + excludingProtectedRequestId: excludingProtectedRequestId, + syncPrivateMessages: syncPrivateMessages + ) + guard generation == refreshGeneration else { return } + refreshTask = nil } - refreshTask = task + refreshTask = (syncPrivateMessages, task) await task.value - - guard generation == refreshGeneration else { return } - refreshTask = nil } func ensurePaymentAllowed(_ request: PaykitPaymentRequest) async throws { @@ -2030,10 +2039,11 @@ final class PaykitPaymentRequestManager { private func performRefresh( generation: Int, - excludingProtectedRequestId: PaykitPaymentRequest.ID? + excludingProtectedRequestId: PaykitPaymentRequest.ID?, + syncPrivateMessages: Bool ) async { do { - let snapshot = try await service.synchronize() + let snapshot = try await service.synchronize(syncPrivateMessages: syncPrivateMessages) guard generation == refreshGeneration, let activeIdentity else { return } async let completedProofKinds = completedPaymentProofKinds(activeIdentity) async let inFlightRequestIds = inFlightPaymentRequestIds(activeIdentity) @@ -2251,7 +2261,7 @@ final class PaykitPaymentRequestManager { private func invalidateRefresh() { refreshGeneration += 1 - refreshTask?.cancel() + refreshTask?.task.cancel() refreshTask = nil } diff --git a/BitkitTests/PaykitPaymentRequestServiceTests.swift b/BitkitTests/PaykitPaymentRequestServiceTests.swift index 2b6585808..ce4d59e78 100644 --- a/BitkitTests/PaykitPaymentRequestServiceTests.swift +++ b/BitkitTests/PaykitPaymentRequestServiceTests.swift @@ -9,6 +9,72 @@ import XCTest @MainActor final class PaykitPaymentRequestServiceTests: XCTestCase { + func testSharedStateRefreshSkipsPrivateMessages() async throws { + let record = try paymentRequestRecord() + let sdk = PaymentRequestSdkMock(records: [record]) + let manager = paymentRequestManager(sdk: sdk) + + await manager.refresh(syncPrivateMessages: false) + + XCTAssertEqual(manager.pendingRequests.map(\.paymentRequestId), [record.paymentRequestId]) + var snapshot = await sdk.snapshot() + XCTAssertEqual(snapshot.processCallCount, 0) + XCTAssertEqual(snapshot.receiveCallCount, 0) + + await manager.refresh() + + snapshot = await sdk.snapshot() + XCTAssertEqual(snapshot.processCallCount, 1) + XCTAssertEqual(snapshot.receiveCallCount, 1) + } + + func testFullRefreshAfterOverlappingSharedStateRefreshReceivesMessages() async throws { + let sdk = PaymentRequestSdkMock(records: []) + let manager = paymentRequestManager(sdk: sdk) + await sdk.pauseNextPaymentRequestList() + let sharedStateRefresh = Task { await manager.refresh(syncPrivateMessages: false) } + try await waitUntil { await sdk.paymentRequestListIsPaused() } + let fullRefreshStarted = expectation(description: "Full refresh started") + let fullRefresh = Task { + fullRefreshStarted.fulfill() + await manager.refresh() + } + await fulfillment(of: [fullRefreshStarted], timeout: 1) + + await sdk.resumePaymentRequestList() + await sharedStateRefresh.value + await fullRefresh.value + + let snapshot = await sdk.snapshot() + XCTAssertEqual(snapshot.processCallCount, 1) + XCTAssertEqual(snapshot.receiveCallCount, 1) + } + + func testClearInvalidatesFullRefreshWaitingForSharedState() async throws { + let sdk = try PaymentRequestSdkMock(records: [paymentRequestRecord()]) + let manager = paymentRequestManager(sdk: sdk) + await sdk.pauseNextPaymentRequestList() + let sharedStateRefresh = Task { await manager.refresh(syncPrivateMessages: false) } + try await waitUntil { await sdk.paymentRequestListIsPaused() } + let fullRefreshStarted = expectation(description: "Full refresh started") + let fullRefresh = Task { + fullRefreshStarted.fulfill() + await manager.refresh() + } + await fulfillment(of: [fullRefreshStarted], timeout: 1) + + manager.clear() + await sdk.resumePaymentRequestList() + await sharedStateRefresh.value + await fullRefresh.value + + XCTAssertTrue(manager.pendingRequests.isEmpty) + XCTAssertTrue(manager.historyRequests.isEmpty) + let snapshot = await sdk.snapshot() + XCTAssertEqual(snapshot.processCallCount, 0) + XCTAssertEqual(snapshot.receiveCallCount, 0) + } + func testReceivedPaymentContactsIncludeSharedServerRequestsAndLatePayments() throws { let payer = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" var record = try receivedPaymentRecord(counterparty: payer) diff --git a/journeys/payment-requests/README.md b/journeys/payment-requests/README.md index ae5781b9c..020822a9a 100644 --- a/journeys/payment-requests/README.md +++ b/journeys/payment-requests/README.md @@ -29,6 +29,12 @@ The source wallet-leg run completed this path on regtest on 2026-08-22: Bitkit p That run established the issuer shapes captured by the fixture: lowercase `btc`, `btc-regtest-p2wpkh`, and a JSON object endpoint payload with a non-empty string `value`. The exact Debug binary SHA was not recorded, so the canonical fixture tests lock the same production gates on the current code. +## Foreground synchronization + +Bitkit refreshes shared request state every 10 seconds. Private messages are synchronized on +startup and explicit refreshes, and on maintenance rounds that back off from 30 to 60 to 120 seconds. +A new peer message can therefore take up to two minutes plus synchronization time to appear during steady polling. + ## Accepting install Acceptance intent is saved before the remote operation and included in wallet backups. diff --git a/journeys/payment-requests/automatic-presentation.xml b/journeys/payment-requests/automatic-presentation.xml index df959bbf5..526914be9 100644 --- a/journeys/payment-requests/automatic-presentation.xml +++ b/journeys/payment-requests/automatic-presentation.xml @@ -5,7 +5,7 @@ On the payer, leave Bitkit unlocked and in the foreground on Home On the requester, send the payer contact a Payment Request for 21,000 sats with the note "First request" - On the payer, verify Payment Request confirmation (id "PaymentRequestConfirm") appears automatically with 21,000 sats and For shows "First request" + On the payer, wait for the next private-message sync and verify Payment Request confirmation (id "PaymentRequestConfirm") appears automatically with 21,000 sats and For shows "First request" Close the confirmation without paying On the payer, open Receive and leave its sheet open On the requester, send the payer contact another Payment Request for 5,000 sats with the note "Second request" From ac90e653242a157cd34b69cff8a0535a1997085d Mon Sep 17 00:00:00 2001 From: benk10 Date: Fri, 2 Oct 2026 08:47:33 -0500 Subject: [PATCH 19/68] fix: allow fixed-address recurring paykit payments --- .../PaykitPaymentRequestService.swift | 6 +- .../PrivatePaykitService+Payments.swift | 25 +++- .../PaykitPaymentRequestServiceTests.swift | 138 +++++++++++++++++- .../fixed-onchain-destination.xml | 17 +++ 4 files changed, 175 insertions(+), 11 deletions(-) create mode 100644 journeys/subscriptions/fixed-onchain-destination.xml diff --git a/Bitkit/Services/PaykitPaymentRequestService.swift b/Bitkit/Services/PaykitPaymentRequestService.swift index 39d7d690e..fb775f514 100644 --- a/Bitkit/Services/PaykitPaymentRequestService.swift +++ b/Bitkit/Services/PaykitPaymentRequestService.swift @@ -1888,7 +1888,11 @@ final class PaykitPaymentRequestManager { } func isApprovedForPayment(_ request: PaykitPaymentRequest) -> Bool { - approvedPaymentRequestIds.contains(request.id) + guard approvedPaymentRequestIds.contains(request.id) else { return false } + guard let billingPeriod = request.billingPeriod else { return true } + return !subscriptions.contains { + request.belongs(to: $0) && $0.paidPeriods.contains(billingPeriod) + } } func finishPayment(_ request: PaykitPaymentRequest) async { diff --git a/Bitkit/Services/PrivatePaykitService+Payments.swift b/Bitkit/Services/PrivatePaykitService+Payments.swift index c57a74258..c3843766e 100644 --- a/Bitkit/Services/PrivatePaykitService+Payments.swift +++ b/Bitkit/Services/PrivatePaykitService+Payments.swift @@ -130,7 +130,13 @@ extension PrivatePaykitService { publicKey: publicKey, paymentRequest: paymentRequest, resolution: resolution, - validateEndpoints: { await self.privatePayableEndpoints(from: $0, publicKey: publicKey) } + validateEndpoints: { endpoints, allowUsedOnchainAddress in + await self.privatePayableEndpoints( + from: endpoints, + publicKey: publicKey, + allowUsedOnchainAddress: allowUsedOnchainAddress + ) + } ) if case .opened = result { return result } @@ -189,7 +195,7 @@ extension PrivatePaykitService { publicKey: String, paymentRequest: PaykitPaymentRequest?, resolution: PrivateContactPaymentResolution, - validateEndpoints: ([PublicPaykitService.Endpoint]) async -> [PublicPaykitService.Endpoint] + validateEndpoints: ([PublicPaykitService.Endpoint], Bool) async -> [PublicPaykitService.Endpoint] ) async -> PublicPaykitPaymentLaunchResult { let privateEndpoints = resolvedEndpoints(from: resolution) let paymentListVersion = resolution.privatePaymentListVersion @@ -200,7 +206,9 @@ extension PrivatePaykitService { let acceptedEndpoints = privateEndpoints.filter { endpoint in acceptedIdentifiers?.contains(endpoint.methodId.rawValue) ?? true } - let payableEndpoints = await validateEndpoints(acceptedEndpoints) + // Request-bound endpoints have no list version; recurring terms may reuse their fixed address. + let allowUsedOnchainAddress = paymentRequest?.billingPeriod != nil && paymentListVersion == nil + let payableEndpoints = await validateEndpoints(acceptedEndpoints, allowUsedOnchainAddress) guard !payableEndpoints.isEmpty, paymentListVersion != nil || paymentRequest != nil else { return acceptedEndpoints.isEmpty ? .noEndpoint : .notOpened } @@ -357,7 +365,12 @@ extension PrivatePaykitService { } } - func privatePayableEndpoints(from endpoints: [PublicPaykitService.Endpoint], publicKey: String) async -> [PublicPaykitService.Endpoint] { + func privatePayableEndpoints( + from endpoints: [PublicPaykitService.Endpoint], + publicKey: String, + allowUsedOnchainAddress: Bool = false, + isAddressUsed: (String) async throws -> Bool = { try await CoreService.shared.utility.isAddressUsed(address: $0) } + ) async -> [PublicPaykitService.Endpoint] { let payableEndpoints = await PublicPaykitService.payableEndpoints(from: endpoints) var reusableEndpoints: [PublicPaykitService.Endpoint] = [] var staleLightningPaymentHashes = Set() @@ -379,13 +392,13 @@ extension PrivatePaykitService { continue } - guard PublicPaykitService.MethodId.onchainPreferenceOrder.contains(endpoint.methodId) else { + guard PublicPaykitService.MethodId.onchainPreferenceOrder.contains(endpoint.methodId), !allowUsedOnchainAddress else { reusableEndpoints.append(endpoint) continue } do { - let isUsed = try await CoreService.shared.utility.isAddressUsed(address: endpoint.value) + let isUsed = try await isAddressUsed(endpoint.value) if !isUsed { reusableEndpoints.append(endpoint) } diff --git a/BitkitTests/PaykitPaymentRequestServiceTests.swift b/BitkitTests/PaykitPaymentRequestServiceTests.swift index 22a7ca87e..bc6eee4b2 100644 --- a/BitkitTests/PaykitPaymentRequestServiceTests.swift +++ b/BitkitTests/PaykitPaymentRequestServiceTests.swift @@ -298,7 +298,8 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { publicKey: publicKey, paymentRequest: request, resolution: resolution, - validateEndpoints: { endpoints in + validateEndpoints: { endpoints, allowUsedOnchainAddress in + XCTAssertFalse(allowUsedOnchainAddress) XCTAssertEqual(endpoints.map(\.value), [address]) return endpoints } @@ -328,14 +329,14 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { publicKey: request.counterparty, paymentRequest: request, resolution: resolution, - validateEndpoints: { _ in [] } + validateEndpoints: { _, _ in [] } ) guard case .notOpened = rejected else { return XCTFail("Wallet validation must reject the target") } let generic = await service.privatePaymentResult( publicKey: request.counterparty, paymentRequest: nil, resolution: resolution, - validateEndpoints: { $0 } + validateEndpoints: { endpoints, _ in endpoints } ) guard case .notOpened = generic else { return XCTFail("Contact payments require a list version") } resolution.payableEndpoints = [] @@ -343,11 +344,74 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { publicKey: request.counterparty, paymentRequest: request, resolution: resolution, - validateEndpoints: { $0 } + validateEndpoints: { endpoints, _ in endpoints } ) guard case .noEndpoint = missing else { return XCTFail("Missing bound endpoints must not fall back") } } + func testUsedOnchainAddressIsPayableOnlyForBoundRecurringRequest() async throws { + snapshotAppDefaultsDomain() + UserDefaults.standard.removeObject(forKey: PrivatePaykitService.cacheStateKey) + let service = PrivatePaykitService() + let address = "bcrt1qfn50lqawrce0evh66qrnlt8j447lwmeyqp5gmd" + let method = PublicPaykitService.MethodId.regtestOnchainP2wpkh + let payload = try PublicPaykitService.serializePayload(value: address) + let now = try XCTUnwrap(PaykitPaymentRequest.parseDate("2027-01-15T08:00:00Z")) + let record = try paymentRequestRecord( + counterparty: "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg", + state: .activeRecurring, + recurrence: PaymentRequestRecurrence( + every: 1, unit: "month", startsAt: "2027-01-01T08:00:00Z", anchor: "2027-01-01T08:00:00Z", endsAt: nil + ), + endpoints: [method.rawValue], + paymentEndpoints: [method.rawValue: payload] + ) + let subscription = try XCTUnwrap(PaykitSubscription(record: record)) + let recurring = try XCTUnwrap(subscription.requests(through: now, acceptedAt: PaykitPreciseInstant(date: now)).first) + let oneTime = try XCTUnwrap(PaykitPaymentRequest(record: paymentRequestRecord( + counterparty: record.counterparty, endpoints: [method.rawValue], paymentEndpoints: [method.rawValue: payload] + ), now: now)) + let cases: [(request: PaykitPaymentRequest?, version: UInt64?, opens: Bool)] = [ + (recurring, nil, true), + (recurring, 7, false), + (oneTime, nil, false), + (oneTime, 7, false), + (nil, 7, false), + ] + + for testCase in cases { + var resolution = try boundResolution(publicKey: record.counterparty, method: method, payload: payload) + resolution.privatePaymentListVersion = testCase.version + var usageChecks = 0 + let result = await service.privatePaymentResult( + publicKey: record.counterparty, + paymentRequest: testCase.request, + resolution: resolution, + validateEndpoints: { endpoints, allowUsedOnchainAddress in + XCTAssertEqual(allowUsedOnchainAddress, testCase.opens) + return await service.privatePayableEndpoints( + from: endpoints, + publicKey: record.counterparty, + allowUsedOnchainAddress: allowUsedOnchainAddress, + isAddressUsed: { + XCTAssertEqual($0, address) + usageChecks += 1 + return true + } + ) + } + ) + XCTAssertEqual(usageChecks, testCase.opens ? 0 : 1) + if testCase.opens { + guard case let .opened(target, context) = result else { return XCTFail("Expected the recurring fixed address") } + XCTAssertEqual(target, address) + XCTAssertNil(try XCTUnwrap(context).paymentListVersion) + } else { + guard case .notOpened = result else { return XCTFail("Used list and one-time addresses must remain unavailable") } + } + } + } + func testRequestEndpointCannotBypassExecutionClaimRejection() async throws { let method = PublicPaykitService.onchainMethodId(for: "bcrt1qinvoice") var record = try paymentRequestRecord( @@ -1241,6 +1305,72 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { XCTAssertEqual(request.billingPeriod?.endsAt, try XCTUnwrap(ISO8601DateFormatter().date(from: "2027-02-01T08:00:00Z"))) } + func testPaidSubscriptionPeriodStaysBlockedWhileNextUnpaidPeriodIsAuthorized() async throws { + actor InFlightPayments { + var ids = Set() + + func insert(_ id: PaykitPaymentRequest.ID) { + ids.insert(id) + } + } + + let clock = try PaymentRequestTestClock(XCTUnwrap(PaykitPaymentRequest.parseDate("2027-01-15T08:00:00Z"))) + let method = PublicPaykitService.MethodId.regtestOnchainP2wpkh + var record = try paymentRequestRecord( + state: .activeRecurring, + recurrence: PaymentRequestRecurrence( + every: 1, unit: "month", startsAt: "2027-01-01T08:00:00Z", anchor: "2027-01-01T08:00:00Z", endsAt: nil + ), + endpoints: [method.rawValue], + paymentEndpoints: [method.rawValue: PublicPaykitService.serializePayload(value: "bcrt1qfn50lqawrce0evh66qrnlt8j447lwmeyqp5gmd")] + ) + let sdk = PaymentRequestSdkMock(records: [record]) + let inFlightPayments = InFlightPayments() + let manager = PaykitPaymentRequestManager( + service: PaykitPaymentRequestService(sdk: sdk, now: { clock.now() }, logWarning: { _ in }), + presentationStore: PaymentRequestPresentationMemoryStore(), + acceptanceStore: PaymentRequestPresentationMemoryStore(), + subscriptionStateStore: PaymentRequestSubscriptionStateMemoryStore(), + completedPaymentProofKinds: { _ in [:] }, + inFlightPaymentRequestIds: { _ in await inFlightPayments.ids }, + now: { clock.now() }, + isAvailable: { true }, + logWarning: { _ in } + ) + manager.activate(identity: "pubky\(String(repeating: "z", count: 52))") + await manager.refresh() + let firstPeriod = try XCTUnwrap(manager.pendingRequests.first) + try await manager.prepareForPayment(firstPeriod) + try await manager.ensurePaymentAllowed(firstPeriod) + await inFlightPayments.insert(firstPeriod.id) + await manager.refresh() + XCTAssertTrue(manager.pendingRequests.isEmpty) + try await manager.ensurePaymentAllowed(firstPeriod) + record.paymentProofs = try [paymentProofRecord( + endpoint: method.rawValue, kind: .onchain, billingPeriod: XCTUnwrap(firstPeriod.billingPeriod).sdkValue + )] + await sdk.setRecords([record]) + clock.advance(by: 31 * 24 * 60 * 60) + await manager.refresh() + + do { + try await manager.ensurePaymentAllowed(firstPeriod) + XCTFail("A paid billing period must not authorize another payment") + } catch { + XCTAssertEqual(error as? PaykitPaymentRequestError, .requestUnavailable) + } + do { + try await manager.prepareForPayment(firstPeriod) + XCTFail("A paid billing period must not be approved again") + } catch { + XCTAssertEqual(error as? PaykitPaymentRequestError, .requestUnavailable) + } + let nextPeriod = try XCTUnwrap(manager.pendingRequests.first) + XCTAssertEqual(nextPeriod.billingPeriod?.sdkValue.startsAt, "2027-02-01T08:00:00Z") + try await manager.prepareForPayment(nextPeriod) + try await manager.ensurePaymentAllowed(nextPeriod) + } + func testRefreshKeepsCreatorSubscriptionWithoutGeneratingPayerPayment() async throws { let now = try XCTUnwrap(ISO8601DateFormatter().date(from: "2027-01-15T08:00:00Z")) let recurrence = PaymentRequestRecurrence( diff --git a/journeys/subscriptions/fixed-onchain-destination.xml b/journeys/subscriptions/fixed-onchain-destination.xml new file mode 100644 index 000000000..1f44e720a --- /dev/null +++ b/journeys/subscriptions/fixed-onchain-destination.xml @@ -0,0 +1,17 @@ + + + Requires a linked regtest issuer that can propose a recurring Payment Request with an explicit + fixed P2WPKH endpoint, and a payer funded for two periods. Confirms that address reuse does not + block an unpaid period and that a paid period cannot be paid again. + + + Have the issuer propose a monthly subscription for 5,000 sats with no payment deadline, binding its regtest P2WPKH address in the request endpoints + Open the proposal in Bitkit, accept it, and complete the first on-chain payment + Wait for the first payment proof to appear in the shared request state and verify the first period appears as paid in subscription details + Advance the subscription clock offset to the next monthly period in Dev Settings, then refresh Subscriptions + Open the next unpaid period and verify the send review uses the same fixed receiving address + Complete the second payment and wait for its proof to appear in the shared request state + Refresh Subscriptions and verify both periods appear as paid with no action to pay either period again + Reset the subscription clock offset in Dev Settings + + From 2f9afe062bc7df2357c15cca2c52dbb521e0d98a Mon Sep 17 00:00:00 2001 From: benk10 Date: Fri, 2 Oct 2026 15:29:24 +0100 Subject: [PATCH 20/68] test: align profile fixture with shared storage --- BitkitTests/PubkyProfileManagerTests.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/BitkitTests/PubkyProfileManagerTests.swift b/BitkitTests/PubkyProfileManagerTests.swift index f61879b62..965f5d978 100644 --- a/BitkitTests/PubkyProfileManagerTests.swift +++ b/BitkitTests/PubkyProfileManagerTests.swift @@ -114,7 +114,7 @@ final class PubkyProfileManagerTests: XCTestCase { snapshotAppDefaultsDomain() UserDefaults.standard.removeObject(forKey: "pubky_profile_name") let savedReference = AdoptedPubkyReference.current - let keys: [KeychainEntryType] = [.paykitSession, .pubkySecretKey, .paykitSdkState] + let keys: [KeychainEntryType] = [.paykitSession, .pubkySecretKey] let savedValues = try keys.map { try Keychain.load(key: $0) } defer { AdoptedPubkyReference.current = savedReference From f2d69b3c80d75b46588a908bd7bbbc8cfc8e3f90 Mon Sep 17 00:00:00 2001 From: benk10 Date: Fri, 2 Oct 2026 09:44:00 -0500 Subject: [PATCH 21/68] fix: preserve paykit recovery and payment details --- Bitkit/Services/CoreService.swift | 40 +++++++------ .../PaykitPaymentRequestService.swift | 2 +- .../Services/PaykitSubscriptionProposal.swift | 7 ++- .../PrivatePaykitService+Contacts.swift | 32 +++++------ .../AddressSearchCoordinatorTests.swift | 53 +++++++++++++++-- .../PaykitPaymentRequestServiceTests.swift | 44 +++++++------- .../PaykitSubscriptionProposalTests.swift | 57 ++++++++++++++++--- BitkitTests/PrivatePaykitServiceTests.swift | 44 ++++++++++++++ journeys/subscriptions/create-and-propose.xml | 8 ++- 9 files changed, 215 insertions(+), 72 deletions(-) diff --git a/Bitkit/Services/CoreService.swift b/Bitkit/Services/CoreService.swift index 6ec324f92..99b831b01 100644 --- a/Bitkit/Services/CoreService.swift +++ b/Bitkit/Services/CoreService.swift @@ -1810,19 +1810,21 @@ actor AddressSearchCoordinator { private var waitQueue: [CheckedContinuation] = [] private let defaults: UserDefaults private let listAccounts: @Sendable () async throws -> [LDKNode.OnchainWalletAccount] - private let deriveAddresses: @Sendable (LDKNode.OnchainWalletAccount, LDKNode.KeychainKind, UInt32, UInt32) async throws -> [String] + private let deriveAddresses: @Sendable (LDKNode.OnchainWalletAccount, LDKNode.KeychainKind, UInt32, UInt32) async throws + -> [LightningService.AddressDerivationInfo] init( defaults: UserDefaults = .standard, listAccounts: @escaping @Sendable () async throws -> [LDKNode.OnchainWalletAccount] = { try await LightningService.shared.listOnchainWalletAccounts() }, - deriveAddresses: @escaping @Sendable (LDKNode.OnchainWalletAccount, LDKNode.KeychainKind, UInt32, UInt32) async throws -> [String] = { - account, keychain, startIndex, count in - try await LightningService.shared.addressInfosForType( - account.addressType, keychain: keychain, startIndex: startIndex, count: count, accountIndex: account.accountIndex - ).map(\.address) - } + deriveAddresses: @escaping @Sendable (LDKNode.OnchainWalletAccount, LDKNode.KeychainKind, UInt32, UInt32) async throws + -> [LightningService.AddressDerivationInfo] = { + account, keychain, startIndex, count in + try await LightningService.shared.addressInfosForType( + account.addressType, keychain: keychain, startIndex: startIndex, count: count, accountIndex: account.accountIndex + ) + } ) { self.defaults = defaults self.listAccounts = listAccounts @@ -1866,13 +1868,13 @@ actor AddressSearchCoordinator { details.outputs.contains { $0.scriptpubkeyAddress == address } } - func findMatch(in addresses: [String]) -> String? { + func findMatch(in addresses: [LightningService.AddressDerivationInfo]) -> LightningService.AddressDerivationInfo? { if let exact = details.outputs.first(where: { $0.value == value }), - let addr = exact.scriptpubkeyAddress, addresses.contains(addr) + let match = addresses.first(where: { $0.address == exact.scriptpubkeyAddress }) { - return addr + return match } - return addresses.first { matchesTransaction($0) } + return addresses.first { matchesTransaction($0.address) } } if !currentWalletAddress.isEmpty, matchesTransaction(currentWalletAddress) { @@ -1912,14 +1914,16 @@ actor AddressSearchCoordinator { guard $0 >= 0, $0 <= Int(UInt32.max) else { return nil } return UInt32($0) } - let endIndex = lastUsed.map { $0 > UInt32.max - searchWindow ? UInt32.max : $0 + searchWindow } ?? searchWindow + // Include the address exactly searchWindow indexes after the last match. + let endIndex = lastUsed.map { $0 > UInt32.max - searchWindow - 1 ? UInt32.max : $0 + searchWindow + 1 } ?? searchWindow var index: UInt32 = 0 var currentAddressBatch: UInt32? while index < endIndex { - let addresses: [String] + let count = min(batchSize, endIndex - index) + let addresses: [LightningService.AddressDerivationInfo] do { - addresses = try await deriveAddresses(account, keychain, index, batchSize) + addresses = try await deriveAddresses(account, keychain, index, count) } catch { Logger.warn( "Skipping \(addressType.stringValue) account \(account.accountIndex) " + @@ -1929,12 +1933,12 @@ actor AddressSearchCoordinator { break } - if !currentWalletAddress.isEmpty, currentAddressBatch == nil, addresses.contains(currentWalletAddress) { + if !currentWalletAddress.isEmpty, currentAddressBatch == nil, addresses.contains(where: { $0.address == currentWalletAddress }) { currentAddressBatch = index } if let match = findMatch(in: addresses) { - defaults.set(Int(index), forKey: key) - return match + defaults.set(Int(match.index), forKey: key) + return match.address } if let found = currentAddressBatch { let stopIndex = found > UInt32.max - batchSize ? UInt32.max : found + batchSize @@ -1942,7 +1946,7 @@ actor AddressSearchCoordinator { break } } - index += batchSize + index += count } } return nil diff --git a/Bitkit/Services/PaykitPaymentRequestService.swift b/Bitkit/Services/PaykitPaymentRequestService.swift index fb775f514..fdb597a1d 100644 --- a/Bitkit/Services/PaykitPaymentRequestService.swift +++ b/Bitkit/Services/PaykitPaymentRequestService.swift @@ -1599,7 +1599,7 @@ final class PaykitPaymentRequestManager { } catch { // An interrupted response may follow a committed acceptance. Keep its local owner for reconciliation. switch error { - case is CancellationError, PaykitError.Transport, PaykitError.Storage, PaykitError.Identity: + case is CancellationError, PaykitError.Transport, PaykitError.Storage, PaykitError.Identity, PaykitError.ConcurrentUpdate: break default: if !alreadySaved, actionGeneration == stateGeneration, PubkyPublicKeyFormat.matches(activeIdentity, identity) { diff --git a/Bitkit/Services/PaykitSubscriptionProposal.swift b/Bitkit/Services/PaykitSubscriptionProposal.swift index 675a822a0..f1da75b1d 100644 --- a/Bitkit/Services/PaykitSubscriptionProposal.swift +++ b/Bitkit/Services/PaykitSubscriptionProposal.swift @@ -13,7 +13,10 @@ enum PaykitSubscriptionProposal { } static func encodedSize(_ terms: Paykit.PaymentRequestTerms) throws -> Int { - guard let recurrence = terms.recurrence else { + // Unmodeled fields must not silently reduce the estimated transport size. + guard let recurrence = terms.recurrence, + terms.paymentEndpoints == nil, terms.conversion == nil, terms.paymentDeadline == nil + else { throw PaykitPaymentRequestError.requestUnavailable } let metadata = try JSONSerialization.jsonObject(with: Data(terms.metadata.exportText().utf8)) @@ -21,6 +24,7 @@ enum PaykitSubscriptionProposal { let wire: [String: Any] = [ "version": 1, "kind": "paykit.payment_request", + "app_id": "bitkit", "event_id": uuid, "payment_request_id": uuid, "request": [ @@ -35,6 +39,7 @@ enum PaykitSubscriptionProposal { "ends_at": recurrence.endsAt as Any? ?? NSNull(), ], "accepted_payment_endpoint_identifiers": terms.acceptedPaymentEndpointIdentifiers, + "required_app_id": terms.requiredAppId as Any? ?? NSNull(), "metadata": metadata, ], ] diff --git a/Bitkit/Services/PrivatePaykitService+Contacts.swift b/Bitkit/Services/PrivatePaykitService+Contacts.swift index a59a67f7d..005459331 100644 --- a/Bitkit/Services/PrivatePaykitService+Contacts.swift +++ b/Bitkit/Services/PrivatePaykitService+Contacts.swift @@ -162,14 +162,7 @@ extension PrivatePaykitService { ) } - func removePublishedEndpoints() async throws { - let publicKeys = Set(knownSavedContactKeys) - .union(state.contacts.keys) - .union(Self.pendingDeletedContactCleanupKeys()) - try await removePublishedEndpoints(for: Array(publicKeys)) - } - - func removePublishedEndpoints(for publicKeys: [String]) async throws { + func removePublishedEndpoints(for publicKeys: [String]? = nil) async throws { try await removePublishedEndpoints(for: publicKeys, operations: EndpointCleanupOperations( linkedPeers: { try await PaykitSdkService.shared.linkedPeers() }, clearPaymentList: { try await PaykitSdkService.shared.clearPrivatePaymentList(to: $0) }, @@ -179,9 +172,9 @@ extension PrivatePaykitService { )) } - func removePublishedEndpoints(for publicKeys: [String], operations: EndpointCleanupOperations) async throws { - let publicKeys = normalizedSavedContactKeys(publicKeys) - guard !publicKeys.isEmpty else { return } + func removePublishedEndpoints(for publicKeys: [String]? = nil, operations: EndpointCleanupOperations) async throws { + let publicKeys = publicKeys.map { normalizedSavedContactKeys($0) } + guard publicKeys?.isEmpty != true else { return } do { try await withPublicationLock { @@ -193,10 +186,16 @@ extension PrivatePaykitService { } } - private func removePublishedEndpointsLocked(for publicKeys: [String], operations: EndpointCleanupOperations) async throws { + private func removePublishedEndpointsLocked(for publicKeys: [String]?, operations: EndpointCleanupOperations) async throws { let linkedPublicKeys = try await Set(operations.linkedPeers() .filter { $0.state != .notLinked } .compactMap { PubkyPublicKeyFormat.normalized($0.counterparty) }) + let publicKeys = publicKeys ?? normalizedSavedContactKeys(Array( + Set(knownSavedContactKeys) + .union(state.contacts.keys) + .union(Self.pendingDeletedContactCleanupKeys()) + .union(linkedPublicKeys) + )) let cleanupKeys = privatePaymentListCleanupKeys(publicKeys, linkedPublicKeys: linkedPublicKeys) let publicKeySet = Set(publicKeys) let cleanupStateSnapshots = Dictionary(uniqueKeysWithValues: publicKeys.map { publicKey in @@ -343,15 +342,10 @@ extension PrivatePaykitService { ? Set(knownSavedContactKeys).union(state.contacts.keys).union(Self.pendingDeletedContactCleanupKeys()) : Set(pendingPrivateEndpointRemovalKeys(savedPublicKeys: publicKeys)) - guard !cleanupKeys.isEmpty else { - if isFullCleanupPending { - Self.setContactSharingCleanupPending(false) - } - return - } + guard isFullCleanupPending || !cleanupKeys.isEmpty else { return } do { - try await removePublishedEndpoints(for: Array(cleanupKeys)) + try await removePublishedEndpoints(for: isFullCleanupPending ? nil : Array(cleanupKeys)) for publicKey in cleanupKeys where !savedKeys.contains(publicKey) { await clearContactState(publicKey: publicKey) } diff --git a/BitkitTests/AddressSearchCoordinatorTests.swift b/BitkitTests/AddressSearchCoordinatorTests.swift index 731f018ad..9e2cccfaa 100644 --- a/BitkitTests/AddressSearchCoordinatorTests.swift +++ b/BitkitTests/AddressSearchCoordinatorTests.swift @@ -27,7 +27,7 @@ final class AddressSearchCoordinatorTests: XCTestCase { deriveAddresses: { account, keychain, start, count in XCTAssertEqual(count, 200) if account.accountIndex == 3, keychain == .external, start == 1200 { - return ["server-receive"] + return [LightningService.AddressDerivationInfo(address: "server-receive", index: 1200)] } return [] } @@ -47,7 +47,8 @@ final class AddressSearchCoordinatorTests: XCTestCase { listAccounts: { [OnchainWalletAccount(addressType: .nativeSegwit, accountIndex: 3)] }, deriveAddresses: { account, keychain, start, _ in XCTAssertEqual(account.accountIndex, 0, "Account zero should resolve before companion derivation") - return account.addressType == .nativeSegwit && keychain == .internal && start == 200 ? ["owned-change"] : [] + return account.addressType == .nativeSegwit && keychain == .internal && start == 200 + ? [LightningService.AddressDerivationInfo(address: "owned-change", index: 203)] : [] } ) let result = try await search.runAddressSearch( @@ -55,7 +56,51 @@ final class AddressSearchCoordinatorTests: XCTestCase { currentWalletAddress: "", selectedAddressType: .nativeSegwit ) XCTAssertEqual(result, "owned-change") - XCTAssertEqual(defaults.integer(forKey: "addressSearch_lastUsedChangeIndex_nativeSegwit"), 200) + XCTAssertEqual(defaults.integer(forKey: "addressSearch_lastUsedChangeIndex_nativeSegwit"), 203) + } + + func testMatchedCompanionReceiveIndexExtendsPersistedWindowByOneThousand() async throws { + let key = "addressSearch_lastUsedReceiveIndex_nativeSegwit_account3" + for matchIndex in [999, 1999] { + let search = AddressSearchCoordinator( + defaults: defaults, + listAccounts: { [OnchainWalletAccount(addressType: .nativeSegwit, accountIndex: 3)] }, + deriveAddresses: { account, keychain, start, count in + guard account.accountIndex == 3, account.addressType == .nativeSegwit, keychain == .external else { return [] } + XCTAssertLessThanOrEqual(start + count, UInt32(matchIndex + 1)) + return (start ..< start + count).map { LightningService.AddressDerivationInfo(address: "owned-\($0)", index: $0) } + } + ) + let result = try await search.runAddressSearch( + details: details(["owned-\(matchIndex - 1)", "owned-\(matchIndex)"]), value: 15000, + currentWalletAddress: "", selectedAddressType: .nativeSegwit + ) + XCTAssertEqual(result, "owned-\(matchIndex)") + XCTAssertEqual(defaults.integer(forKey: key), matchIndex) + } + XCTAssertNil(defaults.object(forKey: "addressSearch_lastUsedReceiveIndex_nativeSegwit")) + } + + func testSearchIncludesOneThousandAheadAtBatchBoundaryButNotBeyond() async throws { + let key = "addressSearch_lastUsedChangeIndex_nativeSegwit" + for matchIndex in [2000, 2001] { + defaults.set(1000, forKey: key) + let search = AddressSearchCoordinator( + defaults: defaults, + listAccounts: { [] }, + deriveAddresses: { account, keychain, start, count in + guard account.accountIndex == 0, account.addressType == .nativeSegwit, keychain == .internal else { return [] } + XCTAssertLessThanOrEqual(start + count, 2001) + return (start ..< start + count).map { LightningService.AddressDerivationInfo(address: "owned-\($0)", index: $0) } + } + ) + let result = try await search.runAddressSearch( + details: details(["owned-\(matchIndex)"]), value: 15000, + currentWalletAddress: "", selectedAddressType: .nativeSegwit + ) + XCTAssertEqual(result, matchIndex == 2000 ? "owned-2000" : nil) + XCTAssertEqual(defaults.integer(forKey: key), matchIndex == 2000 ? 2000 : 1000) + } } func testCurrentReceiveAddressDoesNotRequireAccountLookup() async throws { @@ -79,7 +124,7 @@ final class AddressSearchCoordinatorTests: XCTestCase { XCTAssertEqual(account.accountIndex, 0) XCTAssertLessThan(start, 1000) XCTAssertEqual(count, 200) - return ["unrelated-owned-address"] + return [LightningService.AddressDerivationInfo(address: "unrelated-owned-address", index: start)] } ) let result = try await search.runAddressSearch( diff --git a/BitkitTests/PaykitPaymentRequestServiceTests.swift b/BitkitTests/PaykitPaymentRequestServiceTests.swift index bc6eee4b2..51aa6574d 100644 --- a/BitkitTests/PaykitPaymentRequestServiceTests.swift +++ b/BitkitTests/PaykitPaymentRequestServiceTests.swift @@ -4338,26 +4338,32 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { } func testInterruptedAcceptanceCanResumeAfterRefresh() async throws { - let record = try paymentRequestRecord() - let sdk = PaymentRequestSdkMock(records: [record]) - let store = PaymentRequestPresentationMemoryStore() - let manager = paymentRequestManager(sdk: sdk, acceptanceStore: store) - await manager.refresh() - let request = try XCTUnwrap(manager.pendingRequests.first) - await sdk.setAcceptanceResponseError(PaykitError.Transport(code: "transport_error", context: "response lost")) + for error in [ + PaykitError.Transport(code: "transport_error", context: "response lost"), + PaykitError.ConcurrentUpdate(code: "concurrent_update", context: "response read locked"), + ] { + let record = try paymentRequestRecord() + let sdk = PaymentRequestSdkMock(records: [record]) + let store = PaymentRequestPresentationMemoryStore() + let manager = paymentRequestManager(sdk: sdk, acceptanceStore: store) + await manager.refresh() + let request = try XCTUnwrap(manager.pendingRequests.first) + await sdk.setAcceptanceResponseError(error) - do { - try await manager.prepareForPayment(request) - XCTFail("The interrupted call must not authorize execution") - } catch {} - XCTAssertFalse(manager.isApprovedForPayment(request)) - var accepted = record - accepted.state = .accepted - await sdk.setRecords([accepted]) - await manager.refresh() - let retry = try XCTUnwrap(manager.pendingRequests.first) - try await manager.prepareForPayment(retry) - try await manager.ensurePaymentAllowed(retry) + do { + try await manager.prepareForPayment(request) + XCTFail("The interrupted call must not authorize execution") + } catch {} + XCTAssertFalse(manager.isApprovedForPayment(request)) + var accepted = record + accepted.state = .accepted + await sdk.setRecords([accepted]) + let restarted = paymentRequestManager(sdk: sdk, acceptanceStore: store) + await restarted.refresh() + let retry = try XCTUnwrap(restarted.pendingRequests.first) + try await restarted.prepareForPayment(retry) + try await restarted.ensurePaymentAllowed(retry) + } } func testFailedAcceptancePreservesAnotherRequestsIntent() async throws { diff --git a/BitkitTests/PaykitSubscriptionProposalTests.swift b/BitkitTests/PaykitSubscriptionProposalTests.swift index 64f916edc..da942ad21 100644 --- a/BitkitTests/PaykitSubscriptionProposalTests.swift +++ b/BitkitTests/PaykitSubscriptionProposalTests.swift @@ -4,21 +4,62 @@ import UIKit import XCTest final class PaykitSubscriptionProposalTests: XCTestCase { - func testTransportLimitIncludesEnvelopeEndpointsAndPublicIcon() throws { + func testTransportLimitIncludesAppIdsEndpointsAndPublicIcon() throws { + // Paykit's payment-request wire format, without optional request fields. + let emptyWire = #""" + {"version":1,"kind":"paykit.payment_request","app_id":"bitkit", + "event_id":"00000000-0000-0000-0000-000000000000","payment_request_id":"00000000-0000-0000-0000-000000000000", + "request":{"amount":{"value":"0.001","asset":"btc"},"payment_reference":"bitkit-00000000-0000-0000-0000-000000000000", + "proposal_expires_at":"2027-01-22T08:00:00.000Z", + "recurrence":{"every":1,"unit":"month","starts_at":"2027-01-15T08:00:00.000Z","anchor":"2027-01-15T08:00:00.000Z","ends_at":null}, + "accepted_payment_endpoint_identifiers":["btc-regtest-p2wpkh","btc-lightning-bolt11","btc-lightning-lnurl"],"required_app_id":"bitkit", + "metadata":{"note":"Support","subscription":{"benefits":[],"description":"", + "icon_uri":"pubky://\#(String(repeating: "x", count: 122))","version":1}}}} + """#.split(separator: "\n").joined() + XCTAssertNoThrow(try JSONSerialization.jsonObject(with: Data(emptyWire.utf8))) + XCTAssertEqual(emptyWire.utf8.count, 840) let empty = try terms(description: "", iconURI: PaykitSubscriptionProposal.reservedIconURI) - let available = try PaykitSubscriptionProposal.maximumMessageBytes - (PaykitSubscriptionProposal.encodedSize(empty)) - XCTAssertGreaterThan(available, 0) - let full = try terms(description: String(repeating: "a", count: available), iconURI: PaykitSubscriptionProposal.reservedIconURI) + XCTAssertEqual(try PaykitSubscriptionProposal.encodedSize(empty), emptyWire.utf8.count) + let full = try terms(description: String(repeating: "a", count: 160), iconURI: PaykitSubscriptionProposal.reservedIconURI) XCTAssertEqual(try PaykitSubscriptionProposal.encodedSize(full), 1000) XCTAssertNoThrow(try PaykitSubscriptionProposal.validate(full)) - XCTAssertThrowsError(try PaykitSubscriptionProposal.validate(terms( - description: String(repeating: "a", count: available + 1), + let oversized = try terms( + description: String(repeating: "a", count: 161), iconURI: PaykitSubscriptionProposal.reservedIconURI - ))) { error in + ) + XCTAssertEqual(try PaykitSubscriptionProposal.encodedSize(oversized), 1001) + XCTAssertThrowsError(try PaykitSubscriptionProposal.validate(oversized)) { error in XCTAssertEqual(error as? PaykitPaymentRequestError, .subscriptionTooLong) } } + func testSizeIncludesNullableFieldsAndRecurrenceEnd() throws { + var request = try terms(description: "") + let base = try PaykitSubscriptionProposal.encodedSize(request) + request.requiredAppId = nil + XCTAssertEqual(try PaykitSubscriptionProposal.encodedSize(request), base - 4) + request.proposalExpiresAt = nil + XCTAssertEqual(try PaykitSubscriptionProposal.encodedSize(request), base - 26) + request.recurrence?.endsAt = "2028-01-15T08:00:00.000Z" + XCTAssertEqual(try PaykitSubscriptionProposal.encodedSize(request), base - 4) + request.requiredAppId = "checkout" + XCTAssertEqual(try PaykitSubscriptionProposal.encodedSize(request), base + 2) + } + + func testUnsupportedSubscriptionFieldsAreNotSilentlyExcludedFromSize() throws { + var boundEndpoints = try terms(description: "") + boundEndpoints.paymentEndpoints = ["btc-regtest-p2wpkh": #"{"address":"bcrt1qexample"}"#] + var conversion = try terms(description: "") + conversion.conversion = .perPeriod + var deadline = try terms(description: "") + deadline.paymentDeadline = .periodStart(seconds: 3600) + for request in [boundEndpoints, conversion, deadline] { + XCTAssertThrowsError(try PaykitSubscriptionProposal.validate(request)) { error in + XCTAssertEqual(error as? PaykitPaymentRequestError, .requestUnavailable) + } + } + } + func testSizeCountsUTF8AndJSONEscapingRatherThanCharacters() throws { let base = try PaykitSubscriptionProposal.encodedSize(terms(description: "")) XCTAssertEqual(try PaykitSubscriptionProposal.encodedSize(terms(description: "💜")), base + 4) @@ -57,7 +98,7 @@ final class PaykitSubscriptionProposalTests: XCTestCase { anchor: "2027-01-15T08:00:00.000Z", endsAt: nil ), - acceptedPaymentEndpointIdentifiers: ["bitcoin:regtest", "lightning:bolt11", "lightning:lnurl"], + acceptedPaymentEndpointIdentifiers: ["btc-regtest-p2wpkh", "btc-lightning-bolt11", "btc-lightning-lnurl"], paymentEndpoints: nil, requiredAppId: "bitkit", conversion: nil, diff --git a/BitkitTests/PrivatePaykitServiceTests.swift b/BitkitTests/PrivatePaykitServiceTests.swift index c90abaf7f..ac07f0465 100644 --- a/BitkitTests/PrivatePaykitServiceTests.swift +++ b/BitkitTests/PrivatePaykitServiceTests.swift @@ -612,6 +612,50 @@ final class PrivatePaykitServiceTests: XCTestCase { } } + func testFullCleanupFindsRemotePeersAndRetainsFailedDiscovery() async throws { + UserDefaults.standard.removeObject(forKey: PrivatePaykitService.cacheStateKey) + let publicKey = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" + let peer = LinkedPeerRecord( + counterparty: publicKey, state: .linked, + lastSyncAt: nil, lastPrivateReceiveAt: nil, failureCount: 0, + localRecoveryAttemptId: nil, localRecoveryMarkerCreatedAt: nil, localRecoveryMarkerLastError: nil, + remoteRecoveryAttemptId: nil, remoteRecoveryMarkerObservedAt: nil + ) + let service = PrivatePaykitService() + var failLookup = true + var cleared = [String]() + var registryUpdates = 0 + let operations = PrivatePaykitService.EndpointCleanupOperations( + linkedPeers: { + if failLookup { throw PrivatePaykitError.privateUnavailable } + return [peer] + }, + clearPaymentList: { + cleared.append($0) + return PrivatePaymentListDeliveryReport(queued: [], cleared: [], failedToQueue: [], failedToDeliver: []) + }, + drainMessages: { XCTAssertEqual($0, [publicKey]) }, + pendingDrainKeys: { _ in [] }, + syncApp: { + XCTAssertEqual(cleared, [publicKey]) + registryUpdates += 1 + } + ) + + do { + try await service.removePublishedEndpoints(operations: operations) + XCTFail("Failed discovery must keep cleanup pending") + } catch { + XCTAssertTrue(PublicPaykitService.isCleanupPending) + } + XCTAssertTrue(cleared.isEmpty) + XCTAssertEqual(registryUpdates, 0) + failLookup = false + try await service.removePublishedEndpoints(operations: operations) + XCTAssertEqual(cleared, [publicKey]) + XCTAssertEqual(registryUpdates, 1) + } + func testCleanupSkipsNeverLinkedContactsWithoutPublishedDetails() async { let service = PrivatePaykitService() var publishedState = PrivatePaykitService.ContactState() diff --git a/journeys/subscriptions/create-and-propose.xml b/journeys/subscriptions/create-and-propose.xml index bbadcc520..47bf370a7 100644 --- a/journeys/subscriptions/create-and-propose.xml +++ b/journeys/subscriptions/create-and-propose.xml @@ -2,7 +2,8 @@ Creator side. Builds a subscription from the Subscriptions overview and proposes it to a saved, linked contact, ending on the sent confirmation and the new row in the CREATED section. Requires - the two linked Bitkit instances described in README.md. + the two linked Bitkit instances described in README.md. Also checks rejection of a proposal + that exceeds the transport message limit before sending a shorter proposal. iOS differs from the Android original in two places. The description field is a text editor rather than a text field, so `ui-automation type-text --element-ref` refuses it; tap the field and type @@ -19,12 +20,15 @@ Tap the amount pencil (id "SubscriptionEditAmount"), enter "5000" on the keypad, and tap Continue (id "PaymentRequestAmountContinue") Verify the amount reads 5,000 sats Type "Journey Sub" into the subscription name field (id "SubscriptionName") - Type a short description into the description field (id "SubscriptionDescription"), so the payer's detail screen offers More Info later + Enter 600 ASCII letters in the description field (id "SubscriptionDescription") Verify the frequency selector has "Monthly" selected (id "Tab-month") Tap Choose Recipient (id "SubscriptionChooseRecipient"), now enabled Verify the recipient step lists the linked contact under CONTACTS and that Propose Subscription (id "SubscriptionPropose") is disabled Tap the linked contact row and verify it shows the selected checkmark Tap Propose Subscription (id "SubscriptionPropose") + Verify an error asks to shorten the subscription content and no sent confirmation appears + Tap Back to return to the subscription details and replace the description with "Monthly support" + Tap Choose Recipient, select the linked contact if needed, and tap Propose Subscription again Verify the sent confirmation (id "SubscriptionProposalSent") shows headline "Sent Proposal", the recipient contact, and the subscription name with 5,000 sats Tap OK Verify a row for "Journey Sub" appears in the CREATED section with subtitle "Proposal sent" From 0e7da00b303a5907b9c15113b8c92d679ceb02ed Mon Sep 17 00:00:00 2001 From: benk10 Date: Fri, 2 Oct 2026 10:17:45 -0500 Subject: [PATCH 22/68] fix: stop redundant paykit polling --- Bitkit/AppScene.swift | 2 +- .../PrivatePaykitService+Contacts.swift | 54 ++++++++++++++----- .../PaykitPaymentRequestServiceTests.swift | 12 ++--- BitkitTests/PrivatePaykitServiceTests.swift | 22 +++++++- journeys/payment-requests/README.md | 2 + 5 files changed, 68 insertions(+), 24 deletions(-) diff --git a/Bitkit/AppScene.swift b/Bitkit/AppScene.swift index f13a1dd5d..21c0c55fd 100644 --- a/Bitkit/AppScene.swift +++ b/Bitkit/AppScene.swift @@ -1227,7 +1227,7 @@ struct AppScene: View { return } guard scenePhase == .active else { return } - await refreshIncomingPaykitPaymentRequests() + await refreshIncomingPaykitPaymentRequests(refreshMaintenance: false) } } diff --git a/Bitkit/Services/PrivatePaykitService+Contacts.swift b/Bitkit/Services/PrivatePaykitService+Contacts.swift index 005459331..0dda6fe97 100644 --- a/Bitkit/Services/PrivatePaykitService+Contacts.swift +++ b/Bitkit/Services/PrivatePaykitService+Contacts.swift @@ -15,7 +15,7 @@ extension PrivatePaykitService { struct EndpointPublicationOperations { let currentPublicKey: () async -> String? - let ensureLink: (_ publicKey: String) async throws -> Void + let ensureLink: (_ publicKey: String) async throws -> LinkedPeerState let buildEndpoints: (_ publicKey: String) async throws -> [PublicPaykitService.Endpoint] let syncPaymentLists: (_ updates: [PrivatePaymentListReservationUpdateInput]) async throws -> PrivatePaymentListDeliveryReport } @@ -116,6 +116,12 @@ extension PrivatePaykitService { Self.initialLinkBurstStartedSubject.send() initialLinkBurstTask = Task { [reason, generation] in + defer { + if generation == initialLinkBurstGeneration { + initialLinkBurstTask = nil + initialLinkBurstPublicKeys.removeAll() + } + } for delay in [UInt64(0)] + Self.initialLinkBurstRetryDelays { if delay > 0 { try? await Task.sleep(nanoseconds: delay) @@ -132,11 +138,11 @@ extension PrivatePaykitService { requireImmediatePublication: false, reason: "\(reason) initial link burst" ) + guard !Task.isCancelled, generation == initialLinkBurstGeneration else { return } + let pendingKeys = await pendingPrivateMessageDrainKeys(publicKeys, retryMissingPeers: true) + guard !Task.isCancelled, generation == initialLinkBurstGeneration else { return } + if pendingKeys.isEmpty { break } } - - guard generation == initialLinkBurstGeneration else { return } - initialLinkBurstTask = nil - initialLinkBurstPublicKeys.removeAll() } } @@ -410,7 +416,7 @@ extension PrivatePaykitService { await PubkyService.currentPublicKey() }, ensureLink: { publicKey in - _ = try await PaykitSdkService.shared.ensureLinkWithPeer(publicKey) + try await PaykitSdkService.shared.ensureLinkWithPeer(publicKey).state }, buildEndpoints: { publicKey in try await self.buildLocalEndpoints( @@ -447,7 +453,9 @@ extension PrivatePaykitService { for publicKey in publicKeys { do { - try await operations.ensureLink(publicKey) + if try await operations.ensureLink(publicKey) != .linked { + linkRetryKeys.append(publicKey) + } } catch PaykitError.NotFound { continue } catch { @@ -458,7 +466,6 @@ extension PrivatePaykitService { linkRetryKeys.append(publicKey) continue } - linkRetryKeys.append(publicKey) do { let endpoints = try await operations.buildEndpoints(publicKey) updates.append(PrivatePaymentListReservationUpdateInput( @@ -511,8 +518,11 @@ extension PrivatePaykitService { let retryKeys = Array(Set(retryKeys)) guard !retryKeys.isEmpty else { return } - await drainPendingPrivateMessages(reason: reason, advancing: retryKeys) - let pendingRetryKeys = await pendingPrivateMessageDrainKeys(retryKeys) + let drainKeys = await pendingPrivateMessageDrainKeys(retryKeys, retryMissingPeers: true) + guard !drainKeys.isEmpty else { return } + + await drainPendingPrivateMessages(reason: reason, advancing: Array(drainKeys)) + let pendingRetryKeys = await pendingPrivateMessageDrainKeys(Array(drainKeys)) if !pendingRetryKeys.isEmpty { schedulePendingPrivateMessageDrainRetries(reason: reason, retryKeys: Array(pendingRetryKeys)) } @@ -580,7 +590,11 @@ extension PrivatePaykitService { private func drainPendingPrivateMessageRetryKeys(reason: String) async { let retryKeys = Array(pendingMessageDrainRetryKeys) guard !retryKeys.isEmpty else { return } - await drainPendingPrivateMessages(reason: reason, advancing: retryKeys) + + let drainKeys = await pendingPrivateMessageDrainKeys(retryKeys) + if !drainKeys.isEmpty { + await drainPendingPrivateMessages(reason: reason, advancing: Array(drainKeys)) + } await updatePendingMessageDrainRetryKeys(retryKeys) } @@ -600,7 +614,7 @@ extension PrivatePaykitService { pendingMessageDrainRetryKeys.formUnion(remainingKeys) } - private func pendingPrivateMessageDrainKeys(_ retryKeys: [String]) async -> Set { + private func pendingPrivateMessageDrainKeys(_ retryKeys: [String], retryMissingPeers: Bool = false) async -> Set { let retryKeys = Set(retryKeys) guard !retryKeys.isEmpty else { return [] } @@ -626,9 +640,23 @@ extension PrivatePaykitService { return retryKeys } + return Self.pendingPrivateMessageDrainKeys( + retryKeys, + linkedPeers: linkedPeers, + pendingOutbound: pendingOutbound, + retryMissingPeers: retryMissingPeers + ) + } + + static func pendingPrivateMessageDrainKeys( + _ retryKeys: Set, + linkedPeers: [String: LinkedPeerState], + pendingOutbound: Set, + retryMissingPeers: Bool = false + ) -> Set { return Set(retryKeys.filter { retryKey in guard let state = linkedPeers[retryKey] else { - return pendingOutbound.contains(retryKey) + return retryMissingPeers || pendingOutbound.contains(retryKey) } if state == .linked { return pendingOutbound.contains(retryKey) diff --git a/BitkitTests/PaykitPaymentRequestServiceTests.swift b/BitkitTests/PaykitPaymentRequestServiceTests.swift index 51aa6574d..81fd0b8df 100644 --- a/BitkitTests/PaykitPaymentRequestServiceTests.swift +++ b/BitkitTests/PaykitPaymentRequestServiceTests.swift @@ -11,19 +11,15 @@ import XCTest final class PaykitPaymentRequestServiceTests: XCTestCase { func testSharedStateRefreshSkipsPrivateMessages() async throws { let record = try paymentRequestRecord() - let sdk = PaymentRequestSdkMock(records: [record]) + let sdk = PaymentRequestSdkMock(records: []) let manager = paymentRequestManager(sdk: sdk) + await manager.refresh() + await sdk.setRecords([record]) await manager.refresh(syncPrivateMessages: false) XCTAssertEqual(manager.pendingRequests.map(\.paymentRequestId), [record.paymentRequestId]) - var snapshot = await sdk.snapshot() - XCTAssertEqual(snapshot.processCallCount, 0) - XCTAssertEqual(snapshot.receiveCallCount, 0) - - await manager.refresh() - - snapshot = await sdk.snapshot() + let snapshot = await sdk.snapshot() XCTAssertEqual(snapshot.processCallCount, 1) XCTAssertEqual(snapshot.receiveCallCount, 1) } diff --git a/BitkitTests/PrivatePaykitServiceTests.swift b/BitkitTests/PrivatePaykitServiceTests.swift index ac07f0465..a0eb46a43 100644 --- a/BitkitTests/PrivatePaykitServiceTests.swift +++ b/BitkitTests/PrivatePaykitServiceTests.swift @@ -17,6 +17,24 @@ final class PrivatePaykitServiceTests: XCTestCase { XCTAssertTrue(PrivatePaykitService.initialLinkBurstRetryDelays.allSatisfy { $0 == 2_000_000_000 }) } + func testPrivateMessageDrainKeysRespectLinkStateAndPendingOutbound() { + let peers: [String: LinkedPeerState] = [ + "idle": .linked, "pending": .linked, "new": .notLinked, + "linking": .linking, "recovering": .recoveryRequired, + "blocked": .blocked, "unknown": .unknown, + ] + let keys = Set(peers.keys).union(["missing", "missing-pending"]) + let outbound: Set = ["pending", "missing-pending", "blocked", "unknown", "unrelated"] + for retryMissingPeers in [false, true] { + let pending = PrivatePaykitService.pendingPrivateMessageDrainKeys( + keys, linkedPeers: peers, pendingOutbound: outbound, retryMissingPeers: retryMissingPeers + ) + var expected: Set = ["pending", "missing-pending", "new", "linking", "recovering"] + if retryMissingPeers { expected.insert("missing") } + XCTAssertEqual(pending, expected) + } + } + func testPendingEndpointReconciliationRestoresSavedContactsWhenPublishingRemainsEnabled() throws { try withIsolatedDefaults { defaults in defaults.set(true, forKey: PrivatePaykitService.cleanupPendingKey) @@ -714,7 +732,7 @@ final class PrivatePaykitServiceTests: XCTestCase { var syncedUpdates = [PrivatePaymentListReservationUpdateInput]() let operations = PrivatePaykitService.EndpointPublicationOperations( currentPublicKey: { "pubkylocal" }, - ensureLink: { _ in }, + ensureLink: { _ in .linked }, buildEndpoints: { publicKey in if publicKey == failedPublicKey { throw preparationError } XCTAssertEqual(publicKey, successfulPublicKey) @@ -751,7 +769,7 @@ final class PrivatePaykitServiceTests: XCTestCase { let preparationError = NSError(domain: "PrivatePaykitServiceTests", code: 1) let operations = PrivatePaykitService.EndpointPublicationOperations( currentPublicKey: { "pubkylocal" }, - ensureLink: { _ in }, + ensureLink: { _ in .linked }, buildEndpoints: { _ in throw preparationError }, syncPaymentLists: { _ in XCTFail("No payment list should be synced") diff --git a/journeys/payment-requests/README.md b/journeys/payment-requests/README.md index 020822a9a..6e56f3895 100644 --- a/journeys/payment-requests/README.md +++ b/journeys/payment-requests/README.md @@ -33,6 +33,8 @@ That run established the issuer shapes captured by the fixture: lowercase `btc`, Bitkit refreshes shared request state every 10 seconds. Private messages are synchronized on startup and explicit refreshes, and on maintenance rounds that back off from 30 to 60 to 120 seconds. +Startup link retries stop when no link or outbound work remains; subsequent startup request polls +read shared state without repeating private-message synchronization or target discovery. A new peer message can therefore take up to two minutes plus synchronization time to appear during steady polling. ## Accepting install From c5ae3143df51e64d5867288b2a4d6cff89e1a994 Mon Sep 17 00:00:00 2001 From: benk10 Date: Fri, 2 Oct 2026 10:50:25 -0500 Subject: [PATCH 23/68] fix: guard paykit payment authorization and cleanup --- .../PaykitPaymentRequestService.swift | 7 ++-- .../PrivatePaykitService+Contacts.swift | 8 +++- .../PaykitPaymentRequestServiceTests.swift | 39 +++++++++++++++++++ BitkitTests/PrivatePaykitServiceTests.swift | 8 +++- .../cancellation-during-confirmation.xml | 16 ++++++++ 5 files changed, 72 insertions(+), 6 deletions(-) create mode 100644 journeys/subscriptions/cancellation-during-confirmation.xml diff --git a/Bitkit/Services/PaykitPaymentRequestService.swift b/Bitkit/Services/PaykitPaymentRequestService.swift index fdb597a1d..44aa241d5 100644 --- a/Bitkit/Services/PaykitPaymentRequestService.swift +++ b/Bitkit/Services/PaykitPaymentRequestService.swift @@ -1599,7 +1599,8 @@ final class PaykitPaymentRequestManager { } catch { // An interrupted response may follow a committed acceptance. Keep its local owner for reconciliation. switch error { - case is CancellationError, PaykitError.Transport, PaykitError.Storage, PaykitError.Identity, PaykitError.ConcurrentUpdate: + case is CancellationError, PaykitError.Transport, PaykitError.Storage, + PaykitError.Identity, PaykitError.ConcurrentUpdate, PaykitError.SharedStateBusy: break default: if !alreadySaved, actionGeneration == stateGeneration, PubkyPublicKeyFormat.matches(activeIdentity, identity) { @@ -1890,8 +1891,8 @@ final class PaykitPaymentRequestManager { func isApprovedForPayment(_ request: PaykitPaymentRequest) -> Bool { guard approvedPaymentRequestIds.contains(request.id) else { return false } guard let billingPeriod = request.billingPeriod else { return true } - return !subscriptions.contains { - request.belongs(to: $0) && $0.paidPeriods.contains(billingPeriod) + return subscriptions.contains { + request.belongs(to: $0) && $0.lifecycleState == .activeRecurring && !$0.paidPeriods.contains(billingPeriod) } } diff --git a/Bitkit/Services/PrivatePaykitService+Contacts.swift b/Bitkit/Services/PrivatePaykitService+Contacts.swift index 0dda6fe97..df725c115 100644 --- a/Bitkit/Services/PrivatePaykitService+Contacts.swift +++ b/Bitkit/Services/PrivatePaykitService+Contacts.swift @@ -193,14 +193,18 @@ extension PrivatePaykitService { } private func removePublishedEndpointsLocked(for publicKeys: [String]?, operations: EndpointCleanupOperations) async throws { - let linkedPublicKeys = try await Set(operations.linkedPeers() + let peers = try await operations.linkedPeers() + let linkedPublicKeys = Set(peers .filter { $0.state != .notLinked } .compactMap { PubkyPublicKeyFormat.normalized($0.counterparty) }) + let discoveredPublicKeys = peers + .filter { $0.state == .linked } + .compactMap { PubkyPublicKeyFormat.normalized($0.counterparty) } let publicKeys = publicKeys ?? normalizedSavedContactKeys(Array( Set(knownSavedContactKeys) .union(state.contacts.keys) .union(Self.pendingDeletedContactCleanupKeys()) - .union(linkedPublicKeys) + .union(discoveredPublicKeys) )) let cleanupKeys = privatePaymentListCleanupKeys(publicKeys, linkedPublicKeys: linkedPublicKeys) let publicKeySet = Set(publicKeys) diff --git a/BitkitTests/PaykitPaymentRequestServiceTests.swift b/BitkitTests/PaykitPaymentRequestServiceTests.swift index 81fd0b8df..3b6e53ad5 100644 --- a/BitkitTests/PaykitPaymentRequestServiceTests.swift +++ b/BitkitTests/PaykitPaymentRequestServiceTests.swift @@ -1367,6 +1367,44 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { try await manager.ensurePaymentAllowed(nextPeriod) } + func testCanceledSubscriptionCannotAuthorizeAnApprovedPeriod() async throws { + let clock = try PaymentRequestTestClock(XCTUnwrap(PaykitPaymentRequest.parseDate("2027-01-15T08:00:00Z"))) + var record = try paymentRequestRecord( + state: .activeRecurring, + recurrence: PaymentRequestRecurrence( + every: 1, unit: "month", startsAt: "2027-01-01T08:00:00Z", anchor: "2027-01-01T08:00:00Z", endsAt: nil + ) + ) + let sdk = PaymentRequestSdkMock(records: [record]) + let manager = paymentRequestManager(sdk: sdk, clock: clock) + await manager.refresh() + let request = try XCTUnwrap(manager.pendingRequests.first) + try await manager.prepareForPayment(request) + XCTAssertTrue(manager.isApprovedForPayment(request)) + await sdk.pauseNextLinkedPeers() + + let authorization = Task { try await manager.ensurePaymentAllowed(request) } + try await waitUntil { await sdk.linkedPeersIsPaused() } + record.state = .canceled + await sdk.setRecords([record]) + await manager.refresh(syncPrivateMessages: false) + await sdk.resumeLinkedPeers() + + XCTAssertFalse(manager.isApprovedForPayment(request)) + do { + try await authorization.value + XCTFail("A canceled subscription must not authorize payment") + } catch { + XCTAssertEqual(error as? PaykitPaymentRequestError, .requestUnavailable) + } + do { + try await manager.prepareForPayment(request) + XCTFail("A canceled subscription must not be approved again") + } catch { + XCTAssertEqual(error as? PaykitPaymentRequestError, .requestUnavailable) + } + } + func testRefreshKeepsCreatorSubscriptionWithoutGeneratingPayerPayment() async throws { let now = try XCTUnwrap(ISO8601DateFormatter().date(from: "2027-01-15T08:00:00Z")) let recurrence = PaymentRequestRecurrence( @@ -4337,6 +4375,7 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { for error in [ PaykitError.Transport(code: "transport_error", context: "response lost"), PaykitError.ConcurrentUpdate(code: "concurrent_update", context: "response read locked"), + PaykitError.SharedStateBusy(code: "shared_state_busy", context: "response read busy"), ] { let record = try paymentRequestRecord() let sdk = PaymentRequestSdkMock(records: [record]) diff --git a/BitkitTests/PrivatePaykitServiceTests.swift b/BitkitTests/PrivatePaykitServiceTests.swift index a0eb46a43..c7d2c70f2 100644 --- a/BitkitTests/PrivatePaykitServiceTests.swift +++ b/BitkitTests/PrivatePaykitServiceTests.swift @@ -639,6 +639,12 @@ final class PrivatePaykitServiceTests: XCTestCase { localRecoveryAttemptId: nil, localRecoveryMarkerCreatedAt: nil, localRecoveryMarkerLastError: nil, remoteRecoveryAttemptId: nil, remoteRecoveryMarkerObservedAt: nil ) + var linkingPeer = peer + linkingPeer.counterparty = "pubky5rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" + linkingPeer.state = .linking + var recoveringPeer = peer + recoveringPeer.counterparty = "pubky6rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" + recoveringPeer.state = .recoveryRequired let service = PrivatePaykitService() var failLookup = true var cleared = [String]() @@ -646,7 +652,7 @@ final class PrivatePaykitServiceTests: XCTestCase { let operations = PrivatePaykitService.EndpointCleanupOperations( linkedPeers: { if failLookup { throw PrivatePaykitError.privateUnavailable } - return [peer] + return [peer, linkingPeer, recoveringPeer] }, clearPaymentList: { cleared.append($0) diff --git a/journeys/subscriptions/cancellation-during-confirmation.xml b/journeys/subscriptions/cancellation-during-confirmation.xml new file mode 100644 index 000000000..2b5a6fa5f --- /dev/null +++ b/journeys/subscriptions/cancellation-during-confirmation.xml @@ -0,0 +1,16 @@ + + + Requires a linked regtest issuer and a payer funded for a 5,000-sat on-chain payment. + The issuer can cancel an accepted recurring request with a fixed P2WPKH endpoint and no + payment deadline. Verifies cancellation while confirmation is open. Cancellation after payment + preparation or during an authorization lookup is covered by unit tests. + + + Have the issuer propose a monthly subscription for 5,000 sats with no payment deadline and an explicit regtest P2WPKH endpoint + Accept the proposal in Bitkit and open its unpaid period to the send confirmation screen without confirming payment + Have the issuer cancel the subscription and keep Bitkit foregrounded until the payer's request state or logs confirm receipt of the cancellation + If the send confirmation is still open, try to confirm payment and verify it is refused + Verify no transaction to the request's receiving address was broadcast and no payment proof was created for the period + Return to Subscriptions and verify the canceled subscription offers no action to pay its unpaid period + + From f8f45ca484b5d474b97409faa3d87aa5a7eb7363 Mon Sep 17 00:00:00 2001 From: benk10 Date: Fri, 2 Oct 2026 11:31:05 -0500 Subject: [PATCH 24/68] fix: yield between paykit handshake advances --- Bitkit/Services/PubkyService.swift | 6 +-- BitkitTests/PaykitSdkClientConfigTests.swift | 48 +++++++++++++++++++ .../delete-and-readd-contact.xml | 3 ++ 3 files changed, 54 insertions(+), 3 deletions(-) diff --git a/Bitkit/Services/PubkyService.swift b/Bitkit/Services/PubkyService.swift index 58fa569e4..d687dd95e 100644 --- a/Bitkit/Services/PubkyService.swift +++ b/Bitkit/Services/PubkyService.swift @@ -752,7 +752,7 @@ actor PaykitSdkService { func ensureLinkWithPeer( _ counterparty: String, - maxAdvanceSteps: UInt32 = 8 + maxAdvanceSteps: UInt32 = 1 ) async throws -> LinkedPeerHandshakeReport { try await withStateRevisionTracking { sdk in try await sdk.ensureLinkWithPeer(counterparty: counterparty, maxAdvanceSteps: maxAdvanceSteps) @@ -922,7 +922,7 @@ actor PaykitSdkService { counterparty: counterparty, amount: amount, afterPrivatePaymentListVersion: afterPrivatePaymentListVersion, - maxAdvanceSteps: 8 + maxAdvanceSteps: 1 ) } } @@ -937,7 +937,7 @@ actor PaykitSdkService { counterparty: counterparty, paymentRequestId: paymentRequestId, afterPrivatePaymentListVersion: afterPrivatePaymentListVersion, - maxAdvanceSteps: 8 + maxAdvanceSteps: 1 ) } } diff --git a/BitkitTests/PaykitSdkClientConfigTests.swift b/BitkitTests/PaykitSdkClientConfigTests.swift index bb17b03b2..f47fa6914 100644 --- a/BitkitTests/PaykitSdkClientConfigTests.swift +++ b/BitkitTests/PaykitSdkClientConfigTests.swift @@ -8,6 +8,33 @@ final class PaykitSdkClientConfigTests: XCTestCase { "&secret=e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3s" + "&cid=paykit.test&cpk=5jsjx1o6fzu6aeeo697r3i5rx15zq41kikcye8wtwdqm4nb4tryo" + @MainActor + func testPrivateLinkCallsAdvanceOnceAndAllowPendingHandshakesToResume() async throws { + try await withCachedSessionKey { service, sdk, _ in + let pending = try await service.ensureLinkWithPeer("peer") + XCTAssertEqual(pending.state, .linking) + sdk.handshakeState = .linked + let linked = try await service.ensureLinkWithPeer("peer") + XCTAssertEqual(linked.state, .linked) + + let preparations: [() async throws -> PreparedPrivateContactPayment] = [ + { try await service.prepareAndResolvePrivateContactPayment(counterparty: "peer", afterPrivatePaymentListVersion: nil) }, + { + try await service.prepareAndResolvePrivatePaymentRequest( + counterparty: "peer", paymentRequestId: "request", afterPrivatePaymentListVersion: nil + ) + }, + ] + for prepare in preparations { + do { + _ = try await prepare() + XCTFail("Pending preparation must remain retryable") + } catch PaykitError.RecoveryRequired {} + } + XCTAssertEqual(sdk.handshakeAdvanceSteps, [1, 1, 1, 1]) + } + } + func testRegisteredIdentityCannotActivateAfterWalletWipe() async throws { let keys: [KeychainEntryType] = [.paykitSession] let saved = try keys.map { try Keychain.load(key: $0) } @@ -669,6 +696,8 @@ private final class UnavailableProfileManager: PubkyProfileManager { } private final class CacheActivationSdk: PaykitSdk, @unchecked Sendable { + var handshakeState: LinkedPeerState = .linking + var handshakeAdvanceSteps: [UInt32] = [] var previousKey: String? var capability: PubkyIdentityCapability = .signedOut var registry: PaykitAppRegistry? @@ -682,6 +711,25 @@ private final class CacheActivationSdk: PaykitSdk, @unchecked Sendable { var publicationCalls = 0 var backupError: Error? + override func ensureLinkWithPeer(counterparty: String, maxAdvanceSteps: UInt32) async throws -> LinkedPeerHandshakeReport { + handshakeAdvanceSteps.append(maxAdvanceSteps) + return LinkedPeerHandshakeReport(counterparty: counterparty, state: handshakeState, generation: 1, handshakeRole: nil) + } + + override func prepareAndResolvePrivateContactPayment( + counterparty _: String, amount _: PaymentAmountContext?, afterPrivatePaymentListVersion _: UInt64?, maxAdvanceSteps: UInt32 + ) async throws -> PreparedPrivateContactPayment { + handshakeAdvanceSteps.append(maxAdvanceSteps) + throw PaykitError.RecoveryRequired(code: "recovery_required", context: "Handshake pending") + } + + override func prepareAndResolvePrivatePaymentRequest( + counterparty _: String, paymentRequestId _: String, afterPrivatePaymentListVersion _: UInt64?, maxAdvanceSteps: UInt32 + ) async throws -> PreparedPrivateContactPayment { + handshakeAdvanceSteps.append(maxAdvanceSteps) + throw PaykitError.RecoveryRequired(code: "recovery_required", context: "Handshake pending") + } + override func identityStatus() async throws -> IdentityStatus? { IdentityStatus(publicKey: previousKey, capability: capability) } diff --git a/journeys/payment-requests/delete-and-readd-contact.xml b/journeys/payment-requests/delete-and-readd-contact.xml index 9c0348738..2308be133 100644 --- a/journeys/payment-requests/delete-and-readd-contact.xml +++ b/journeys/payment-requests/delete-and-readd-contact.xml @@ -14,7 +14,10 @@ Verify no Payment Request confirmation appears for "After deletion" and no payable request from the deleted contact appears in Payment Requests Restart the payer app, return Home, and wait for two foreground request polling intervals Verify requests from the deleted contact remain unavailable for payment + Move the requester app to the background On the payer, explicitly add the requester's Pubky key as a contact again + While the requester remains in the background, return Home, reopen Contacts, and verify the saved contact remains visible + Bring the requester app back to the foreground Keep both apps in the foreground until the private connection is established again On the requester, send a new Payment Request with the note "After readd" On the payer, open "After readd" from Payment Requests and verify its Payment Request confirmation shows the saved contact name From d78aaa3266874adb89a951ea6f2e3de165bdef74 Mon Sep 17 00:00:00 2001 From: benk10 Date: Fri, 2 Oct 2026 12:22:26 -0500 Subject: [PATCH 25/68] fix: consolidate paykit background retries --- Bitkit/AppScene.swift | 44 ++----------- Bitkit/Managers/ContactsManager.swift | 5 +- .../PrivatePaykitService+Backup.swift | 4 -- .../PrivatePaykitService+Contacts.swift | 62 ++----------------- .../Services/PrivatePaykitService+State.swift | 4 -- Bitkit/Services/PrivatePaykitService.swift | 9 --- BitkitTests/PrivatePaykitServiceTests.swift | 23 ++++--- .../delete-and-readd-contact.xml | 1 + 8 files changed, 27 insertions(+), 125 deletions(-) diff --git a/Bitkit/AppScene.swift b/Bitkit/AppScene.swift index 21c0c55fd..152ad6b53 100644 --- a/Bitkit/AppScene.swift +++ b/Bitkit/AppScene.swift @@ -205,8 +205,6 @@ struct PaykitPaymentRequestPollingSchedule { } struct AppScene: View { - private static let initialPaykitSyncRetryDelays = Array(repeating: Duration.seconds(2), count: 14) - @Environment(\.scenePhase) var scenePhase @EnvironmentObject private var session: SessionManager @@ -242,7 +240,6 @@ struct AppScene: View { @State private var receivedPaymentBackfillCache = PaykitReceivedPaymentBackfillCache( activityChanges: CoreService.shared.activity.activitiesChangedPublisher ) - @State private var initialPaykitSyncGeneration = 0 @State private var hideSplash = false @State private var removeSplash = false @@ -370,7 +367,6 @@ struct AppScene: View { await pubkyProfile.retrySessionRestoration() } .task(id: [scenePhase == .active, network.isConnected]) { await pollIncomingPaykitPaymentRequests() } - .task(id: initialPaykitSyncGeneration) { await pollIncomingPaykitPaymentRequestsDuringInitialSync() } .task { await handlePendingPaykitSubscriptionNotification() } .onChange(of: currency.hasStaleData) { _, newValue in handleCurrencyStaleData(newValue) } .onChange(of: wallet.walletExists) { _, newValue in handleWalletExistsChange(newValue) } @@ -468,18 +464,9 @@ struct AppScene: View { let publicKeys = contacts.map(\.publicKey) Task { await PrivatePaykitService.shared.prepareSavedContacts(publicKeys, wallet: wallet) - await PrivatePaykitService.shared.startInitialLinkBurst( - for: publicKeys, - savedPublicKeys: publicKeys, - wallet: wallet, - reason: "contact sync" - ) await refreshIncomingPaykitPaymentRequests() } } - .onReceive(PrivatePaykitService.initialLinkBurstStartedPublisher) { - initialPaykitSyncGeneration += 1 - } .onReceive(PaykitPaymentProofService.proofStateChangedPublisher) { Task { await refreshIncomingPaykitPaymentRequests() } } @@ -1044,11 +1031,6 @@ struct AppScene: View { contactsManager.contacts.map(\.publicKey), wallet: wallet ) - await PrivatePaykitService.shared.startInitialLinkBurst( - for: contactsManager.contacts.map(\.publicKey), - wallet: wallet, - reason: "wallet started" - ) await refreshIncomingPaykitPaymentRequests() } } else { @@ -1091,11 +1073,10 @@ struct AppScene: View { if PaykitFeatureFlags.isUIEnabled { await refreshPrivateOnlyPaykitApp() let contactPublicKeys = contactsManager.contacts.map(\.publicKey) - await PrivatePaykitService.shared.startInitialLinkBurst( + await PrivatePaykitService.shared.refreshSavedContactEndpoints( for: contactPublicKeys, savedPublicKeys: contactPublicKeys, - wallet: wallet, - reason: "foreground" + wallet: wallet ) await refreshIncomingPaykitPaymentRequests() } @@ -1186,6 +1167,7 @@ struct AppScene: View { guard scenePhase == .active, network.isConnected else { return } await PubkyService.republishIdentityIfNeeded(publicKey: pubkyProfile.publicKey) + await refreshIncomingPaykitPaymentRequests() var schedule = PaykitPaymentRequestPollingSchedule() while !Task.isCancelled { do { @@ -1216,21 +1198,6 @@ struct AppScene: View { } } - private func pollIncomingPaykitPaymentRequestsDuringInitialSync() async { - guard scenePhase == .active else { return } - - await refreshIncomingPaykitPaymentRequests() - for delay in Self.initialPaykitSyncRetryDelays { - do { - try await Task.sleep(for: delay) - } catch { - return - } - guard scenePhase == .active else { return } - await refreshIncomingPaykitPaymentRequests(refreshMaintenance: false) - } - } - private func presentNextIncomingPaykitPaymentRequest() async { guard scenePhase == .active, isPinVerified || !settings.pinEnabled, @@ -1567,11 +1534,10 @@ struct AppScene: View { } if PaykitFeatureFlags.isUIEnabled { let contactPublicKeys = contactsManager.contacts.map(\.publicKey) - await PrivatePaykitService.shared.startInitialLinkBurst( + await PrivatePaykitService.shared.refreshSavedContactEndpoints( for: contactPublicKeys, savedPublicKeys: contactPublicKeys, - wallet: wallet, - reason: "network restored" + wallet: wallet ) } await refreshIncomingPaykitPaymentRequests() diff --git a/Bitkit/Managers/ContactsManager.swift b/Bitkit/Managers/ContactsManager.swift index 78182ab1e..fd80b6967 100644 --- a/Bitkit/Managers/ContactsManager.swift +++ b/Bitkit/Managers/ContactsManager.swift @@ -340,11 +340,10 @@ class ContactsManager: ObservableObject { do { _ = try await PubkyService.saveContact(publicKey: prefixedKey, label: contact.profile.name) - await PrivatePaykitService.shared.startInitialLinkBurst( + await PrivatePaykitService.shared.refreshSavedContactEndpoints( for: [prefixedKey], savedPublicKeys: contacts.map(\.publicKey), - wallet: wallet, - reason: "contact link refresh" + wallet: wallet ) } catch is CancellationError { return diff --git a/Bitkit/Services/PrivatePaykitService+Backup.swift b/Bitkit/Services/PrivatePaykitService+Backup.swift index 987cfb91e..48fbc2904 100644 --- a/Bitkit/Services/PrivatePaykitService+Backup.swift +++ b/Bitkit/Services/PrivatePaykitService+Backup.swift @@ -26,10 +26,6 @@ extension PrivatePaykitService { // Wallet restore must not rewind the identity's live state or Noise counters. try Keychain.upsert(key: .paykitRecoveryBackup, data: Data(decoded.sdkState.utf8)) } - initialLinkBurstTask?.cancel() - initialLinkBurstTask = nil - initialLinkBurstPublicKeys.removeAll() - initialLinkBurstGeneration += 1 pendingMessageDrainRetryTask?.cancel() pendingMessageDrainRetryTask = nil pendingMessageDrainRetryKeys.removeAll() diff --git a/Bitkit/Services/PrivatePaykitService+Contacts.swift b/Bitkit/Services/PrivatePaykitService+Contacts.swift index df725c115..89fe89961 100644 --- a/Bitkit/Services/PrivatePaykitService+Contacts.swift +++ b/Bitkit/Services/PrivatePaykitService+Contacts.swift @@ -96,56 +96,6 @@ extension PrivatePaykitService { ) } - func startInitialLinkBurst( - for publicKeys: [String], - savedPublicKeys: [String]? = nil, - wallet: WalletViewModel, - reason: String - ) { - if let savedPublicKeys { - _ = rememberSavedContacts(savedPublicKeys + publicKeys, replacing: false) - } - - let publicKeys = normalizedSavedContactKeys(publicKeys) - guard !publicKeys.isEmpty else { return } - - initialLinkBurstPublicKeys.formUnion(publicKeys) - initialLinkBurstGeneration += 1 - let generation = initialLinkBurstGeneration - initialLinkBurstTask?.cancel() - Self.initialLinkBurstStartedSubject.send() - - initialLinkBurstTask = Task { [reason, generation] in - defer { - if generation == initialLinkBurstGeneration { - initialLinkBurstTask = nil - initialLinkBurstPublicKeys.removeAll() - } - } - for delay in [UInt64(0)] + Self.initialLinkBurstRetryDelays { - if delay > 0 { - try? await Task.sleep(nanoseconds: delay) - } - guard !Task.isCancelled, - generation == initialLinkBurstGeneration - else { return } - - let publicKeys = Array(initialLinkBurstPublicKeys) - _ = await refreshSavedContactEndpointsReturningError( - for: publicKeys, - wallet: wallet, - forceRefreshLightning: false, - requireImmediatePublication: false, - reason: "\(reason) initial link burst" - ) - guard !Task.isCancelled, generation == initialLinkBurstGeneration else { return } - let pendingKeys = await pendingPrivateMessageDrainKeys(publicKeys, retryMissingPeers: true) - guard !Task.isCancelled, generation == initialLinkBurstGeneration else { return } - if pendingKeys.isEmpty { break } - } - } - } - @discardableResult func refreshSavedContactEndpointsReturningError( for publicKeys: [String], @@ -519,7 +469,7 @@ extension PrivatePaykitService { } private func drainAndSchedulePrivateLinkRetries(reason: String, retryKeys: [String]) async { - let retryKeys = Array(Set(retryKeys)) + let retryKeys = Array(Set(retryKeys).subtracting(pendingMessageDrainRetryKeys)) guard !retryKeys.isEmpty else { return } let drainKeys = await pendingPrivateMessageDrainKeys(retryKeys, retryMissingPeers: true) @@ -550,8 +500,6 @@ extension PrivatePaykitService { } try await PaykitSdkService.shared.processPendingPrivateMessages() try await PaykitSdkService.shared.receivePrivateMessagesFromLinkedPeers() - try await PaykitSdkService.shared.processPendingPrivateMessages() - try await PaykitSdkService.shared.receivePrivateMessagesFromLinkedPeers() } catch { Logger.warn("Failed to process pending private Paykit messages during \(reason): \(error)", context: "PrivatePaykit") } @@ -561,7 +509,9 @@ extension PrivatePaykitService { let retryKeys = Set(retryKeys) guard !retryKeys.isEmpty else { return } + let hasActiveRetry = pendingMessageDrainRetryTask?.isCancelled == false && !pendingMessageDrainRetryKeys.isEmpty pendingMessageDrainRetryKeys.formUnion(retryKeys) + guard !hasActiveRetry else { return } pendingMessageDrainRetryGeneration += 1 let retryGeneration = pendingMessageDrainRetryGeneration pendingMessageDrainRetryTask?.cancel() @@ -573,13 +523,13 @@ extension PrivatePaykitService { guard !Task.isCancelled else { return } try? await Task.sleep(nanoseconds: delay) guard !Task.isCancelled else { return } - await PrivatePaykitService.shared.drainPendingPrivateMessageRetryKeys(reason: "\(reason) retry") - let hasPending = await PrivatePaykitService.shared.hasPendingMessageDrainRetryKeys(generation: retryGeneration) + await self.drainPendingPrivateMessageRetryKeys(reason: "\(reason) retry") + let hasPending = self.hasPendingMessageDrainRetryKeys(generation: retryGeneration) guard hasPending else { break } retryIndex += 1 } guard !Task.isCancelled else { return } - await PrivatePaykitService.shared.finishPendingPrivateMessageDrainRetries(generation: retryGeneration) + self.finishPendingPrivateMessageDrainRetries(generation: retryGeneration) } } diff --git a/Bitkit/Services/PrivatePaykitService+State.swift b/Bitkit/Services/PrivatePaykitService+State.swift index 49eb67090..b24011a14 100644 --- a/Bitkit/Services/PrivatePaykitService+State.swift +++ b/Bitkit/Services/PrivatePaykitService+State.swift @@ -4,10 +4,6 @@ import Foundation extension PrivatePaykitService { func closeAndClear() async { - initialLinkBurstTask?.cancel() - initialLinkBurstTask = nil - initialLinkBurstPublicKeys.removeAll() - initialLinkBurstGeneration += 1 pendingMessageDrainRetryTask?.cancel() pendingMessageDrainRetryTask = nil pendingMessageDrainRetryKeys.removeAll() diff --git a/Bitkit/Services/PrivatePaykitService.swift b/Bitkit/Services/PrivatePaykitService.swift index b07d1a7a3..438a126b1 100644 --- a/Bitkit/Services/PrivatePaykitService.swift +++ b/Bitkit/Services/PrivatePaykitService.swift @@ -48,16 +48,11 @@ actor PrivatePaykitService { static let shared = PrivatePaykitService() private static let walletBackupDataChangedSubject = PassthroughSubject() - static let initialLinkBurstStartedSubject = PassthroughSubject() nonisolated static var walletBackupDataChangedPublisher: AnyPublisher { walletBackupDataChangedSubject.eraseToAnyPublisher() } - nonisolated static var initialLinkBurstStartedPublisher: AnyPublisher { - initialLinkBurstStartedSubject.eraseToAnyPublisher() - } - static let invoiceRefreshBufferSeconds: TimeInterval = 30 * 60 static let maxReceivedInvoicePaymentHashesPerContact = 100 static let publishingEnabledKey = "sharesPrivatePaykitEndpoints" @@ -73,7 +68,6 @@ actor PrivatePaykitService { 45_000_000_000, 90_000_000_000, ] - static let initialLinkBurstRetryDelays = Array(repeating: UInt64(2_000_000_000), count: 14) var state: PrivatePaykitState var knownSavedContactKeys: Set = [] @@ -82,9 +76,6 @@ actor PrivatePaykitService { var pendingMessageDrainRetryGeneration = 0 // A restart makes the send outcome uncertain, so only live attempts may release a consumed list. var privatePaymentListConsumptions: [PrivatePaymentListConsumptionKey: PrivatePaymentListConsumption] = [:] - var initialLinkBurstTask: Task? - var initialLinkBurstPublicKeys: Set = [] - var initialLinkBurstGeneration = 0 var prePaymentPublicationKeys: Set = [] private let publicationLock = PrivatePaykitPublicationLock() diff --git a/BitkitTests/PrivatePaykitServiceTests.swift b/BitkitTests/PrivatePaykitServiceTests.swift index c7d2c70f2..5d42119a5 100644 --- a/BitkitTests/PrivatePaykitServiceTests.swift +++ b/BitkitTests/PrivatePaykitServiceTests.swift @@ -12,11 +12,6 @@ final class PrivatePaykitServiceTests: XCTestCase { snapshotAppDefaultsDomain() } - func testInitialLinkBurstUsesBoundedTwoSecondRetryCadence() { - XCTAssertEqual(PrivatePaykitService.initialLinkBurstRetryDelays.count, 14) - XCTAssertTrue(PrivatePaykitService.initialLinkBurstRetryDelays.allSatisfy { $0 == 2_000_000_000 }) - } - func testPrivateMessageDrainKeysRespectLinkStateAndPendingOutbound() { let peers: [String: LinkedPeerState] = [ "idle": .linked, "pending": .linked, "new": .notLinked, @@ -527,17 +522,25 @@ final class PrivatePaykitServiceTests: XCTestCase { XCTAssertFalse(contactState?.hasContactOwnedCacheState == true) } - func testPrivatePaymentRecoveryUsesCounterpartyIdentity() async { + func testPrivatePaymentRecoveryKeepsActiveRetryWhenContactsAreAdded() async { let service = PrivatePaykitService() let publicKey = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" + let otherPublicKey = "pubky1rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" await service.schedulePrivatePaymentRecovery(for: publicKey) + let generation = await service.pendingMessageDrainRetryGeneration + await service.schedulePrivatePaymentRecovery(for: publicKey) + await service.schedulePrivatePaymentRecovery(for: otherPublicKey) let retryKeys = await service.testPendingMessageDrainRetryKeys() - XCTAssertEqual( - retryKeys, - [publicKey] - ) + let repeatedGeneration = await service.pendingMessageDrainRetryGeneration + XCTAssertEqual(retryKeys, [publicKey, otherPublicKey]) + XCTAssertEqual(repeatedGeneration, generation) + await service.clearTestPendingMessageDrainRetries() + + await service.schedulePrivatePaymentRecovery(for: otherPublicKey) + let restartedGeneration = await service.pendingMessageDrainRetryGeneration + XCTAssertGreaterThan(restartedGeneration, generation) await service.clearTestPendingMessageDrainRetries() } diff --git a/journeys/payment-requests/delete-and-readd-contact.xml b/journeys/payment-requests/delete-and-readd-contact.xml index 2308be133..debc8ab0c 100644 --- a/journeys/payment-requests/delete-and-readd-contact.xml +++ b/journeys/payment-requests/delete-and-readd-contact.xml @@ -17,6 +17,7 @@ Move the requester app to the background On the payer, explicitly add the requester's Pubky key as a contact again While the requester remains in the background, return Home, reopen Contacts, and verify the saved contact remains visible + Keep the payer in the foreground for two minutes, then background and foreground it twice while the requester remains in the background Bring the requester app back to the foreground Keep both apps in the foreground until the private connection is established again On the requester, send a new Payment Request with the note "After readd" From dc785b485fe769f25cbd1b1f95c2d194211fc05c Mon Sep 17 00:00:00 2001 From: benk10 Date: Fri, 2 Oct 2026 14:03:58 -0500 Subject: [PATCH 26/68] fix: receive Paykit messages on each inbox poll --- Bitkit/AppScene.swift | 2 +- .../PaykitPaymentRequestService.swift | 29 +++++++++-------- .../PaykitPaymentRequestServiceTests.swift | 32 +++++++++---------- journeys/payment-requests/README.md | 9 +++--- 4 files changed, 36 insertions(+), 36 deletions(-) diff --git a/Bitkit/AppScene.swift b/Bitkit/AppScene.swift index 152ad6b53..556c06d4e 100644 --- a/Bitkit/AppScene.swift +++ b/Bitkit/AppScene.swift @@ -1111,7 +1111,7 @@ struct AppScene: View { await PaykitPaymentProofService.shared.reconcile() } guard let identity = pubkyProfile.publicKey else { return } - await paykitPaymentRequestManager.refresh(syncPrivateMessages: refreshMaintenance) + await paykitPaymentRequestManager.refresh(processOutgoingMessages: refreshMaintenance) guard pubkyProfile.authState == .authenticated, PubkyPublicKeyFormat.matches(identity, pubkyProfile.publicKey) else { return } diff --git a/Bitkit/Services/PaykitPaymentRequestService.swift b/Bitkit/Services/PaykitPaymentRequestService.swift index 44aa241d5..f859ce412 100644 --- a/Bitkit/Services/PaykitPaymentRequestService.swift +++ b/Bitkit/Services/PaykitPaymentRequestService.swift @@ -548,12 +548,12 @@ struct PaykitPaymentRequestService { self.logWarning = logWarning } - func synchronize(syncPrivateMessages: Bool = true) async throws -> PaykitPaymentRequestSnapshot { - if syncPrivateMessages { + func synchronize(processOutgoingMessages: Bool = true) async throws -> PaykitPaymentRequestSnapshot { + if processOutgoingMessages { try await processPendingMessages() - let intakeReports = try await sdk.receivePrivateMessagesFromLinkedPeers() - logIntakeFailures(intakeReports) } + let intakeReports = try await sdk.receivePrivateMessagesFromLinkedPeers() + logIntakeFailures(intakeReports) let synchronizationDate = now() let sharedRecords = try await sdk.sharedPaymentRequests() let records = sharedRecords.filter(PaykitSdkService.isBitkitPaymentRequest) @@ -1134,7 +1134,7 @@ final class PaykitPaymentRequestManager { private var expiredRequestedPresentations: [PaykitPaymentRequest] = [] private var unavailableRequestedPresentations: [PaykitPaymentRequest] = [] private var isPresentingRequests = false - private var refreshTask: (syncPrivateMessages: Bool, task: Task)? + private var refreshTask: (processOutgoingMessages: Bool, task: Task)? private var expirationTask: Task? private var presentationRetryTask: Task? private var refreshGeneration = 0 @@ -1154,6 +1154,7 @@ final class PaykitPaymentRequestManager { private var subscriptionClockOffset: TimeInterval { subscriptionNow().timeIntervalSince(now()) } + private var presentedSubscriptionProposalIds: Set = [] private var dismissedSubscriptionPaymentIds: Set = [] private var persistedSubscriptionState = PaykitSubscriptionState() @@ -1492,8 +1493,8 @@ final class PaykitPaymentRequestManager { return subscription } - func refresh(syncPrivateMessages: Bool = true) async { - await refresh(excludingProtectedRequestId: nil, syncPrivateMessages: syncPrivateMessages) + func refresh(processOutgoingMessages: Bool = true) async { + await refresh(excludingProtectedRequestId: nil, processOutgoingMessages: processOutgoingMessages) } /// Applies a changed subscription clock offset: waits for a refresh already reading the old clock, then refreshes again. @@ -1517,12 +1518,12 @@ final class PaykitPaymentRequestManager { ) } - private func refresh(excludingProtectedRequestId: PaykitPaymentRequest.ID?, syncPrivateMessages: Bool = true) async { + private func refresh(excludingProtectedRequestId: PaykitPaymentRequest.ID?, processOutgoingMessages: Bool = true) async { if let refreshTask { let generation = stateGeneration await refreshTask.task.value - if syncPrivateMessages, !refreshTask.syncPrivateMessages, generation == stateGeneration, !Task.isCancelled { - await refresh(excludingProtectedRequestId: excludingProtectedRequestId, syncPrivateMessages: true) + if processOutgoingMessages, !refreshTask.processOutgoingMessages, generation == stateGeneration, !Task.isCancelled { + await refresh(excludingProtectedRequestId: excludingProtectedRequestId, processOutgoingMessages: true) } return } @@ -1534,12 +1535,12 @@ final class PaykitPaymentRequestManager { await performRefresh( generation: generation, excludingProtectedRequestId: excludingProtectedRequestId, - syncPrivateMessages: syncPrivateMessages + processOutgoingMessages: processOutgoingMessages ) guard generation == refreshGeneration else { return } refreshTask = nil } - refreshTask = (syncPrivateMessages, task) + refreshTask = (processOutgoingMessages, task) await task.value } @@ -2064,10 +2065,10 @@ final class PaykitPaymentRequestManager { private func performRefresh( generation: Int, excludingProtectedRequestId: PaykitPaymentRequest.ID?, - syncPrivateMessages: Bool + processOutgoingMessages: Bool ) async { do { - let snapshot = try await service.synchronize(syncPrivateMessages: syncPrivateMessages) + let snapshot = try await service.synchronize(processOutgoingMessages: processOutgoingMessages) guard generation == refreshGeneration, let activeIdentity else { return } async let completedProofKinds = completedPaymentProofKinds(activeIdentity) async let inFlightRequestIds = inFlightPaymentRequestIds(activeIdentity) diff --git a/BitkitTests/PaykitPaymentRequestServiceTests.swift b/BitkitTests/PaykitPaymentRequestServiceTests.swift index 3b6e53ad5..2362671b3 100644 --- a/BitkitTests/PaykitPaymentRequestServiceTests.swift +++ b/BitkitTests/PaykitPaymentRequestServiceTests.swift @@ -9,26 +9,26 @@ import XCTest @MainActor final class PaykitPaymentRequestServiceTests: XCTestCase { - func testSharedStateRefreshSkipsPrivateMessages() async throws { + func testInboxRefreshReceivesMessagesWithoutDrainingOutboundWork() async throws { let record = try paymentRequestRecord() let sdk = PaymentRequestSdkMock(records: []) let manager = paymentRequestManager(sdk: sdk) await manager.refresh() await sdk.setRecords([record]) - await manager.refresh(syncPrivateMessages: false) + await manager.refresh(processOutgoingMessages: false) XCTAssertEqual(manager.pendingRequests.map(\.paymentRequestId), [record.paymentRequestId]) let snapshot = await sdk.snapshot() XCTAssertEqual(snapshot.processCallCount, 1) - XCTAssertEqual(snapshot.receiveCallCount, 1) + XCTAssertEqual(snapshot.receiveCallCount, 2) } - func testFullRefreshAfterOverlappingSharedStateRefreshReceivesMessages() async throws { + func testFullRefreshAfterOverlappingInboxRefreshDrainsOutboundWork() async throws { let sdk = PaymentRequestSdkMock(records: []) let manager = paymentRequestManager(sdk: sdk) await sdk.pauseNextPaymentRequestList() - let sharedStateRefresh = Task { await manager.refresh(syncPrivateMessages: false) } + let inboxRefresh = Task { await manager.refresh(processOutgoingMessages: false) } try await waitUntil { await sdk.paymentRequestListIsPaused() } let fullRefreshStarted = expectation(description: "Full refresh started") let fullRefresh = Task { @@ -38,19 +38,19 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { await fulfillment(of: [fullRefreshStarted], timeout: 1) await sdk.resumePaymentRequestList() - await sharedStateRefresh.value + await inboxRefresh.value await fullRefresh.value let snapshot = await sdk.snapshot() XCTAssertEqual(snapshot.processCallCount, 1) - XCTAssertEqual(snapshot.receiveCallCount, 1) + XCTAssertEqual(snapshot.receiveCallCount, 2) } - func testClearInvalidatesFullRefreshWaitingForSharedState() async throws { + func testClearInvalidatesFullRefreshWaitingForInbox() async throws { let sdk = try PaymentRequestSdkMock(records: [paymentRequestRecord()]) let manager = paymentRequestManager(sdk: sdk) await sdk.pauseNextPaymentRequestList() - let sharedStateRefresh = Task { await manager.refresh(syncPrivateMessages: false) } + let inboxRefresh = Task { await manager.refresh(processOutgoingMessages: false) } try await waitUntil { await sdk.paymentRequestListIsPaused() } let fullRefreshStarted = expectation(description: "Full refresh started") let fullRefresh = Task { @@ -61,14 +61,14 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { manager.clear() await sdk.resumePaymentRequestList() - await sharedStateRefresh.value + await inboxRefresh.value await fullRefresh.value XCTAssertTrue(manager.pendingRequests.isEmpty) XCTAssertTrue(manager.historyRequests.isEmpty) let snapshot = await sdk.snapshot() XCTAssertEqual(snapshot.processCallCount, 0) - XCTAssertEqual(snapshot.receiveCallCount, 0) + XCTAssertEqual(snapshot.receiveCallCount, 1) } func testReceivedPaymentContactsIncludeSharedServerRequestsAndLatePayments() throws { @@ -698,7 +698,7 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { let offset: TimeInterval = 10 * 24 * 60 * 60 let shiftedNow = now.addingTimeInterval(offset) - let dueRequests = subscription.requests(through: shiftedNow, acceptedAt: acceptedAt[subscription.id]!) + let dueRequests = try subscription.requests(through: shiftedNow, acceptedAt: XCTUnwrap(acceptedAt[subscription.id])) let jumpedRequest = try XCTUnwrap(dueRequests.first { $0.billingPeriod?.sdkValue.startsAt == "2027-01-22T08:00:00Z" }) await scheduler.synchronize( [subscription], @@ -737,7 +737,7 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { let offset: TimeInterval = 365 * 24 * 60 * 60 let shiftedNow = now.addingTimeInterval(offset) - let dueRequests = subscription.requests(through: shiftedNow, acceptedAt: acceptedAt[subscription.id]!) + let dueRequests = try subscription.requests(through: shiftedNow, acceptedAt: XCTUnwrap(acceptedAt[subscription.id])) XCTAssertGreaterThan(dueRequests.count, 300) await scheduler.synchronize( [subscription], @@ -809,7 +809,7 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { let offset: TimeInterval = 20 * 24 * 60 * 60 let shiftedNow = now.addingTimeInterval(offset) - let dueRequests = subscription.requests(through: shiftedNow, acceptedAt: acceptedAt[subscription.id]!) + let dueRequests = try subscription.requests(through: shiftedNow, acceptedAt: XCTUnwrap(acceptedAt[subscription.id])) XCTAssertFalse(subscription.isActive(at: shiftedNow)) await scheduler.synchronize( [subscription], @@ -843,7 +843,7 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { let offset: TimeInterval = 10 * 24 * 60 * 60 let shiftedNow = now.addingTimeInterval(offset) - let dueIds = Set(subscription.requests(through: shiftedNow, acceptedAt: acceptedAt[subscription.id]!).map(\.id)) + let dueIds = try Set(subscription.requests(through: shiftedNow, acceptedAt: XCTUnwrap(acceptedAt[subscription.id])).map(\.id)) await center.pauseNextPendingRequests() let first = Task { await scheduler.synchronize( @@ -1387,7 +1387,7 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { try await waitUntil { await sdk.linkedPeersIsPaused() } record.state = .canceled await sdk.setRecords([record]) - await manager.refresh(syncPrivateMessages: false) + await manager.refresh(processOutgoingMessages: false) await sdk.resumeLinkedPeers() XCTAssertFalse(manager.isApprovedForPayment(request)) diff --git a/journeys/payment-requests/README.md b/journeys/payment-requests/README.md index 6e56f3895..30d1296f2 100644 --- a/journeys/payment-requests/README.md +++ b/journeys/payment-requests/README.md @@ -31,11 +31,10 @@ That run established the issuer shapes captured by the fixture: lowercase `btc`, ## Foreground synchronization -Bitkit refreshes shared request state every 10 seconds. Private messages are synchronized on -startup and explicit refreshes, and on maintenance rounds that back off from 30 to 60 to 120 seconds. -Startup link retries stop when no link or outbound work remains; subsequent startup request polls -read shared state without repeating private-message synchronization or target discovery. -A new peer message can therefore take up to two minutes plus synchronization time to appear during steady polling. +Bitkit receives private messages and refreshes shared request state every 10 seconds. +Outbound retries, endpoint publication, and target discovery run on startup, explicit refreshes, +and maintenance rounds that back off from 30 to 60 to 120 seconds. +While foregrounded and online, new peer messages are checked on the next inbox poll, plus synchronization time. ## Accepting install From 89c4fd82bd51f5a0efc7cc9e29b521805f2bec1e Mon Sep 17 00:00:00 2001 From: benk10 Date: Fri, 2 Oct 2026 14:20:10 -0500 Subject: [PATCH 27/68] fix: report private endpoint withdrawal failures --- .../PrivatePaykitService+Contacts.swift | 31 +++++++++++++------ BitkitTests/PrivatePaykitServiceTests.swift | 11 +++++-- 2 files changed, 30 insertions(+), 12 deletions(-) diff --git a/Bitkit/Services/PrivatePaykitService+Contacts.swift b/Bitkit/Services/PrivatePaykitService+Contacts.swift index 89fe89961..b524a3074 100644 --- a/Bitkit/Services/PrivatePaykitService+Contacts.swift +++ b/Bitkit/Services/PrivatePaykitService+Contacts.swift @@ -168,11 +168,17 @@ extension PrivatePaykitService { for publicKey in cleanupKeys { do { guard let report = try await operations.clearPaymentList(publicKey) else { continue } + logPrivatePaymentListDeliveryFailures(report, reason: "cleanup") if !report.failedToQueue.isEmpty || !report.failedToDeliver.isEmpty { throw PrivatePaykitError.privateUnavailable } clearedRetryKeys.append(publicKey) } catch { + Logger.warn( + "Failed to clear private Paykit endpoints for \(PubkyPublicKeyFormat.redacted(publicKey)): " + + PaykitResolutionFailureDiagnostics.reason(for: error), + context: "PrivatePaykit" + ) failedPublicKeys.insert(publicKey) firstError = firstError ?? error } @@ -182,6 +188,10 @@ extension PrivatePaykitService { await operations.drainMessages(clearedRetryKeys) let pendingRetryKeys = await operations.pendingDrainKeys(clearedRetryKeys) if !pendingRetryKeys.isEmpty { + Logger.warn( + "Private Paykit endpoint withdrawal remains pending for \(pendingRetryKeys.map(PubkyPublicKeyFormat.redacted))", + context: "PrivatePaykit" + ) failedPublicKeys.formUnion(pendingRetryKeys) firstError = firstError ?? PrivatePaykitError.privateUnavailable } @@ -623,7 +633,7 @@ extension PrivatePaykitService { } private func applyPrivatePaymentListDeliveryReport(_ report: PrivatePaymentListDeliveryReport, reason: String) -> Error? { - var firstError: Error? + logPrivatePaymentListDeliveryFailures(report, reason: reason) var didChangeState = false for change in report.queued { @@ -636,6 +646,14 @@ extension PrivatePaykitService { didChangeState = clearPublishedPrivatePaymentList(publicKey: publicKey) || didChangeState } + if didChangeState { + persistState(markWalletBackup: true) + } + + return report.failedToQueue.isEmpty && report.failedToDeliver.isEmpty ? nil : PrivatePaykitError.privateUnavailable + } + + private func logPrivatePaymentListDeliveryFailures(_ report: PrivatePaymentListDeliveryReport, reason: String) { for change in report.failedToQueue { let publicKey = PubkyPublicKeyFormat.normalized(change.counterparty) ?? change.counterparty Logger.warn( @@ -643,23 +661,16 @@ extension PrivatePaykitService { "\(change.error?.redactedContext() ?? "unknown error")", context: "PrivatePaykit" ) - firstError = firstError ?? PrivatePaykitError.privateUnavailable } for failure in report.failedToDeliver { let publicKey = PubkyPublicKeyFormat.normalized(failure.counterparty) ?? failure.counterparty Logger.warn( - "Failed to deliver private Paykit endpoints for \(PubkyPublicKeyFormat.redacted(publicKey)) during \(reason): \(failure.error)", + "Failed to deliver private Paykit endpoints for \(PubkyPublicKeyFormat.redacted(publicKey)) during \(reason): " + + failure.error.redactedContext(), context: "PrivatePaykit" ) - firstError = firstError ?? PrivatePaykitError.privateUnavailable } - - if didChangeState { - persistState(markWalletBackup: true) - } - - return firstError } func normalizedSavedContactKeys(_ publicKeys: [String]) -> [String] { diff --git a/BitkitTests/PrivatePaykitServiceTests.swift b/BitkitTests/PrivatePaykitServiceTests.swift index 5d42119a5..7459ffb3a 100644 --- a/BitkitTests/PrivatePaykitServiceTests.swift +++ b/BitkitTests/PrivatePaykitServiceTests.swift @@ -588,7 +588,7 @@ final class PrivatePaykitServiceTests: XCTestCase { func testCleanupFailuresKeepRegistryReconciliationPending() async throws { let publicKey = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" UserDefaults.standard.set(false, forKey: PrivatePaykitService.publishingEnabledKey) - for failureStage in ["lookup", "withdraw", "registry"] { + for failureStage in ["lookup", "withdraw", "queue", "pending", "registry"] { let service = PrivatePaykitService() var contactState = PrivatePaykitService.ContactState() contactState.hasPublishedPrivatePaymentList = true @@ -604,10 +604,17 @@ final class PrivatePaykitServiceTests: XCTestCase { }, clearPaymentList: { _ in if shouldFail, failureStage == "withdraw" { throw PrivatePaykitError.privateUnavailable } + if shouldFail, failureStage == "queue" { + return PrivatePaymentListDeliveryReport( + queued: [], cleared: [], + failedToQueue: [PrivatePaymentListSyncChange(counterparty: publicKey, outboundMessageId: nil, error: nil)], + failedToDeliver: [] + ) + } return PrivatePaymentListDeliveryReport(queued: [], cleared: [], failedToQueue: [], failedToDeliver: []) }, drainMessages: { _ in }, - pendingDrainKeys: { _ in [] }, + pendingDrainKeys: { _ in shouldFail && failureStage == "pending" ? [publicKey] : [] }, syncApp: { registryUpdates += 1 if shouldFail, failureStage == "registry" { throw PrivatePaykitError.privateUnavailable } From 27e921a324c6fe9546596dd4001ca274f1cb8a9b Mon Sep 17 00:00:00 2001 From: benk10 Date: Fri, 2 Oct 2026 15:31:53 -0500 Subject: [PATCH 28/68] fix: coalesce paykit preparation and reduce inbox polling --- Bitkit/AppScene.swift | 30 +-- Bitkit/Services/ContactPaymentsService.swift | 16 +- .../PaykitPaymentRequestService.swift | 28 +-- .../PrivatePaykitService+Backup.swift | 2 + .../PrivatePaykitService+Contacts.swift | 211 ++++++++++++------ .../Services/PrivatePaykitService+State.swift | 2 + Bitkit/Services/PrivatePaykitService.swift | 6 + BitkitTests/ContactPaymentsServiceTests.swift | 16 +- ...itPaymentRequestPollingScheduleTests.swift | 28 ++- .../PaykitPaymentRequestServiceTests.swift | 16 +- BitkitTests/PrivatePaykitServiceTests.swift | 159 +++++++++++++ journeys/contacts/README.md | 5 + journeys/contacts/import-all-contacts.xml | 1 + journeys/payment-requests/README.md | 9 +- 14 files changed, 394 insertions(+), 135 deletions(-) diff --git a/Bitkit/AppScene.swift b/Bitkit/AppScene.swift index 556c06d4e..35dff4ee2 100644 --- a/Bitkit/AppScene.swift +++ b/Bitkit/AppScene.swift @@ -189,17 +189,20 @@ enum PaykitPaymentRequestPollingRound: Equatable { struct PaykitPaymentRequestPollingSchedule { let nextDelay: Duration = .seconds(10) - private static let maintenanceIntervals: [Duration] = [.seconds(30), .seconds(60), .seconds(120)] + private static let maintenanceIntervals: [Duration] = [.seconds(30), .seconds(60)] private var maintenanceIntervalIndex = 0 - private var maintenanceDelay = Self.maintenanceIntervals[0] + private var nextMaintenance: ContinuousClock.Instant - mutating func takeRound(isConnected: Bool) -> PaykitPaymentRequestPollingRound { + init(now: ContinuousClock.Instant = .now) { + nextMaintenance = now.advanced(by: Self.maintenanceIntervals[0]) + } + + mutating func takeRound(isConnected: Bool, now: ContinuousClock.Instant = .now) -> PaykitPaymentRequestPollingRound { guard isConnected else { return .skip } - maintenanceDelay -= nextDelay - guard maintenanceDelay <= .zero else { return .refreshInbox } + guard now >= nextMaintenance else { return .refreshInbox } maintenanceIntervalIndex = min(maintenanceIntervalIndex + 1, Self.maintenanceIntervals.count - 1) - maintenanceDelay = Self.maintenanceIntervals[maintenanceIntervalIndex] + nextMaintenance = now.advanced(by: Self.maintenanceIntervals[maintenanceIntervalIndex]) return .refreshInboxAndMaintenance } } @@ -456,12 +459,11 @@ struct AppScene: View { paykitPaymentRequestManager.clear() } } - .onReceive(contactsManager.$contacts) { contacts in + .onReceive(contactsManager.$contacts.map { $0.map(\.publicKey).sorted() }.removeDuplicates()) { publicKeys in guard PaykitFeatureFlags.isUIEnabled, wallet.walletExists == true, pubkyProfile.authState == .authenticated else { return } - let publicKeys = contacts.map(\.publicKey) Task { await PrivatePaykitService.shared.prepareSavedContacts(publicKeys, wallet: wallet) await refreshIncomingPaykitPaymentRequests() @@ -1073,9 +1075,8 @@ struct AppScene: View { if PaykitFeatureFlags.isUIEnabled { await refreshPrivateOnlyPaykitApp() let contactPublicKeys = contactsManager.contacts.map(\.publicKey) - await PrivatePaykitService.shared.refreshSavedContactEndpoints( - for: contactPublicKeys, - savedPublicKeys: contactPublicKeys, + await PrivatePaykitService.shared.prepareSavedContacts( + contactPublicKeys, wallet: wallet ) await refreshIncomingPaykitPaymentRequests() @@ -1111,7 +1112,7 @@ struct AppScene: View { await PaykitPaymentProofService.shared.reconcile() } guard let identity = pubkyProfile.publicKey else { return } - await paykitPaymentRequestManager.refresh(processOutgoingMessages: refreshMaintenance) + await paykitPaymentRequestManager.refresh(syncPrivateMessages: refreshMaintenance) guard pubkyProfile.authState == .authenticated, PubkyPublicKeyFormat.matches(identity, pubkyProfile.publicKey) else { return } @@ -1534,9 +1535,8 @@ struct AppScene: View { } if PaykitFeatureFlags.isUIEnabled { let contactPublicKeys = contactsManager.contacts.map(\.publicKey) - await PrivatePaykitService.shared.refreshSavedContactEndpoints( - for: contactPublicKeys, - savedPublicKeys: contactPublicKeys, + await PrivatePaykitService.shared.prepareSavedContacts( + contactPublicKeys, wallet: wallet ) } diff --git a/Bitkit/Services/ContactPaymentsService.swift b/Bitkit/Services/ContactPaymentsService.swift index a3b7d8b75..b9dc004b4 100644 --- a/Bitkit/Services/ContactPaymentsService.swift +++ b/Bitkit/Services/ContactPaymentsService.swift @@ -131,21 +131,19 @@ enum ContactPaymentsService { defaults.set(true, forKey: confirmedPreferenceKey) try await operations.syncPaykitApp(canUsePrivatePayments) + try await operations.syncPublicEndpoints(true) + operations.setPublicCleanupPending(false) + if canUsePrivatePayments { + operations.setPrivateCleanupPending(false) + } if canUsePrivatePayments, let error = await operations.preparePrivateEndpoints( contactPublicKeys, - true + false ) { throw error } - - try await operations.syncPublicEndpoints(true) - - operations.setPublicCleanupPending(false) - if canUsePrivatePayments { - operations.setPrivateCleanupPending(false) - } } @MainActor @@ -207,7 +205,7 @@ enum ContactPaymentsService { if restoresPrivateEndpoints { if let error = await operations.preparePrivateEndpoints( contactPublicKeys, - true + false ) { operations.setPrivateCleanupPending(true) Logger.warn("Failed to restore private contact payments: \(error)", context: "ContactPaymentsService") diff --git a/Bitkit/Services/PaykitPaymentRequestService.swift b/Bitkit/Services/PaykitPaymentRequestService.swift index f859ce412..136baabe0 100644 --- a/Bitkit/Services/PaykitPaymentRequestService.swift +++ b/Bitkit/Services/PaykitPaymentRequestService.swift @@ -548,12 +548,12 @@ struct PaykitPaymentRequestService { self.logWarning = logWarning } - func synchronize(processOutgoingMessages: Bool = true) async throws -> PaykitPaymentRequestSnapshot { - if processOutgoingMessages { + func synchronize(syncPrivateMessages: Bool = true) async throws -> PaykitPaymentRequestSnapshot { + if syncPrivateMessages { try await processPendingMessages() + let intakeReports = try await sdk.receivePrivateMessagesFromLinkedPeers() + logIntakeFailures(intakeReports) } - let intakeReports = try await sdk.receivePrivateMessagesFromLinkedPeers() - logIntakeFailures(intakeReports) let synchronizationDate = now() let sharedRecords = try await sdk.sharedPaymentRequests() let records = sharedRecords.filter(PaykitSdkService.isBitkitPaymentRequest) @@ -1134,7 +1134,7 @@ final class PaykitPaymentRequestManager { private var expiredRequestedPresentations: [PaykitPaymentRequest] = [] private var unavailableRequestedPresentations: [PaykitPaymentRequest] = [] private var isPresentingRequests = false - private var refreshTask: (processOutgoingMessages: Bool, task: Task)? + private var refreshTask: (syncPrivateMessages: Bool, task: Task)? private var expirationTask: Task? private var presentationRetryTask: Task? private var refreshGeneration = 0 @@ -1493,8 +1493,8 @@ final class PaykitPaymentRequestManager { return subscription } - func refresh(processOutgoingMessages: Bool = true) async { - await refresh(excludingProtectedRequestId: nil, processOutgoingMessages: processOutgoingMessages) + func refresh(syncPrivateMessages: Bool = true) async { + await refresh(excludingProtectedRequestId: nil, syncPrivateMessages: syncPrivateMessages) } /// Applies a changed subscription clock offset: waits for a refresh already reading the old clock, then refreshes again. @@ -1518,12 +1518,12 @@ final class PaykitPaymentRequestManager { ) } - private func refresh(excludingProtectedRequestId: PaykitPaymentRequest.ID?, processOutgoingMessages: Bool = true) async { + private func refresh(excludingProtectedRequestId: PaykitPaymentRequest.ID?, syncPrivateMessages: Bool = true) async { if let refreshTask { let generation = stateGeneration await refreshTask.task.value - if processOutgoingMessages, !refreshTask.processOutgoingMessages, generation == stateGeneration, !Task.isCancelled { - await refresh(excludingProtectedRequestId: excludingProtectedRequestId, processOutgoingMessages: true) + if syncPrivateMessages, !refreshTask.syncPrivateMessages, generation == stateGeneration, !Task.isCancelled { + await refresh(excludingProtectedRequestId: excludingProtectedRequestId, syncPrivateMessages: true) } return } @@ -1535,12 +1535,12 @@ final class PaykitPaymentRequestManager { await performRefresh( generation: generation, excludingProtectedRequestId: excludingProtectedRequestId, - processOutgoingMessages: processOutgoingMessages + syncPrivateMessages: syncPrivateMessages ) guard generation == refreshGeneration else { return } refreshTask = nil } - refreshTask = (processOutgoingMessages, task) + refreshTask = (syncPrivateMessages, task) await task.value } @@ -2065,10 +2065,10 @@ final class PaykitPaymentRequestManager { private func performRefresh( generation: Int, excludingProtectedRequestId: PaykitPaymentRequest.ID?, - processOutgoingMessages: Bool + syncPrivateMessages: Bool ) async { do { - let snapshot = try await service.synchronize(processOutgoingMessages: processOutgoingMessages) + let snapshot = try await service.synchronize(syncPrivateMessages: syncPrivateMessages) guard generation == refreshGeneration, let activeIdentity else { return } async let completedProofKinds = completedPaymentProofKinds(activeIdentity) async let inFlightRequestIds = inFlightPaymentRequestIds(activeIdentity) diff --git a/Bitkit/Services/PrivatePaykitService+Backup.swift b/Bitkit/Services/PrivatePaykitService+Backup.swift index 48fbc2904..604ce0e46 100644 --- a/Bitkit/Services/PrivatePaykitService+Backup.swift +++ b/Bitkit/Services/PrivatePaykitService+Backup.swift @@ -21,6 +21,8 @@ extension PrivatePaykitService { } func restoreBackup(_ backup: String?) async throws { + invalidateContactPreparation() + unavailableLinkRetryAt.removeAll() let decoded = try backup.map { try JSONDecoder().decode(Backup.self, from: Data($0.utf8)) } if let decoded { // Wallet restore must not rewind the identity's live state or Noise counters. diff --git a/Bitkit/Services/PrivatePaykitService+Contacts.swift b/Bitkit/Services/PrivatePaykitService+Contacts.swift index b524a3074..dc8d71760 100644 --- a/Bitkit/Services/PrivatePaykitService+Contacts.swift +++ b/Bitkit/Services/PrivatePaykitService+Contacts.swift @@ -18,6 +18,7 @@ extension PrivatePaykitService { let ensureLink: (_ publicKey: String) async throws -> LinkedPeerState let buildEndpoints: (_ publicKey: String) async throws -> [PublicPaykitService.Endpoint] let syncPaymentLists: (_ updates: [PrivatePaymentListReservationUpdateInput]) async throws -> PrivatePaymentListDeliveryReport + var linkedPeers: () async throws -> [LinkedPeerRecord] = { [] } } struct EndpointCleanupOperations { @@ -34,7 +35,12 @@ extension PrivatePaykitService { wallet: WalletViewModel, requireImmediatePublication: Bool = false ) async -> Error? { - await prepareSavedContacts( + if !requireImmediatePublication { + let keys = rememberSavedContacts(publicKeys, replacing: true) + scheduleContactPreparation(keys, wallet: wallet) + return nil + } + return await prepareSavedContacts( publicKeys, publicationUnavailableReason: privateEndpointPublicationUnavailabilityReason(wallet: wallet), prepareLinks: { await self.prepareRelevantPrivateLinksIfAvailable($0, reason: "prepare") }, @@ -84,16 +90,56 @@ extension PrivatePaykitService { } func refreshKnownSavedContactEndpoints(wallet: WalletViewModel, reason: String, forceRefreshLightning: Bool = false) async { - let publicKeys = Array(knownSavedContactKeys) - guard !publicKeys.isEmpty else { return } + scheduleContactPreparation(Array(knownSavedContactKeys), wallet: wallet, forceRefreshLightning: forceRefreshLightning) + } - _ = await refreshSavedContactEndpointsReturningError( - for: publicKeys, - wallet: wallet, - forceRefreshLightning: forceRefreshLightning, - requireImmediatePublication: false, - reason: reason - ) + private func scheduleContactPreparation(_ publicKeys: [String], wallet: WalletViewModel, forceRefreshLightning: Bool = false) { + scheduleContactPreparation(publicKeys, forceRefreshLightning: forceRefreshLightning) { keys, forceRefresh in + guard !UserDefaults.standard.bool(forKey: Self.cleanupPendingKey) else { return } + _ = await self.refreshSavedContactEndpointsReturningError( + for: keys, + wallet: wallet, + forceRefreshLightning: forceRefresh, + requireImmediatePublication: false, + reason: "contact preparation" + ) + } + } + + func scheduleContactPreparation( + _ publicKeys: [String], + forceRefreshLightning: Bool = false, + operation: @escaping ([String], Bool) async -> Void + ) { + pendingPreparationKeys.formUnion(publicKeys.filter { forceRefreshLightning || !activePreparationKeys.contains($0) }) + pendingForceRefreshLightning = pendingForceRefreshLightning || forceRefreshLightning + guard preparationTask == nil, !pendingPreparationKeys.isEmpty else { return } + preparationTask = Task { + defer { + activePreparationKeys.removeAll() + preparationTask = nil + } + while !pendingPreparationKeys.isEmpty { + let keys = pendingPreparationKeys.intersection(knownSavedContactKeys) + let forceRefresh = pendingForceRefreshLightning + pendingPreparationKeys.removeAll() + pendingForceRefreshLightning = false + activePreparationKeys = keys + await operation(Array(keys).sorted(), forceRefresh) + activePreparationKeys.removeAll() + } + } + } + + func invalidateContactPreparation() { + preparationGeneration += 1 + pendingPreparationKeys.removeAll() + activePreparationKeys.removeAll() + pendingForceRefreshLightning = false + pendingMessageDrainRetryTask?.cancel() + pendingMessageDrainRetryTask = nil + pendingMessageDrainRetryKeys.removeAll() + pendingMessageDrainRetryGeneration += 1 } @discardableResult @@ -104,10 +150,14 @@ extension PrivatePaykitService { requireImmediatePublication: Bool, reason: String = "refresh" ) async -> Error? { + let generation = preparationGeneration guard await canPublishPrivateEndpoints(wallet: wallet) else { await prepareRelevantPrivateLinksIfAvailable(publicKeys, reason: reason) return requireImmediatePublication && !publicKeys.isEmpty ? PrivatePaykitError.privateUnavailable : nil } + guard generation == preparationGeneration else { + return requireImmediatePublication ? PrivatePaykitError.privateUnavailable : nil + } return await syncLocalEndpointPublication( for: publicKeys, @@ -131,6 +181,7 @@ extension PrivatePaykitService { func removePublishedEndpoints(for publicKeys: [String]? = nil, operations: EndpointCleanupOperations) async throws { let publicKeys = publicKeys.map { normalizedSavedContactKeys($0) } guard publicKeys?.isEmpty != true else { return } + if publicKeys == nil { invalidateContactPreparation() } do { try await withPublicationLock { @@ -293,17 +344,17 @@ extension PrivatePaykitService { let publicKeys = rememberSavedContacts(Array(restoreKeys), replacing: true) await PrivatePaykitAddressReservationStore.shared.reconcileReservedIndexesWithLdk() - let error = await refreshSavedContactEndpointsReturningError( - for: publicKeys, - wallet: wallet, - forceRefreshLightning: false, - requireImmediatePublication: true, - reason: "reconcile" - ) + let error = await resumeEndpointPublication { + await self.refreshSavedContactEndpointsReturningError( + for: publicKeys, + wallet: wallet, + forceRefreshLightning: false, + requireImmediatePublication: true, + reason: "reconcile" + ) + } if let error { Logger.warn("Failed to reconcile private Paykit endpoints: \(error)", context: "PrivatePaykit") - } else { - Self.setContactSharingCleanupPending(false) } return } @@ -332,6 +383,16 @@ extension PrivatePaykitService { return Array(Self.pendingDeletedContactCleanupKeys().subtracting(savedKeys)).sorted() } + func resumeEndpointPublication(_ publish: () async -> Error?) async -> Error? { + let generation = preparationGeneration + Self.setContactSharingCleanupPending(false) + let error = await publish() + if error != nil, generation == preparationGeneration { + Self.setContactSharingCleanupPending(true) + } + return error + } + private func syncLocalEndpointPublication( for publicKeys: [String], wallet: WalletViewModel, @@ -351,26 +412,6 @@ extension PrivatePaykitService { ) } - func syncLocalEndpointPublication( - for publicKeys: [String], - reason: String, - requireImmediatePublication: Bool, - operations: EndpointPublicationOperations - ) async -> Error? { - do { - return try await withPublicationLock { - await syncLocalEndpointPublicationLocked( - for: publicKeys, - reason: reason, - requireImmediatePublication: requireImmediatePublication, - operations: operations - ) - } - } catch { - return requireImmediatePublication ? error : nil - } - } - private func endpointPublicationOperations( wallet: WalletViewModel, forceRefreshLightning: Bool @@ -394,11 +435,12 @@ extension PrivatePaykitService { updates, clearUnlistedLinkedPeers: false ) - } + }, + linkedPeers: { try await PaykitSdkService.shared.linkedPeers() } ) } - private func syncLocalEndpointPublicationLocked( + func syncLocalEndpointPublication( for publicKeys: [String], reason: String, requireImmediatePublication: Bool, @@ -407,22 +449,39 @@ extension PrivatePaykitService { let publicKeys = normalizedSavedContactKeys(publicKeys) guard !publicKeys.isEmpty else { return nil } - guard await operations.currentPublicKey() != nil else { + let generation = preparationGeneration + guard let identity = await operations.currentPublicKey() else { return requireImmediatePublication ? PubkyServiceError.sessionNotActive : nil } var firstError: Error? - var updates = [PrivatePaymentListReservationUpdateInput]() + var preparedKeys = [String]() var linkRetryKeys = [String]() + let peerStates: [String: LinkedPeerState] + do { + peerStates = try await Dictionary(uniqueKeysWithValues: operations.linkedPeers().map { ($0.counterparty, $0.state) }) + } catch { + Logger.warn("Failed to read private Paykit links during \(reason): \(error)", context: "PrivatePaykit") + return requireImmediatePublication ? error : nil + } for publicKey in publicKeys { + guard generation == preparationGeneration else { break } + guard knownSavedContactKeys.contains(publicKey), !UserDefaults.standard.bool(forKey: Self.cleanupPendingKey) else { continue } + if peerStates[publicKey] == .blocked { continue } + if !requireImmediatePublication, let retryAt = unavailableLinkRetryAt[publicKey], retryAt > Date() { continue } do { - if try await operations.ensureLink(publicKey) != .linked { + if peerStates[publicKey] != .linked, try await operations.ensureLink(publicKey) != .linked { linkRetryKeys.append(publicKey) } + unavailableLinkRetryAt[publicKey] = nil } catch PaykitError.NotFound { + unavailableLinkRetryAt[publicKey] = Date().addingTimeInterval(5 * 60) continue } catch { + if case PaykitError.Transport = error { + unavailableLinkRetryAt[publicKey] = Date().addingTimeInterval(5 * 60) + } Logger.warn( "Failed to prepare private Paykit link for \(PubkyPublicKeyFormat.redacted(publicKey)) during \(reason): \(error)", context: "PrivatePaykit" @@ -430,32 +489,45 @@ extension PrivatePaykitService { linkRetryKeys.append(publicKey) continue } - do { - let endpoints = try await operations.buildEndpoints(publicKey) - updates.append(PrivatePaymentListReservationUpdateInput( - counterparty: publicKey, - reservations: reservations(from: endpoints, publicKey: publicKey) - )) - } catch { - firstError = firstError ?? error - Logger.warn( - "Failed to prepare private Paykit endpoints for \(PubkyPublicKeyFormat.redacted(publicKey)) during \(reason): \(error)", - context: "PrivatePaykit" - ) - } - } - - guard !updates.isEmpty else { - await drainAndSchedulePrivateLinkRetries(reason: reason, retryKeys: linkRetryKeys) - return requireImmediatePublication ? firstError : nil + preparedKeys.append(publicKey) } do { - let report = try await operations.syncPaymentLists(updates) - let deliveryError = applyPrivatePaymentListDeliveryReport(report, reason: reason) - firstError = firstError ?? deliveryError - let retryKeys = linkRetryKeys + privatePaymentListDeliveryRetryKeys(from: report) - await drainAndSchedulePrivateLinkRetries(reason: reason, retryKeys: retryKeys) + try await withPublicationLock { + guard generation == preparationGeneration, + await operations.currentPublicKey() == identity, + !UserDefaults.standard.bool(forKey: Self.cleanupPendingKey) + else { throw PrivatePaykitError.privateUnavailable } + var updates = [PrivatePaymentListReservationUpdateInput]() + for publicKey in preparedKeys { + guard generation == preparationGeneration else { throw PrivatePaykitError.privateUnavailable } + guard knownSavedContactKeys.contains(publicKey) else { continue } + do { + let endpoints = try await operations.buildEndpoints(publicKey) + updates.append(PrivatePaymentListReservationUpdateInput( + counterparty: publicKey, + reservations: reservations(from: endpoints, publicKey: publicKey) + )) + } catch { + firstError = firstError ?? error + Logger.warn( + "Failed to prepare private Paykit endpoints for \(PubkyPublicKeyFormat.redacted(publicKey)) during \(reason): \(error)", + context: "PrivatePaykit" + ) + } + } + updates.removeAll { !knownSavedContactKeys.contains($0.counterparty) } + guard generation == preparationGeneration else { throw PrivatePaykitError.privateUnavailable } + guard !updates.isEmpty else { + schedulePendingPrivateMessageDrainRetries(reason: reason, retryKeys: linkRetryKeys) + return + } + let report = try await operations.syncPaymentLists(updates) + let deliveryError = applyPrivatePaymentListDeliveryReport(report, reason: reason) + firstError = firstError ?? deliveryError + let retryKeys = linkRetryKeys + privatePaymentListDeliveryRetryKeys(from: report) + schedulePendingPrivateMessageDrainRetries(reason: reason, retryKeys: retryKeys) + } } catch { Logger.warn("Failed to sync private Paykit endpoint publications during \(reason): \(error)", context: "PrivatePaykit") firstError = firstError ?? error @@ -465,7 +537,7 @@ extension PrivatePaykitService { } private func prepareRelevantPrivateLinksIfAvailable(_ publicKeys: [String], reason: String) async { - guard await canUsePrivateLinks() else { return } + guard !UserDefaults.standard.bool(forKey: Self.cleanupPendingKey), await canUsePrivateLinks() else { return } await drainAndSchedulePrivateLinkRetries(reason: reason, retryKeys: normalizedSavedContactKeys(publicKeys)) } @@ -498,7 +570,10 @@ extension PrivatePaykitService { private func drainPendingPrivateMessages(reason: String, advancing retryKeys: [String]) async { do { + let generation = preparationGeneration for retryKey in Set(retryKeys) { + guard generation == preparationGeneration else { break } + if let retryAt = unavailableLinkRetryAt[retryKey], retryAt > Date() { continue } do { _ = try await PaykitSdkService.shared.ensureLinkWithPeer(retryKey) } catch { diff --git a/Bitkit/Services/PrivatePaykitService+State.swift b/Bitkit/Services/PrivatePaykitService+State.swift index b24011a14..9c14008bb 100644 --- a/Bitkit/Services/PrivatePaykitService+State.swift +++ b/Bitkit/Services/PrivatePaykitService+State.swift @@ -4,6 +4,8 @@ import Foundation extension PrivatePaykitService { func closeAndClear() async { + invalidateContactPreparation() + unavailableLinkRetryAt.removeAll() pendingMessageDrainRetryTask?.cancel() pendingMessageDrainRetryTask = nil pendingMessageDrainRetryKeys.removeAll() diff --git a/Bitkit/Services/PrivatePaykitService.swift b/Bitkit/Services/PrivatePaykitService.swift index 438a126b1..7c0fcde63 100644 --- a/Bitkit/Services/PrivatePaykitService.swift +++ b/Bitkit/Services/PrivatePaykitService.swift @@ -71,6 +71,12 @@ actor PrivatePaykitService { var state: PrivatePaykitState var knownSavedContactKeys: Set = [] + var pendingPreparationKeys: Set = [] + var activePreparationKeys: Set = [] + var preparationTask: Task? + var preparationGeneration = 0 + var pendingForceRefreshLightning = false + var unavailableLinkRetryAt: [String: Date] = [:] var pendingMessageDrainRetryTask: Task? var pendingMessageDrainRetryKeys: Set = [] var pendingMessageDrainRetryGeneration = 0 diff --git a/BitkitTests/ContactPaymentsServiceTests.swift b/BitkitTests/ContactPaymentsServiceTests.swift index bbac39dac..b04813842 100644 --- a/BitkitTests/ContactPaymentsServiceTests.swift +++ b/BitkitTests/ContactPaymentsServiceTests.swift @@ -56,8 +56,8 @@ final class ContactPaymentsServiceTests: XCTestCase { XCTAssertEqual(operations.publicPublicationValues, [true]) XCTAssertEqual(operations.privatePublications.count, 1) XCTAssertEqual(operations.privatePublications[0].contactPublicKeys, ["contact-a", "contact-b"]) - XCTAssertTrue(operations.privatePublications[0].requiresImmediatePublication) - XCTAssertEqual(operations.calls, ["app:true", "private:publish", "public:true"]) + XCTAssertFalse(operations.privatePublications[0].requiresImmediatePublication) + XCTAssertEqual(operations.calls, ["app:true", "public:true", "private:publish"]) XCTAssertEqual(operations.privateRemovalCount, 0) XCTAssertEqual(operations.publicCleanupValues, [false]) XCTAssertEqual(operations.privateCleanupValues, [false]) @@ -169,7 +169,7 @@ final class ContactPaymentsServiceTests: XCTestCase { } } - func testFailedPrivateEnableDoesNotPublishPublicEndpointAndRestoresDisabledState() async throws { + func testFailedPrivateSetupWithdrawsPublicEndpointAndRestoresDisabledState() async throws { try await withIsolatedDefaultsAsync { defaults in defaults.set(true, forKey: PublicPaykitService.cleanupPendingKey) let operations = OperationsSpy() @@ -188,13 +188,13 @@ final class ContactPaymentsServiceTests: XCTestCase { XCTAssertEqual(error as? TestError, .operationFailed) } - XCTAssertEqual(operations.publicPublicationValues, [false]) - XCTAssertFalse(operations.calls.contains("public:true")) + XCTAssertEqual(operations.publicPublicationValues, [true, false]) + XCTAssertTrue(operations.calls.contains("public:true")) XCTAssertEqual(operations.privatePublications.count, 1) - XCTAssertTrue(operations.privatePublications[0].requiresImmediatePublication) + XCTAssertFalse(operations.privatePublications[0].requiresImmediatePublication) XCTAssertEqual(operations.privateRemovalCount, 1) - XCTAssertEqual(operations.publicCleanupValues, [true]) - XCTAssertEqual(operations.privateCleanupValues, [false]) + XCTAssertEqual(operations.publicCleanupValues, [false, true]) + XCTAssertEqual(operations.privateCleanupValues, [false, false]) XCTAssertFalse(defaults.bool(forKey: PublicPaykitService.publishingEnabledKey)) XCTAssertFalse(defaults.bool(forKey: PrivatePaykitService.publishingEnabledKey)) XCTAssertFalse(defaults.bool(forKey: ContactPaymentsService.confirmedPreferenceKey)) diff --git a/BitkitTests/PaykitPaymentRequestPollingScheduleTests.swift b/BitkitTests/PaykitPaymentRequestPollingScheduleTests.swift index b2fce1e76..c9e0f7f6e 100644 --- a/BitkitTests/PaykitPaymentRequestPollingScheduleTests.swift +++ b/BitkitTests/PaykitPaymentRequestPollingScheduleTests.swift @@ -3,14 +3,15 @@ import XCTest final class PaykitPaymentRequestPollingScheduleTests: XCTestCase { func testInboxKeepsTenSecondChecksAndSlowerMaintenance() { - var schedule = PaykitPaymentRequestPollingSchedule() + let start = ContinuousClock.now + var schedule = PaykitPaymentRequestPollingSchedule(now: start) var elapsed: Duration = .zero var maintenanceTimes: [Duration] = [] for _ in 0 ..< 21 { XCTAssertEqual(schedule.nextDelay, .seconds(10)) elapsed += schedule.nextDelay - switch schedule.takeRound(isConnected: true) { + switch schedule.takeRound(isConnected: true, now: start.advanced(by: elapsed)) { case .skip: XCTFail("Connected polling rounds should refresh the inbox") case .refreshInbox: @@ -20,16 +21,25 @@ final class PaykitPaymentRequestPollingScheduleTests: XCTestCase { } } - XCTAssertEqual(maintenanceTimes, [.seconds(30), .seconds(90), .seconds(210)]) + XCTAssertEqual(maintenanceTimes, [.seconds(30), .seconds(90), .seconds(150), .seconds(210)]) } - func testOfflineRoundSkipsWorkWithoutAdvancingMaintenance() { - var schedule = PaykitPaymentRequestPollingSchedule() + func testOfflineRoundSkipsWorkAndReconnectRunsOverdueMaintenance() { + let start = ContinuousClock.now + var schedule = PaykitPaymentRequestPollingSchedule(now: start) - XCTAssertEqual(schedule.takeRound(isConnected: false), .skip) + XCTAssertEqual(schedule.takeRound(isConnected: false, now: start.advanced(by: .seconds(90))), .skip) XCTAssertEqual(schedule.nextDelay, .seconds(10)) - XCTAssertEqual(schedule.takeRound(isConnected: true), .refreshInbox) - XCTAssertEqual(schedule.takeRound(isConnected: true), .refreshInbox) - XCTAssertEqual(schedule.takeRound(isConnected: true), .refreshInboxAndMaintenance) + XCTAssertEqual(schedule.takeRound(isConnected: true, now: start.advanced(by: .seconds(100))), .refreshInboxAndMaintenance) + XCTAssertEqual(schedule.takeRound(isConnected: true, now: start.advanced(by: .seconds(110))), .refreshInbox) + } + + func testSlowRefreshCountsTowardNextMaintenanceDeadline() { + let start = ContinuousClock.now + var schedule = PaykitPaymentRequestPollingSchedule(now: start) + + XCTAssertEqual(schedule.takeRound(isConnected: true, now: start.advanced(by: .seconds(30))), .refreshInboxAndMaintenance) + XCTAssertEqual(schedule.takeRound(isConnected: true, now: start.advanced(by: .seconds(80))), .refreshInbox) + XCTAssertEqual(schedule.takeRound(isConnected: true, now: start.advanced(by: .seconds(100))), .refreshInboxAndMaintenance) } } diff --git a/BitkitTests/PaykitPaymentRequestServiceTests.swift b/BitkitTests/PaykitPaymentRequestServiceTests.swift index 2362671b3..e3d7d6f81 100644 --- a/BitkitTests/PaykitPaymentRequestServiceTests.swift +++ b/BitkitTests/PaykitPaymentRequestServiceTests.swift @@ -9,26 +9,26 @@ import XCTest @MainActor final class PaykitPaymentRequestServiceTests: XCTestCase { - func testInboxRefreshReceivesMessagesWithoutDrainingOutboundWork() async throws { + func testInboxRefreshReadsSharedStateWithoutPrivateMessageSync() async throws { let record = try paymentRequestRecord() let sdk = PaymentRequestSdkMock(records: []) let manager = paymentRequestManager(sdk: sdk) await manager.refresh() await sdk.setRecords([record]) - await manager.refresh(processOutgoingMessages: false) + await manager.refresh(syncPrivateMessages: false) XCTAssertEqual(manager.pendingRequests.map(\.paymentRequestId), [record.paymentRequestId]) let snapshot = await sdk.snapshot() XCTAssertEqual(snapshot.processCallCount, 1) - XCTAssertEqual(snapshot.receiveCallCount, 2) + XCTAssertEqual(snapshot.receiveCallCount, 1) } func testFullRefreshAfterOverlappingInboxRefreshDrainsOutboundWork() async throws { let sdk = PaymentRequestSdkMock(records: []) let manager = paymentRequestManager(sdk: sdk) await sdk.pauseNextPaymentRequestList() - let inboxRefresh = Task { await manager.refresh(processOutgoingMessages: false) } + let inboxRefresh = Task { await manager.refresh(syncPrivateMessages: false) } try await waitUntil { await sdk.paymentRequestListIsPaused() } let fullRefreshStarted = expectation(description: "Full refresh started") let fullRefresh = Task { @@ -43,14 +43,14 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { let snapshot = await sdk.snapshot() XCTAssertEqual(snapshot.processCallCount, 1) - XCTAssertEqual(snapshot.receiveCallCount, 2) + XCTAssertEqual(snapshot.receiveCallCount, 1) } func testClearInvalidatesFullRefreshWaitingForInbox() async throws { let sdk = try PaymentRequestSdkMock(records: [paymentRequestRecord()]) let manager = paymentRequestManager(sdk: sdk) await sdk.pauseNextPaymentRequestList() - let inboxRefresh = Task { await manager.refresh(processOutgoingMessages: false) } + let inboxRefresh = Task { await manager.refresh(syncPrivateMessages: false) } try await waitUntil { await sdk.paymentRequestListIsPaused() } let fullRefreshStarted = expectation(description: "Full refresh started") let fullRefresh = Task { @@ -68,7 +68,7 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { XCTAssertTrue(manager.historyRequests.isEmpty) let snapshot = await sdk.snapshot() XCTAssertEqual(snapshot.processCallCount, 0) - XCTAssertEqual(snapshot.receiveCallCount, 1) + XCTAssertEqual(snapshot.receiveCallCount, 0) } func testReceivedPaymentContactsIncludeSharedServerRequestsAndLatePayments() throws { @@ -1387,7 +1387,7 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { try await waitUntil { await sdk.linkedPeersIsPaused() } record.state = .canceled await sdk.setRecords([record]) - await manager.refresh(processOutgoingMessages: false) + await manager.refresh(syncPrivateMessages: false) await sdk.resumeLinkedPeers() XCTAssertFalse(manager.isApprovedForPayment(request)) diff --git a/BitkitTests/PrivatePaykitServiceTests.swift b/BitkitTests/PrivatePaykitServiceTests.swift index 7459ffb3a..ed07f99f1 100644 --- a/BitkitTests/PrivatePaykitServiceTests.swift +++ b/BitkitTests/PrivatePaykitServiceTests.swift @@ -710,6 +710,7 @@ final class PrivatePaykitServiceTests: XCTestCase { PaykitError.Transport(code: "offline", context: "Unavailable homeserver"), ] { let service = PrivatePaykitService() + _ = await service.rememberSavedContacts(["pubky1rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg"], replacing: true) let operations = PrivatePaykitService.EndpointPublicationOperations( currentPublicKey: { "pubkylocal" }, ensureLink: { _ in throw linkError }, @@ -766,6 +767,7 @@ final class PrivatePaykitServiceTests: XCTestCase { ) let service = PrivatePaykitService() + _ = await service.rememberSavedContacts([failedPublicKey, successfulPublicKey], replacing: true) let error = await service.syncLocalEndpointPublication( for: [failedPublicKey, successfulPublicKey], reason: "test", @@ -799,6 +801,7 @@ final class PrivatePaykitServiceTests: XCTestCase { ) let service = PrivatePaykitService() + _ = await service.rememberSavedContacts([publicKey], replacing: true) let error = await service.syncLocalEndpointPublication( for: [publicKey], reason: "test", @@ -809,6 +812,162 @@ final class PrivatePaykitServiceTests: XCTestCase { XCTAssertNil(error) } + func testBackgroundPreparationCoalescesRepeatedRequests() async { + let service = PrivatePaykitService() + let publicKey = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" + _ = await service.rememberSavedContacts([publicKey], replacing: true) + let started = expectation(description: "Preparation started") + let (resume, continuation) = AsyncStream.makeStream() + var preparedKeys = [[String]]() + let operation: ([String], Bool) async -> Void = { keys, _ in + preparedKeys.append(keys) + started.fulfill() + for await _ in resume { + break + } + } + + await service.scheduleContactPreparation([publicKey], operation: operation) + await fulfillment(of: [started], timeout: 2) + for _ in 0 ..< 3 { + await service.scheduleContactPreparation([publicKey], operation: operation) + } + let task = await service.preparationTask + continuation.yield(()) + continuation.finish() + await task?.value + + XCTAssertEqual(preparedKeys, [[publicKey]]) + } + + func testResumingPublicationRetainsCleanupOnlyWhenRestorationFails() async { + let service = PrivatePaykitService() + for fails in [false, true] { + PrivatePaykitService.setContactSharingCleanupPending(true) + let error = await service.resumeEndpointPublication { + XCTAssertFalse(UserDefaults.standard.bool(forKey: PrivatePaykitService.cleanupPendingKey)) + return fails ? PrivatePaykitError.privateUnavailable : nil + } + XCTAssertEqual(error != nil, fails) + XCTAssertEqual(UserDefaults.standard.bool(forKey: PrivatePaykitService.cleanupPendingKey), fails) + } + } + + func testInvalidatedRestorationDoesNotRestoreCleanupMarker() async { + let service = PrivatePaykitService() + PrivatePaykitService.setContactSharingCleanupPending(true) + + let error = await service.resumeEndpointPublication { + await service.invalidateContactPreparation() + PrivatePaykitService.setContactSharingCleanupPending(false) + return PrivatePaykitError.privateUnavailable + } + + XCTAssertNotNil(error) + XCTAssertFalse(UserDefaults.standard.bool(forKey: PrivatePaykitService.cleanupPendingKey)) + } + + func testCleanupStopsAStalledPreparationBeforeLaterContactsAreVisited() async { + let service = PrivatePaykitService() + let publicKeys = [ + "pubky1rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg", + "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg", + ] + _ = await service.rememberSavedContacts(publicKeys, replacing: true) + let started = expectation(description: "Link lookup started") + let cleanupFinished = expectation(description: "Cleanup finished") + let (resume, continuation) = AsyncStream.makeStream() + var visitedKeys = [String]() + let publication = Task { + await service.syncLocalEndpointPublication( + for: publicKeys, reason: "test", requireImmediatePublication: true, + operations: .init( + currentPublicKey: { "pubkylocal" }, + ensureLink: { key in + visitedKeys.append(key) + started.fulfill() + for await _ in resume { + break + } + return .linked + }, + buildEndpoints: { _ in XCTFail("Cleanup invalidates preparation"); return [] }, + syncPaymentLists: { _ in + XCTFail("Cleanup invalidates publication") + return .init(queued: [], cleared: [], failedToQueue: [], failedToDeliver: []) + } + ) + ) + } + await fulfillment(of: [started], timeout: 2) + let cleanup = Task { + try await service.removePublishedEndpoints(operations: .init( + linkedPeers: { [] }, + clearPaymentList: { _ in nil }, + drainMessages: { _ in }, + pendingDrainKeys: { _ in [] }, + syncApp: {} + )) + cleanupFinished.fulfill() + } + await fulfillment(of: [cleanupFinished], timeout: 2) + continuation.yield(()) + continuation.finish() + let error = await publication.value + _ = await cleanup.result + + XCTAssertEqual(visitedKeys, [publicKeys[0]]) + XCTAssertNotNil(error) + } + + func testPublicationDropsContactsRemovedWhileLinking() async { + let service = PrivatePaykitService() + let publicKey = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" + _ = await service.rememberSavedContacts([publicKey], replacing: true) + let error = await service.syncLocalEndpointPublication( + for: [publicKey], reason: "test", requireImmediatePublication: true, + operations: .init( + currentPublicKey: { "pubkylocal" }, + ensureLink: { _ in + _ = await service.rememberSavedContacts([], replacing: true) + return .linked + }, + buildEndpoints: { _ in XCTFail("Removed contacts must not reserve addresses"); return [] }, + syncPaymentLists: { _ in + XCTFail("Removed contacts must not receive publications") + return .init(queued: [], cleared: [], failedToQueue: [], failedToDeliver: []) + } + ) + ) + XCTAssertNil(error) + } + + func testUnavailableContactCooldownDoesNotBlockExplicitPublication() async { + let service = PrivatePaykitService() + let publicKey = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" + _ = await service.rememberSavedContacts([publicKey], replacing: true) + var attempts = 0 + let operations = PrivatePaykitService.EndpointPublicationOperations( + currentPublicKey: { "pubkylocal" }, + ensureLink: { _ in + attempts += 1 + throw PaykitError.NotFound(code: "not_found", context: "No App Registry") + }, + buildEndpoints: { _ in XCTFail("Unavailable contacts must not reserve addresses"); return [] }, + syncPaymentLists: { _ in .init(queued: [], cleared: [], failedToQueue: [], failedToDeliver: []) } + ) + for _ in 0 ..< 3 { + _ = await service.syncLocalEndpointPublication( + for: [publicKey], reason: "test", requireImmediatePublication: false, operations: operations + ) + } + XCTAssertEqual(attempts, 1) + _ = await service.syncLocalEndpointPublication( + for: [publicKey], reason: "test", requireImmediatePublication: true, operations: operations + ) + XCTAssertEqual(attempts, 2) + } + private func withIsolatedPrivatePaykitState( _ operation: (PrivatePaykitService) async throws -> Void ) async throws { diff --git a/journeys/contacts/README.md b/journeys/contacts/README.md index c471e8b15..9d8fcbd82 100644 --- a/journeys/contacts/README.md +++ b/journeys/contacts/README.md @@ -8,6 +8,11 @@ using the PR manual checks. Import journeys require a disposable identity with a known following list. They save local Bitkit contacts; payment sharing remains a separate step. +Continue waits for public payment setup, not private linking with every imported contact. +Private preparation runs in the background. Repeat Import All with a large following list containing +unavailable profiles, then delete a contact while preparation is running. It must not be republished +after deletion. Unavailable private-link lookups are retried after five minutes rather than on each refresh. + Network and storage fault injection are outside journey-runner capabilities. Manually disable connectivity after the preview has loaded: importing the prepared contacts must still finish. Simulate a failed local save: stay on import, preserve successful saves, and retry only missing diff --git a/journeys/contacts/import-all-contacts.xml b/journeys/contacts/import-all-contacts.xml index bdc4a31de..4d64a30bf 100644 --- a/journeys/contacts/import-all-contacts.xml +++ b/journeys/contacts/import-all-contacts.xml @@ -10,6 +10,7 @@ Note the friend count shown in the import preview Tap Import All Verify the import finishes and the Let Your Contacts Pay You screen appears + Tap Continue and verify the screen finishes without waiting for every contact to connect Return Home and open Contacts from the menu Verify the prepared contacts, including Pubky-only profiles, are present with their preview names diff --git a/journeys/payment-requests/README.md b/journeys/payment-requests/README.md index 30d1296f2..c9140f193 100644 --- a/journeys/payment-requests/README.md +++ b/journeys/payment-requests/README.md @@ -31,10 +31,11 @@ That run established the issuer shapes captured by the fixture: lowercase `btc`, ## Foreground synchronization -Bitkit receives private messages and refreshes shared request state every 10 seconds. -Outbound retries, endpoint publication, and target discovery run on startup, explicit refreshes, -and maintenance rounds that back off from 30 to 60 to 120 seconds. -While foregrounded and online, new peer messages are checked on the next inbox poll, plus synchronization time. +Bitkit checks shared request state every 10 seconds while foregrounded and online. +Private-message intake, outbound retries, endpoint publication, and target discovery run on startup, +explicit refreshes, and maintenance rounds after 30 seconds, then every 60 seconds. +Maintenance uses elapsed time, including slow requests; polling remains serialized and does not +start catch-up rounds. New peer messages may wait for maintenance plus synchronization time. ## Accepting install From 9a52ba088d4adf2699ac3113263d22e5b563889b Mon Sep 17 00:00:00 2001 From: benk10 Date: Fri, 2 Oct 2026 17:10:58 -0500 Subject: [PATCH 29/68] fix: coordinate private contact preparation and request discovery --- Bitkit/AppScene.swift | 5 + Bitkit/Managers/ContactsManager.swift | 2 + .../PrivatePaykitService+Contacts.swift | 78 +++++-- .../Services/PrivatePaykitService+State.swift | 1 + Bitkit/Services/PrivatePaykitService.swift | 4 +- Bitkit/Views/Contacts/ContactsListView.swift | 2 + BitkitTests/ContactsManagerTests.swift | 14 ++ BitkitTests/PrivatePaykitServiceTests.swift | 217 ++++++++++++++++++ 8 files changed, 309 insertions(+), 14 deletions(-) diff --git a/Bitkit/AppScene.swift b/Bitkit/AppScene.swift index 35dff4ee2..3dd3d2b38 100644 --- a/Bitkit/AppScene.swift +++ b/Bitkit/AppScene.swift @@ -1109,6 +1109,11 @@ struct AppScene: View { } if refreshMaintenance { + do { + try await PrivatePaykitService.shared.awaitContactPreparation() + } catch { + return + } await PaykitPaymentProofService.shared.reconcile() } guard let identity = pubkyProfile.publicKey else { return } diff --git a/Bitkit/Managers/ContactsManager.swift b/Bitkit/Managers/ContactsManager.swift index fd80b6967..3ad171730 100644 --- a/Bitkit/Managers/ContactsManager.swift +++ b/Bitkit/Managers/ContactsManager.swift @@ -271,6 +271,8 @@ class ContactsManager: ObservableObject { Logger.info("Loaded \(contacts.count) contacts", context: "ContactsManager") return + } catch is CancellationError { + throw CancellationError() } catch { guard contactsRevision == revision else { continue } if Self.isMissingContactsDataError(error) { diff --git a/Bitkit/Services/PrivatePaykitService+Contacts.swift b/Bitkit/Services/PrivatePaykitService+Contacts.swift index dc8d71760..d97c45a4c 100644 --- a/Bitkit/Services/PrivatePaykitService+Contacts.swift +++ b/Bitkit/Services/PrivatePaykitService+Contacts.swift @@ -19,6 +19,23 @@ extension PrivatePaykitService { let buildEndpoints: (_ publicKey: String) async throws -> [PublicPaykitService.Endpoint] let syncPaymentLists: (_ updates: [PrivatePaymentListReservationUpdateInput]) async throws -> PrivatePaymentListDeliveryReport var linkedPeers: () async throws -> [LinkedPeerRecord] = { [] } + var canPublish: () async -> Bool = { true } + } + + struct PrivateMessageDrainOperations { + let ensureLink: (String) async throws -> Void + let pendingOutbound: () async throws -> [String] + let linkedPeers: () async throws -> [LinkedPeerRecord] + let processPending: () async throws -> Void + let receive: () async throws -> Void + + static let live = PrivateMessageDrainOperations( + ensureLink: { _ = try await PaykitSdkService.shared.ensureLinkWithPeer($0) }, + pendingOutbound: { try await PaykitSdkService.shared.pendingOutboundPrivateCounterparties() }, + linkedPeers: { try await PaykitSdkService.shared.linkedPeers() }, + processPending: { try await PaykitSdkService.shared.processPendingPrivateMessages() }, + receive: { try await PaykitSdkService.shared.receivePrivateMessagesFromLinkedPeers() } + ) } struct EndpointCleanupOperations { @@ -93,6 +110,23 @@ extension PrivatePaykitService { scheduleContactPreparation(Array(knownSavedContactKeys), wallet: wallet, forceRefreshLightning: forceRefreshLightning) } + func awaitContactPreparation() async throws { + try Task.checkCancellation() + guard let preparationTask else { return } + // Cancelling a caller must not cancel preparation shared by other callers. + let (completion, continuation) = AsyncStream.makeStream() + let waiter = Task { + await preparationTask.value + continuation.finish() + } + defer { + waiter.cancel() + continuation.finish() + } + for await _ in completion {} + try Task.checkCancellation() + } + private func scheduleContactPreparation(_ publicKeys: [String], wallet: WalletViewModel, forceRefreshLightning: Bool = false) { scheduleContactPreparation(publicKeys, forceRefreshLightning: forceRefreshLightning) { keys, forceRefresh in guard !UserDefaults.standard.bool(forKey: Self.cleanupPendingKey) else { return } @@ -151,7 +185,8 @@ extension PrivatePaykitService { reason: String = "refresh" ) async -> Error? { let generation = preparationGeneration - guard await canPublishPrivateEndpoints(wallet: wallet) else { + let operations = endpointPublicationOperations(wallet: wallet, forceRefreshLightning: forceRefreshLightning) + guard await operations.canPublish() else { await prepareRelevantPrivateLinksIfAvailable(publicKeys, reason: reason) return requireImmediatePublication && !publicKeys.isEmpty ? PrivatePaykitError.privateUnavailable : nil } @@ -161,10 +196,9 @@ extension PrivatePaykitService { return await syncLocalEndpointPublication( for: publicKeys, - wallet: wallet, reason: reason, - forceRefreshLightning: forceRefreshLightning, - requireImmediatePublication: requireImmediatePublication + requireImmediatePublication: requireImmediatePublication, + operations: operations ) } @@ -283,6 +317,7 @@ extension PrivatePaykitService { func removeSavedContact(publicKey: String) async { guard let normalizedKey = PubkyPublicKeyFormat.normalized(publicKey) else { return } knownSavedContactKeys.remove(normalizedKey) + unavailableLinkRetryAt[normalizedKey] = nil Self.markDeletedContactCleanupPending([normalizedKey]) do { try await removePublishedEndpoints(for: [normalizedKey]) @@ -299,6 +334,7 @@ extension PrivatePaykitService { let normalizedKeys = normalizedSavedContactKeys(publicKeys) for publicKey in normalizedKeys { knownSavedContactKeys.remove(publicKey) + unavailableLinkRetryAt[publicKey] = nil } Self.markDeletedContactCleanupPending(normalizedKeys) do { @@ -312,8 +348,7 @@ extension PrivatePaykitService { } func pruneUnsavedContactState(savedPublicKeys publicKeys: [String]) async { - let savedKeys = Set(normalizedSavedContactKeys(publicKeys)) - knownSavedContactKeys = savedKeys + let savedKeys = Set(rememberSavedContacts(publicKeys, replacing: true)) let staleKeys: Set = Set(state.contacts.compactMap { publicKey, contactState in guard !savedKeys.contains(publicKey), contactState.hasContactOwnedCacheState else { return nil } @@ -416,7 +451,8 @@ extension PrivatePaykitService { wallet: WalletViewModel, forceRefreshLightning: Bool ) -> EndpointPublicationOperations { - EndpointPublicationOperations( + if let publicationOperations { return publicationOperations } + return EndpointPublicationOperations( currentPublicKey: { await PubkyService.currentPublicKey() }, @@ -436,7 +472,8 @@ extension PrivatePaykitService { clearUnlistedLinkedPeers: false ) }, - linkedPeers: { try await PaykitSdkService.shared.linkedPeers() } + linkedPeers: { try await PaykitSdkService.shared.linkedPeers() }, + canPublish: { await self.canPublishPrivateEndpoints(wallet: wallet) } ) } @@ -480,7 +517,13 @@ extension PrivatePaykitService { continue } catch { if case PaykitError.Transport = error { - unavailableLinkRetryAt[publicKey] = Date().addingTimeInterval(5 * 60) + unavailableLinkRetryAt[publicKey] = nil + if let peers = try? await operations.linkedPeers() { + let state = peers.first { $0.counterparty == publicKey }?.state + if state == nil || state == .notLinked { + unavailableLinkRetryAt[publicKey] = Date().addingTimeInterval(5 * 60) + } + } } Logger.warn( "Failed to prepare private Paykit link for \(PubkyPublicKeyFormat.redacted(publicKey)) during \(reason): \(error)", @@ -568,14 +611,18 @@ extension PrivatePaykitService { normalizedSavedContactKeys((report.queued + report.cleared).map(\.counterparty) + report.failedToDeliver.map(\.counterparty)) } - private func drainPendingPrivateMessages(reason: String, advancing retryKeys: [String]) async { + func drainPendingPrivateMessages( + reason: String, + advancing retryKeys: [String], + operations: PrivateMessageDrainOperations = .live + ) async { do { let generation = preparationGeneration for retryKey in Set(retryKeys) { guard generation == preparationGeneration else { break } if let retryAt = unavailableLinkRetryAt[retryKey], retryAt > Date() { continue } do { - _ = try await PaykitSdkService.shared.ensureLinkWithPeer(retryKey) + try await operations.ensureLink(retryKey) } catch { Logger.warn( "Failed to advance private Paykit link for \(PubkyPublicKeyFormat.redacted(retryKey)) during \(reason): \(error)", @@ -583,8 +630,12 @@ extension PrivatePaykitService { ) } } - try await PaykitSdkService.shared.processPendingPrivateMessages() - try await PaykitSdkService.shared.receivePrivateMessagesFromLinkedPeers() + if try await !operations.pendingOutbound().isEmpty { + try await operations.processPending() + } + if try await operations.linkedPeers().contains(where: { $0.state == .linked }) { + try await operations.receive() + } } catch { Logger.warn("Failed to process pending private Paykit messages during \(reason): \(error)", context: "PrivatePaykit") } @@ -763,6 +814,7 @@ extension PrivatePaykitService { let normalizedKeys = normalizedSavedContactKeys(publicKeys) if replacing { knownSavedContactKeys = Set(normalizedKeys) + unavailableLinkRetryAt = unavailableLinkRetryAt.filter { knownSavedContactKeys.contains($0.key) } } else { knownSavedContactKeys.formUnion(normalizedKeys) } diff --git a/Bitkit/Services/PrivatePaykitService+State.swift b/Bitkit/Services/PrivatePaykitService+State.swift index 9c14008bb..02ad26a12 100644 --- a/Bitkit/Services/PrivatePaykitService+State.swift +++ b/Bitkit/Services/PrivatePaykitService+State.swift @@ -21,6 +21,7 @@ extension PrivatePaykitService { func clearContactState(publicKey: String) async { guard let normalizedKey = PubkyPublicKeyFormat.normalized(publicKey) else { return } + unavailableLinkRetryAt[normalizedKey] = nil privatePaymentListConsumptions = privatePaymentListConsumptions.filter { $0.key.publicKey != normalizedKey } let consumedVersion = state.contacts[normalizedKey]?.consumedPrivatePaymentListVersion if consumedVersion == nil { diff --git a/Bitkit/Services/PrivatePaykitService.swift b/Bitkit/Services/PrivatePaykitService.swift index 7c0fcde63..603c795f2 100644 --- a/Bitkit/Services/PrivatePaykitService.swift +++ b/Bitkit/Services/PrivatePaykitService.swift @@ -84,8 +84,10 @@ actor PrivatePaykitService { var privatePaymentListConsumptions: [PrivatePaymentListConsumptionKey: PrivatePaymentListConsumption] = [:] var prePaymentPublicationKeys: Set = [] private let publicationLock = PrivatePaykitPublicationLock() + let publicationOperations: EndpointPublicationOperations? - init() { + init(publicationOperations: EndpointPublicationOperations? = nil) { + self.publicationOperations = publicationOperations state = UserDefaults.standard.data(forKey: Self.cacheStateKey) .flatMap { try? JSONDecoder().decode(PrivatePaykitState.self, from: $0) } ?? PrivatePaykitState(contacts: [:]) } diff --git a/Bitkit/Views/Contacts/ContactsListView.swift b/Bitkit/Views/Contacts/ContactsListView.swift index a39dead7a..d889bdf37 100644 --- a/Bitkit/Views/Contacts/ContactsListView.swift +++ b/Bitkit/Views/Contacts/ContactsListView.swift @@ -294,6 +294,8 @@ struct ContactsListView: View { if !contactsManager.contacts.isEmpty { app.hasSeenContactsIntro = true } + } catch is CancellationError { + return } catch { Logger.error("Failed to load contacts in view: \(error)", context: "ContactsListView") diff --git a/BitkitTests/ContactsManagerTests.swift b/BitkitTests/ContactsManagerTests.swift index cf5355c1d..048f75c9c 100644 --- a/BitkitTests/ContactsManagerTests.swift +++ b/BitkitTests/ContactsManagerTests.swift @@ -125,6 +125,20 @@ final class ContactsManagerTests: XCTestCase { } } + func testCancelledContactLoadPropagatesWithoutPublishingAnError() async throws { + let manager = ContactsManager(contactRecords: { throw CancellationError() }) + + do { + try await manager.loadContacts(for: "owner") + XCTFail("Contact load should propagate cancellation") + } catch is CancellationError {} + + XCTAssertFalse(manager.isLoading) + XCTAssertFalse(manager.hasLoaded) + XCTAssertTrue(manager.contacts.isEmpty) + XCTAssertNil(manager.loadErrorMessage) + } + func testInitialLoadPreservesUnchangedContactsAfterLocalMutations() async throws { for deletesContact in [true, false] { let first = contactRecord(key: "pubky" + String(repeating: "y", count: 52), name: "First") diff --git a/BitkitTests/PrivatePaykitServiceTests.swift b/BitkitTests/PrivatePaykitServiceTests.swift index ed07f99f1..5c32e4f89 100644 --- a/BitkitTests/PrivatePaykitServiceTests.swift +++ b/BitkitTests/PrivatePaykitServiceTests.swift @@ -95,6 +95,137 @@ final class PrivatePaykitServiceTests: XCTestCase { XCTAssertEqual(preparedContacts, contacts) } + @MainActor + func testPreparingSavedContactsPublishesReservationsInBackground() async throws { + PrivatePaykitService.setContactSharingCleanupPending(false) + let publicKey = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" + let endpoint = PublicPaykitService.Endpoint( + methodId: .regtestOnchainP2wpkh, value: "bcrt1qendpoint", min: nil, max: nil, + rawPayload: #"{"value":"bcrt1qendpoint"}"# + ) + let publishing = expectation(description: "Private reservation publication started") + let published = expectation(description: "Private reservations published") + let (resume, continuation) = AsyncStream.makeStream() + defer { continuation.finish() } + var syncedUpdates = [PrivatePaymentListReservationUpdateInput]() + let service = PrivatePaykitService(publicationOperations: .init( + currentPublicKey: { "pubkylocal" }, + ensureLink: { _ in .linked }, + buildEndpoints: { key in + XCTAssertEqual(key, publicKey) + return [endpoint] + }, + syncPaymentLists: { updates in + publishing.fulfill() + for await _ in resume { + break + } + syncedUpdates = updates + published.fulfill() + return .init(queued: [], cleared: [], failedToQueue: [], failedToDeliver: []) + } + )) + + let error = await service.prepareSavedContacts([publicKey], wallet: WalletViewModel()) + await fulfillment(of: [publishing], timeout: 2) + let returnedBeforePublication = expectation(description: "Preparation wait does not finish before publication") + returnedBeforePublication.isInverted = true + let waiter = Task { + try await service.awaitContactPreparation() + if syncedUpdates.isEmpty { returnedBeforePublication.fulfill() } + } + await fulfillment(of: [returnedBeforePublication], timeout: 0.1) + continuation.finish() + try await waiter.value + await fulfillment(of: [published], timeout: 2) + + XCTAssertNil(error) + XCTAssertEqual(syncedUpdates.count, 1) + let update = try XCTUnwrap(syncedUpdates.first) + XCTAssertEqual(update.counterparty, publicKey) + XCTAssertEqual(update.reservations.count, 1) + let reservation = try XCTUnwrap(update.reservations.first) + XCTAssertEqual(reservation.identifier, endpoint.methodId.rawValue) + XCTAssertEqual(reservation.payload, endpoint.rawPayload) + XCTAssertEqual(reservation.attribution["counterparty"], publicKey) + } + + func testCancellingPreparationWaitLeavesSharedPreparationRunning() async throws { + let service = PrivatePaykitService() + let publicKey = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" + _ = await service.rememberSavedContacts([publicKey], replacing: true) + let started = expectation(description: "Preparation started") + let finished = expectation(description: "Shared preparation finished") + let (resume, continuation) = AsyncStream.makeStream() + defer { continuation.finish() } + await service.scheduleContactPreparation([publicKey]) { _, _ in + started.fulfill() + for await _ in resume { + break + } + XCTAssertFalse(Task.isCancelled) + finished.fulfill() + } + await fulfillment(of: [started], timeout: 2) + let cancelled = expectation(description: "Preparation wait cancelled") + let returnedBeforeCancellation = expectation(description: "Preparation wait remains suspended") + returnedBeforeCancellation.isInverted = true + let waiter = Task { + do { + try await service.awaitContactPreparation() + returnedBeforeCancellation.fulfill() + } catch is CancellationError { + cancelled.fulfill() + } catch { + XCTFail("Unexpected error: \(error)") + } + } + await fulfillment(of: [returnedBeforeCancellation], timeout: 0.1) + waiter.cancel() + await fulfillment(of: [cancelled], timeout: 2) + continuation.finish() + try await service.awaitContactPreparation() + await fulfillment(of: [finished], timeout: 2) + await waiter.value + } + + func testPrivateMessageDrainUsesLinkAndOutboundStateAfterAdvancement() async { + let publicKey = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" + for isLinked in [false, true] { + for hasOutbound in [false, true] { + let service = PrivatePaykitService() + var advanced = false + var processed = 0 + var received = 0 + await service.drainPendingPrivateMessages(reason: "test", advancing: [publicKey], operations: .init( + ensureLink: { key in + XCTAssertEqual(key, publicKey) + advanced = true + }, + pendingOutbound: { + XCTAssertTrue(advanced) + return hasOutbound ? [publicKey] : [] + }, + linkedPeers: { + XCTAssertTrue(advanced) + return [LinkedPeerRecord( + counterparty: publicKey, state: isLinked ? .linked : .linking, + lastSyncAt: nil, lastPrivateReceiveAt: nil, failureCount: 0, + localRecoveryAttemptId: nil, localRecoveryMarkerCreatedAt: nil, localRecoveryMarkerLastError: nil, + remoteRecoveryAttemptId: nil, remoteRecoveryMarkerObservedAt: nil + )] + }, + processPending: { processed += 1 }, + receive: { received += 1 } + )) + + XCTAssertTrue(advanced) + XCTAssertEqual(processed, hasOutbound ? 1 : 0) + XCTAssertEqual(received, isLinked ? 1 : 0) + } + } + } + private func withIsolatedDefaults(_ body: (UserDefaults) throws -> Void) throws { let suiteName = "PrivatePaykitServiceTests.\(UUID().uuidString)" let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) @@ -968,6 +1099,92 @@ final class PrivatePaykitServiceTests: XCTestCase { XCTAssertEqual(attempts, 2) } + func testTransportFailureOnlyDefersContactsWithoutAHandshakeAfterAdvancement() async { + PrivatePaykitService.setContactSharingCleanupPending(false) + let publicKey = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" + let states: [LinkedPeerState?] = [nil, .notLinked, .linking, .recoveryRequired] + for stateAfterFailure in states { + let service = PrivatePaykitService() + _ = await service.rememberSavedContacts([publicKey], replacing: true) + var peerState: LinkedPeerState? + var nextState: LinkedPeerState? + var attempts = 0 + let peers: () async throws -> [LinkedPeerRecord] = { + guard let peerState else { return [] } + return [LinkedPeerRecord( + counterparty: publicKey, state: peerState, + lastSyncAt: nil, lastPrivateReceiveAt: nil, failureCount: 0, + localRecoveryAttemptId: nil, localRecoveryMarkerCreatedAt: nil, localRecoveryMarkerLastError: nil, + remoteRecoveryAttemptId: nil, remoteRecoveryMarkerObservedAt: nil + )] + } + let operations = PrivatePaykitService.EndpointPublicationOperations( + currentPublicKey: { "pubkylocal" }, + ensureLink: { _ in + attempts += 1 + peerState = nextState + throw PaykitError.Transport(code: "offline", context: "Unavailable homeserver") + }, + buildEndpoints: { _ in XCTFail("Failed links must not reserve addresses"); return [] }, + syncPaymentLists: { _ in .init(queued: [], cleared: [], failedToQueue: [], failedToDeliver: []) }, + linkedPeers: peers + ) + _ = await service.syncLocalEndpointPublication( + for: [publicKey], reason: "test", requireImmediatePublication: false, operations: operations + ) + nextState = stateAfterFailure + _ = await service.syncLocalEndpointPublication( + for: [publicKey], reason: "test", requireImmediatePublication: true, operations: operations + ) + await service.clearTestPendingMessageDrainRetries() + await service.drainPendingPrivateMessages(reason: "test retry", advancing: [publicKey], operations: .init( + ensureLink: { _ in attempts += 1 }, + pendingOutbound: { [] }, + linkedPeers: peers, + processPending: { XCTFail("No outbound messages are queued") }, + receive: { XCTFail("The handshake has not completed") } + )) + + XCTAssertEqual(attempts, stateAfterFailure == nil || stateAfterFailure == .notLinked ? 2 : 3) + } + } + + func testRemovedContactStateDoesNotRetainUnavailableLinkCooldown() async { + PrivatePaykitService.setContactSharingCleanupPending(false) + PrivatePaykitService.clearDeletedContactCleanupPending() + UserDefaults.standard.removeObject(forKey: PrivatePaykitService.cacheStateKey) + let service = PrivatePaykitService() + let publicKey = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" + var attempts = 0 + let operations = PrivatePaykitService.EndpointPublicationOperations( + currentPublicKey: { "pubkylocal" }, + ensureLink: { _ in + attempts += 1 + throw PaykitError.NotFound(code: "not_found", context: "No App Registry") + }, + buildEndpoints: { _ in XCTFail("Unavailable contacts must not reserve addresses"); return [] }, + syncPaymentLists: { _ in .init(queued: [], cleared: [], failedToQueue: [], failedToDeliver: []) } + ) + let removals: [(PrivatePaykitService) async -> Void] = [ + { await $0.clearContactState(publicKey: publicKey) }, + { _ = await $0.rememberSavedContacts([], replacing: true) }, + { await $0.pruneUnsavedContactState(savedPublicKeys: []) }, + ] + for (index, remove) in removals.enumerated() { + _ = await service.rememberSavedContacts([publicKey], replacing: true) + _ = await service.syncLocalEndpointPublication( + for: [publicKey], reason: "test", requireImmediatePublication: false, operations: operations + ) + XCTAssertEqual(attempts, index + 1) + await remove(service) + } + _ = await service.rememberSavedContacts([publicKey], replacing: true) + _ = await service.syncLocalEndpointPublication( + for: [publicKey], reason: "test", requireImmediatePublication: false, operations: operations + ) + XCTAssertEqual(attempts, removals.count + 1) + } + private func withIsolatedPrivatePaykitState( _ operation: (PrivatePaykitService) async throws -> Void ) async throws { From 778d9f2cc281ec4ce9512415120ac9337b510d25 Mon Sep 17 00:00:00 2001 From: benk10 Date: Fri, 2 Oct 2026 17:20:25 -0500 Subject: [PATCH 30/68] fix: skip contact loading when disabling sharing --- Bitkit/Views/Settings/GeneralSettingsView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Bitkit/Views/Settings/GeneralSettingsView.swift b/Bitkit/Views/Settings/GeneralSettingsView.swift index e797e8db7..1593ab4bc 100644 --- a/Bitkit/Views/Settings/GeneralSettingsView.swift +++ b/Bitkit/Views/Settings/GeneralSettingsView.swift @@ -166,7 +166,7 @@ struct GeneralSettingsView: View { do { let canUsePrivatePayments = pubkyProfile.hasLocalSecretKeyForCurrentProfile - if canUsePrivatePayments, let publicKey = pubkyProfile.publicKey { + if enabled, canUsePrivatePayments, let publicKey = pubkyProfile.publicKey { try await contactsManager.loadContactsIfNeeded(for: publicKey) } From f5ca80127f1a11111d6b2fa2f65197dc7a62b130 Mon Sep 17 00:00:00 2001 From: benk10 Date: Fri, 2 Oct 2026 17:34:31 -0500 Subject: [PATCH 31/68] fix: avoid contact preparation waits for subscription notifications --- Bitkit/AppScene.swift | 8 ++++++-- BitkitTests/PaykitPaymentRequestServiceTests.swift | 13 +++++++------ journeys/paykit-clock-changes.md | 1 + 3 files changed, 14 insertions(+), 8 deletions(-) diff --git a/Bitkit/AppScene.swift b/Bitkit/AppScene.swift index 3dd3d2b38..2f7e445aa 100644 --- a/Bitkit/AppScene.swift +++ b/Bitkit/AppScene.swift @@ -443,10 +443,10 @@ struct AppScene: View { if authState == .authenticated, let pk = pubkyProfile.publicKey { paykitPaymentRequestManager.activate(identity: pk) Task { + await handlePendingPaykitSubscriptionNotification() try? await contactsManager.loadContacts(for: pk) await refreshPrivateOnlyPaykitApp() await refreshIncomingPaykitPaymentRequests(presentItems: false) - await handlePendingPaykitSubscriptionNotification() if PaykitSubscriptionNotificationTargetStore.load() == nil { await presentNextIncomingPaykitItem() } @@ -1417,6 +1417,10 @@ struct AppScene: View { } private func handlePendingPaykitSubscriptionNotification() async { + guard PaykitFeatureFlags.isUIEnabled, + wallet.walletExists == true, + pubkyProfile.authState == .authenticated + else { return } guard let target = PaykitSubscriptionNotificationTargetStore.load() else { return } guard let identity = pubkyProfile.publicKey else { return } guard target.matches(identity: identity) else { @@ -1427,7 +1431,7 @@ struct AppScene: View { !sheets.isReplacingSheet, app.contactPaymentContext == nil else { return } - await refreshIncomingPaykitPaymentRequests(presentItems: false) + await refreshIncomingPaykitPaymentRequests(presentItems: false, refreshMaintenance: false) guard let request = paykitPaymentRequestManager.pendingRequests.first(where: target.matches) else { if paykitPaymentRequestManager.historyRequests.contains(where: target.matches) { PaykitSubscriptionNotificationTargetStore.clear() diff --git a/BitkitTests/PaykitPaymentRequestServiceTests.swift b/BitkitTests/PaykitPaymentRequestServiceTests.swift index e3d7d6f81..4c2079c5b 100644 --- a/BitkitTests/PaykitPaymentRequestServiceTests.swift +++ b/BitkitTests/PaykitPaymentRequestServiceTests.swift @@ -1268,7 +1268,7 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { ) } - func testRefreshMapsActiveRecurringRequestAndCurrentUnpaidPeriod() async throws { + func testInboxRefreshMapsActiveRecurringRequestAndCurrentUnpaidPeriod() async throws { let now = try XCTUnwrap(ISO8601DateFormatter().date(from: "2027-01-15T08:00:00Z")) let recurrence = PaymentRequestRecurrence( every: 1, @@ -1283,13 +1283,14 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { recurrence: recurrence, metadata: #"{"note":"Mobile plan","subscription":{"version":1,"description":"10 GB every month","benefits":["Roaming"]}}"# ) - let manager = paymentRequestManager( - sdk: PaymentRequestSdkMock(records: [record]), - clock: PaymentRequestTestClock(now) - ) + let sdk = PaymentRequestSdkMock(records: [record]) + let manager = paymentRequestManager(sdk: sdk, clock: PaymentRequestTestClock(now)) - await manager.refresh() + await manager.refresh(syncPrivateMessages: false) + let snapshot = await sdk.snapshot() + XCTAssertEqual(snapshot.processCallCount, 0) + XCTAssertEqual(snapshot.receiveCallCount, 0) let subscription = try XCTUnwrap(manager.subscriptions.first) XCTAssertEqual(subscription.note, "Mobile plan") XCTAssertEqual(subscription.metadata.description, "10 GB every month") diff --git a/journeys/paykit-clock-changes.md b/journeys/paykit-clock-changes.md index b0d342eef..a73df6992 100644 --- a/journeys/paykit-clock-changes.md +++ b/journeys/paykit-clock-changes.md @@ -24,6 +24,7 @@ On iOS, Ring identities are selected from the shared identity list. There is no 3. Schedule a reminder, then move the clock backward before it is due. It must not announce that payment is due while the device's current time is before that billing boundary. 4. Restore the correct clock and verify reminders still work. On Android, WorkManager delivery is best effort and may be delayed by retry backoff or OS scheduling; this check does not require exact delivery to the second. 5. While a payment request is temporarily unavailable and presentation is retrying, move the clock forward and backward. Retry intervals should remain short, while actual payment expiry and approval continue to use absolute timestamps. +6. Tap a due subscription reminder during background contact preparation, then repeat with Bitkit closed before the tap. After authentication and unlock, the app must resolve the due period from shared state without waiting for the whole contact-preparation round. Normal maintenance must still run afterward. Record tap-to-sheet timing separately from authentication and any SDK lock wait. These steps describe the remaining manual verification. Unit tests cover injected restoration failures, state preservation, retry timing, UTC recurrence, and notification scheduling; they do not replace a live grant-session clock-change test. From 21a7a60b654b30491395dae5be2095577cf2da3c Mon Sep 17 00:00:00 2001 From: benk10 Date: Fri, 2 Oct 2026 18:43:16 -0500 Subject: [PATCH 32/68] fix: stop publication before withdrawing contact payments --- Bitkit/Services/ContactPaymentsService.swift | 10 +- BitkitTests/ContactPaymentsServiceTests.swift | 36 ++++--- BitkitTests/PrivatePaykitServiceTests.swift | 96 +++++++++++++++++++ 3 files changed, 126 insertions(+), 16 deletions(-) diff --git a/Bitkit/Services/ContactPaymentsService.swift b/Bitkit/Services/ContactPaymentsService.swift index b9dc004b4..2af69c6d5 100644 --- a/Bitkit/Services/ContactPaymentsService.swift +++ b/Bitkit/Services/ContactPaymentsService.swift @@ -148,6 +148,12 @@ enum ContactPaymentsService { @MainActor private static func disable(operations: Operations, defaults: UserDefaults) async -> Error? { + defaults.set(false, forKey: PublicPaykitService.publishingEnabledKey) + defaults.set(false, forKey: PrivatePaykitService.publishingEnabledKey) + defaults.set(true, forKey: confirmedPreferenceKey) + operations.setPublicCleanupPending(true) + operations.setPrivateCleanupPending(true) + var cleanupError: Error? do { try await operations.removePrivateEndpoints() @@ -157,10 +163,6 @@ enum ContactPaymentsService { cleanupError = error } - defaults.set(false, forKey: PublicPaykitService.publishingEnabledKey) - defaults.set(false, forKey: PrivatePaykitService.publishingEnabledKey) - defaults.set(true, forKey: confirmedPreferenceKey) - do { try await operations.syncPublicEndpoints(false) operations.setPublicCleanupPending(false) diff --git a/BitkitTests/ContactPaymentsServiceTests.swift b/BitkitTests/ContactPaymentsServiceTests.swift index b04813842..060510045 100644 --- a/BitkitTests/ContactPaymentsServiceTests.swift +++ b/BitkitTests/ContactPaymentsServiceTests.swift @@ -133,15 +133,17 @@ final class ContactPaymentsServiceTests: XCTestCase { false, contactPublicKeys: ["contact-a"], canUsePrivatePayments: true, - operations: operations.makeOperations(), + operations: operations.makeOperations(defaults: defaults), defaults: defaults ) XCTAssertEqual(operations.publicPublicationValues, [false]) XCTAssertEqual(operations.privateRemovalCount, 1) XCTAssertEqual(operations.calls, ["private:remove", "public:false"]) - XCTAssertEqual(operations.publicCleanupValues, [false]) - XCTAssertEqual(operations.privateCleanupValues, [false]) + XCTAssertEqual(operations.publicCleanupValues, [true, false]) + XCTAssertEqual(operations.privateCleanupValues, [true, false]) + XCTAssertFalse(defaults.bool(forKey: PublicPaykitService.cleanupPendingKey)) + XCTAssertFalse(defaults.bool(forKey: PrivatePaykitService.cleanupPendingKey)) XCTAssertFalse(defaults.bool(forKey: PublicPaykitService.publishingEnabledKey)) XCTAssertFalse(defaults.bool(forKey: PrivatePaykitService.publishingEnabledKey)) XCTAssertTrue(defaults.bool(forKey: ContactPaymentsService.confirmedPreferenceKey)) @@ -217,7 +219,7 @@ final class ContactPaymentsServiceTests: XCTestCase { false, contactPublicKeys: ["contact-a"], canUsePrivatePayments: true, - operations: operations.makeOperations(), + operations: operations.makeOperations(defaults: defaults), defaults: defaults ) XCTFail("Expected endpoint cleanup to fail") @@ -229,8 +231,10 @@ final class ContactPaymentsServiceTests: XCTestCase { XCTAssertEqual(operations.privateRemovalCount, 1) XCTAssertTrue(operations.privatePublications.isEmpty) XCTAssertEqual(operations.calls, ["private:remove", "public:false"]) - XCTAssertEqual(operations.publicCleanupValues, [publicFails]) - XCTAssertEqual(operations.privateCleanupValues, [privateFails]) + XCTAssertEqual(operations.publicCleanupValues, [true, publicFails]) + XCTAssertEqual(operations.privateCleanupValues, [true, privateFails]) + XCTAssertEqual(defaults.bool(forKey: PublicPaykitService.cleanupPendingKey), publicFails) + XCTAssertEqual(defaults.bool(forKey: PrivatePaykitService.cleanupPendingKey), privateFails) XCTAssertFalse(defaults.bool(forKey: PublicPaykitService.publishingEnabledKey)) XCTAssertFalse(defaults.bool(forKey: PrivatePaykitService.publishingEnabledKey)) XCTAssertTrue(defaults.bool(forKey: ContactPaymentsService.confirmedPreferenceKey)) @@ -240,15 +244,17 @@ final class ContactPaymentsServiceTests: XCTestCase { false, contactPublicKeys: ["contact-a"], canUsePrivatePayments: true, - operations: operations.makeOperations(), + operations: operations.makeOperations(defaults: defaults), defaults: defaults ) XCTAssertEqual(operations.publicPublicationValues, [false, false]) XCTAssertEqual(operations.privateRemovalCount, 2) XCTAssertTrue(operations.privatePublications.isEmpty) - XCTAssertEqual(operations.publicCleanupValues, [publicFails, false]) - XCTAssertEqual(operations.privateCleanupValues, [privateFails, false]) + XCTAssertEqual(operations.publicCleanupValues, [true, publicFails, true, false]) + XCTAssertEqual(operations.privateCleanupValues, [true, privateFails, true, false]) + XCTAssertFalse(defaults.bool(forKey: PublicPaykitService.cleanupPendingKey)) + XCTAssertFalse(defaults.bool(forKey: PrivatePaykitService.cleanupPendingKey)) XCTAssertFalse(ContactPaymentsService.isEnabled(defaults: defaults)) } } @@ -294,7 +300,7 @@ final class ContactPaymentsServiceTests: XCTestCase { var onPreparePrivateEndpoints: (() -> Void)? var preparePrivateEndpoints: (([String], Bool) async -> Error?)? - func makeOperations() -> ContactPaymentsService.Operations { + func makeOperations(defaults: UserDefaults? = nil) -> ContactPaymentsService.Operations { ContactPaymentsService.Operations( syncPaykitApp: { enabled in self.calls.append("app:\(enabled)") @@ -327,8 +333,14 @@ final class ContactPaymentsServiceTests: XCTestCase { throw TestError.operationFailed } }, - setPublicCleanupPending: { self.publicCleanupValues.append($0) }, - setPrivateCleanupPending: { self.privateCleanupValues.append($0) } + setPublicCleanupPending: { + self.publicCleanupValues.append($0) + defaults?.set($0, forKey: PublicPaykitService.cleanupPendingKey) + }, + setPrivateCleanupPending: { + self.privateCleanupValues.append($0) + defaults?.set($0, forKey: PrivatePaykitService.cleanupPendingKey) + } ) } } diff --git a/BitkitTests/PrivatePaykitServiceTests.swift b/BitkitTests/PrivatePaykitServiceTests.swift index 5c32e4f89..e3d7bd2ad 100644 --- a/BitkitTests/PrivatePaykitServiceTests.swift +++ b/BitkitTests/PrivatePaykitServiceTests.swift @@ -998,6 +998,102 @@ final class PrivatePaykitServiceTests: XCTestCase { XCTAssertFalse(UserDefaults.standard.bool(forKey: PrivatePaykitService.cleanupPendingKey)) } + @MainActor + func testDisablingSharingStopsPreparationStartedDuringWithdrawal() async throws { + let defaults = UserDefaults.standard + defaults.set(true, forKey: PublicPaykitService.publishingEnabledKey) + defaults.set(true, forKey: PrivatePaykitService.publishingEnabledKey) + defaults.set(false, forKey: ContactPaymentsService.confirmedPreferenceKey) + PublicPaykitService.setCleanupPending(false) + PrivatePaykitService.setContactSharingCleanupPending(false) + defaults.removeObject(forKey: PrivatePaykitService.cacheStateKey) + defaults.removeObject(forKey: PrivatePaykitService.deletedContactCleanupKeysKey) + + let publicKey = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" + let endpoint = PublicPaykitService.Endpoint( + methodId: .regtestOnchainP2wpkh, value: "bcrt1qendpoint", min: nil, max: nil, + rawPayload: #"{"value":"bcrt1qendpoint"}"# + ) + var publications = 0 + let service = PrivatePaykitService(publicationOperations: .init( + currentPublicKey: { "pubkylocal" }, + ensureLink: { _ in .linked }, + buildEndpoints: { _ in [endpoint] }, + syncPaymentLists: { _ in + publications += 1 + return .init(queued: [], cleared: [], failedToQueue: [], failedToDeliver: []) + } + )) + var contactState = PrivatePaykitService.ContactState() + contactState.hasPublishedPrivatePaymentList = true + await service.setTestContactState(contactState, publicKey: publicKey) + + let withdrawing = expectation(description: "Private withdrawal started") + let prepared = expectation(description: "Preparation completes while withdrawal is suspended") + let (resume, continuation) = AsyncStream.makeStream() + defer { continuation.finish() } + var registryUpdates = 0 + let cleanup = PrivatePaykitService.EndpointCleanupOperations( + linkedPeers: { [] }, + clearPaymentList: { key in + XCTAssertEqual(key, publicKey) + withdrawing.fulfill() + for await _ in resume { + break + } + return .init(queued: [], cleared: [], failedToQueue: [], failedToDeliver: []) + }, + drainMessages: { XCTAssertEqual($0, [publicKey]) }, + pendingDrainKeys: { _ in [] }, + syncApp: { + XCTAssertFalse(defaults.bool(forKey: PrivatePaykitService.publishingEnabledKey)) + XCTAssertTrue(defaults.bool(forKey: PrivatePaykitService.cleanupPendingKey)) + registryUpdates += 1 + } + ) + let disable = Task { + try await ContactPaymentsService.setEnabled( + false, contactPublicKeys: [publicKey], canUsePrivatePayments: true, + operations: .init( + syncPaykitApp: { _ in XCTFail("OFF must withdraw private lists before downgrading") }, + syncPublicEndpoints: { publish in + XCTAssertFalse(publish) + XCTAssertEqual(registryUpdates, 1) + XCTAssertTrue(PublicPaykitService.isCleanupPending) + }, + preparePrivateEndpoints: { _, _ in XCTFail("OFF must not publish"); return nil }, + removePrivateEndpoints: { try await service.removePublishedEndpoints(operations: cleanup) }, + setPublicCleanupPending: PublicPaykitService.setCleanupPending, + setPrivateCleanupPending: PrivatePaykitService.setContactSharingCleanupPending + ) + ) + } + await fulfillment(of: [withdrawing], timeout: 2) + XCTAssertFalse(defaults.bool(forKey: PublicPaykitService.publishingEnabledKey)) + XCTAssertFalse(defaults.bool(forKey: PrivatePaykitService.publishingEnabledKey)) + XCTAssertTrue(defaults.bool(forKey: ContactPaymentsService.confirmedPreferenceKey)) + XCTAssertTrue(PublicPaykitService.isCleanupPending) + XCTAssertTrue(defaults.bool(forKey: PrivatePaykitService.cleanupPendingKey)) + XCTAssertEqual(PrivatePaykitService.fullCleanupReconciliationMode(), .removePublishedState) + + let preparationError = await service.prepareSavedContacts([publicKey], wallet: WalletViewModel()) + XCTAssertNil(preparationError) + let waiter = Task { + try await service.awaitContactPreparation() + prepared.fulfill() + } + await fulfillment(of: [prepared], timeout: 2) + continuation.finish() + try await disable.value + try await waiter.value + + XCTAssertEqual(publications, 0) + XCTAssertEqual(registryUpdates, 1) + XCTAssertFalse(ContactPaymentsService.isEnabled()) + XCTAssertFalse(PublicPaykitService.isCleanupPending) + XCTAssertFalse(defaults.bool(forKey: PrivatePaykitService.cleanupPendingKey)) + } + func testCleanupStopsAStalledPreparationBeforeLaterContactsAreVisited() async { let service = PrivatePaykitService() let publicKeys = [ From 4dd4d6047640c1cda35a8a03ac20ad8372adbd7c Mon Sep 17 00:00:00 2001 From: benk10 Date: Fri, 2 Oct 2026 18:43:20 -0500 Subject: [PATCH 33/68] perf: limit proposal discovery to the selected contact --- .../PaykitPaymentRequestService.swift | 10 +- .../PaykitPaymentRequestServiceTests.swift | 104 +++++++++++++++++- 2 files changed, 108 insertions(+), 6 deletions(-) diff --git a/Bitkit/Services/PaykitPaymentRequestService.swift b/Bitkit/Services/PaykitPaymentRequestService.swift index 136baabe0..b700301de 100644 --- a/Bitkit/Services/PaykitPaymentRequestService.swift +++ b/Bitkit/Services/PaykitPaymentRequestService.swift @@ -670,7 +670,9 @@ struct PaykitPaymentRequestService { guard draft.expiresAt > proposalDate else { throw PaykitPaymentRequestError.requestExpired } - guard try await eligibleTargets(savedPublicKeys: savedPublicKeys, expectedIdentity: expectedIdentity).contains(target) else { + guard let selectedSavedKey = savedPublicKeys.first(where: { PubkyPublicKeyFormat.matches($0, target.publicKey) }), + try await eligibleTargets(savedPublicKeys: [selectedSavedKey], expectedIdentity: expectedIdentity).contains(target) + else { throw PaykitPaymentRequestError.requestUnavailable } let metadataData = try JSONSerialization.data(withJSONObject: ["note": draft.note]) @@ -727,7 +729,9 @@ struct PaykitPaymentRequestService { guard draft.expiresAt > validationDate else { throw PaykitPaymentRequestError.requestExpired } - guard try await eligibleTargets(savedPublicKeys: savedPublicKeys, expectedIdentity: expectedIdentity).contains(target) else { + guard let selectedSavedKey = savedPublicKeys.first(where: { PubkyPublicKeyFormat.matches($0, target.publicKey) }), + try await eligibleTargets(savedPublicKeys: [selectedSavedKey], expectedIdentity: expectedIdentity).contains(target) + else { throw PaykitPaymentRequestError.requestUnavailable } @@ -746,7 +750,7 @@ struct PaykitPaymentRequestService { } else { nil } - guard try await eligibleTargets(savedPublicKeys: savedPublicKeys, expectedIdentity: expectedIdentity).contains(target) else { + guard try await eligibleTargets(savedPublicKeys: [selectedSavedKey], expectedIdentity: expectedIdentity).contains(target) else { throw PaykitPaymentRequestError.requestUnavailable } try await validateBeforeProposing() diff --git a/BitkitTests/PaykitPaymentRequestServiceTests.swift b/BitkitTests/PaykitPaymentRequestServiceTests.swift index 4c2079c5b..7fa6c1bad 100644 --- a/BitkitTests/PaykitPaymentRequestServiceTests.swift +++ b/BitkitTests/PaykitPaymentRequestServiceTests.swift @@ -5232,6 +5232,104 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { XCTAssertEqual(snapshot.processCallCount, 1) } + func testProposalsOnlyInspectSelectedSavedTarget() async throws { + let publicKey = "pubky\(String(repeating: "y", count: 52))" + let unrelatedKey = "pubky\(String(repeating: "a", count: 52))" + let expectedIdentity = "pubky\(String(repeating: "z", count: 52))" + let now = Date(timeIntervalSince1970: 1_800_000_000) + let expiresAt = now.addingTimeInterval(60) + let target = PaykitPaymentRequestTarget(publicKey: publicKey) + let savedPublicKeys = [unrelatedKey, String(publicKey.dropFirst(5)), publicKey] + + for isSubscription in [false, true] { + let sdk = PaymentRequestSdkMock(records: []) + await sdk.configureRecipients( + peers: [ + linkedPeer(counterparty: unrelatedKey, state: .linked), + linkedPeer(counterparty: publicKey, state: .linked), + ], + requestCapabilitiesByPublicKey: [publicKey: true, unrelatedKey: true] + ) + await sdk.setCapabilityLookupFailing(true, for: unrelatedKey) + try await sdk.setProposalResult(paymentRequestRecord(counterparty: publicKey, role: .payee)) + let service = PaykitPaymentRequestService( + sdk: sdk, now: { now }, isPrivatePaymentPublishingEnabled: { true }, logWarning: { _ in } + ) + + if isSubscription { + _ = try await service.proposeSubscription( + PaykitSubscriptionDraft( + amountSats: 1000, name: "Support", description: "", frequency: .month, + expiresAt: expiresAt, iconData: nil + ), + to: target, savedPublicKeys: savedPublicKeys, expectedIdentity: expectedIdentity, + validateBeforeProposing: {} + ) + } else { + _ = try await service.propose( + PaykitPaymentRequestDraft(amountSats: 1000, note: "Support", expiresAt: expiresAt), + to: target, savedPublicKeys: savedPublicKeys, expectedIdentity: expectedIdentity + ) + } + + let lookups = await sdk.capabilityLookupPublicKeys + XCTAssertEqual(lookups, isSubscription ? [publicKey, publicKey] : [publicKey]) + let snapshot = await sdk.snapshot() + XCTAssertEqual(snapshot.proposedRequests.map(\.counterparty), [publicKey]) + } + } + + func testProposalsRejectUnsavedSelectedTarget() async throws { + let publicKey = "pubky\(String(repeating: "y", count: 52))" + let unrelatedKey = "pubky\(String(repeating: "a", count: 52))" + let expectedIdentity = "pubky\(String(repeating: "z", count: 52))" + let now = Date(timeIntervalSince1970: 1_800_000_000) + let expiresAt = now.addingTimeInterval(60) + let target = PaykitPaymentRequestTarget(publicKey: publicKey) + + for isSubscription in [false, true] { + let sdk = PaymentRequestSdkMock(records: []) + await sdk.configureRecipients( + peers: [ + linkedPeer(counterparty: publicKey, state: .linked), + linkedPeer(counterparty: unrelatedKey, state: .linked), + ], + requestCapabilitiesByPublicKey: [publicKey: true, unrelatedKey: true] + ) + try await sdk.setProposalResult(paymentRequestRecord(counterparty: publicKey, role: .payee)) + let service = PaykitPaymentRequestService( + sdk: sdk, now: { now }, isPrivatePaymentPublishingEnabled: { true }, logWarning: { _ in } + ) + + do { + if isSubscription { + _ = try await service.proposeSubscription( + PaykitSubscriptionDraft( + amountSats: 1000, name: "Support", description: "", frequency: .month, + expiresAt: expiresAt, iconData: nil + ), + to: target, savedPublicKeys: [unrelatedKey], expectedIdentity: expectedIdentity, + validateBeforeProposing: {} + ) + } else { + _ = try await service.propose( + PaykitPaymentRequestDraft(amountSats: 1000, note: "Support", expiresAt: expiresAt), + to: target, savedPublicKeys: [unrelatedKey], expectedIdentity: expectedIdentity + ) + } + XCTFail("Expected the unsaved target to be rejected, subscription: \(isSubscription)") + } catch { + XCTAssertEqual(error as? PaykitPaymentRequestError, .requestUnavailable) + } + + let lookups = await sdk.capabilityLookupPublicKeys + XCTAssertTrue(lookups.isEmpty) + let snapshot = await sdk.snapshot() + XCTAssertTrue(snapshot.proposedRequests.isEmpty) + XCTAssertEqual(snapshot.uploadCount, 0) + } + } + func testProposeRemainsCreatedWhenImmediateDeliveryFails() async throws { let publicKey = "pubky\(String(repeating: "y", count: 52))" let expiresAt = Date(timeIntervalSince1970: 1_900_000_000) @@ -5705,7 +5803,7 @@ private actor PaymentRequestSdkMock: PaykitPaymentRequestSdkHandling, PaykitPaym private var liveSessionAvailable = true private var linkedPeersError: PaymentRequestSdkMockError? private var linkedPeersCallCount = 0 - private var capabilityLookupCount = 0 + private(set) var capabilityLookupPublicKeys: [String] = [] private var failingCapabilityKeys: Set = [] private var proposalResult: PaymentRequestRecord? private var uploadCount = 0 @@ -5815,7 +5913,7 @@ private actor PaymentRequestSdkMock: PaykitPaymentRequestSdkHandling, PaykitPaym } func canReceivePaymentRequests(publicKey: String) throws -> Bool { - capabilityLookupCount += 1 + capabilityLookupPublicKeys.append(publicKey) if failingCapabilityKeys.contains(publicKey) { throw PaymentRequestSdkMockError.receive } @@ -6042,7 +6140,7 @@ private actor PaymentRequestSdkMock: PaykitPaymentRequestSdkHandling, PaykitPaym } func capabilityLookups() -> Int { - capabilityLookupCount + capabilityLookupPublicKeys.count } func setLinkedPeersError(_ error: PaymentRequestSdkMockError?) { From 157e04c06c7f0242b4c1120c9f5cddfd2e9a63db Mon Sep 17 00:00:00 2001 From: benk10 Date: Fri, 2 Oct 2026 20:27:16 -0500 Subject: [PATCH 34/68] fix: reduce redundant paykit synchronization --- Bitkit/AppScene.swift | 55 ++++-- Bitkit/Services/ContactPaymentsService.swift | 33 ++++ .../PaykitPaymentRequestService.swift | 19 +- Bitkit/Services/PubkyService.swift | 7 + BitkitTests/ContactPaymentsServiceTests.swift | 180 ++++++++++++++++++ .../PaykitContactKeysObserverTests.swift | 102 ++++++++++ .../PaykitPaymentRequestServiceTests.swift | 85 +++++++-- journeys/contacts/README.md | 4 + 8 files changed, 439 insertions(+), 46 deletions(-) create mode 100644 BitkitTests/PaykitContactKeysObserverTests.swift diff --git a/Bitkit/AppScene.swift b/Bitkit/AppScene.swift index 2f7e445aa..f27a2868b 100644 --- a/Bitkit/AppScene.swift +++ b/Bitkit/AppScene.swift @@ -207,6 +207,17 @@ struct PaykitPaymentRequestPollingSchedule { } } +struct PaykitContactKeysObserver: ViewModifier { + let publicKeys: [String] + let onChange: ([String]) -> Void + + func body(content: Content) -> some View { + content.onChange(of: publicKeys.sorted(), initial: true) { _, publicKeys in + onChange(publicKeys) + } + } +} + struct AppScene: View { @Environment(\.scenePhase) var scenePhase @EnvironmentObject private var session: SessionManager @@ -459,7 +470,7 @@ struct AppScene: View { paykitPaymentRequestManager.clear() } } - .onReceive(contactsManager.$contacts.map { $0.map(\.publicKey).sorted() }.removeDuplicates()) { publicKeys in + .modifier(PaykitContactKeysObserver(publicKeys: contactsManager.contacts.map(\.publicKey)) { publicKeys in guard PaykitFeatureFlags.isUIEnabled, wallet.walletExists == true, pubkyProfile.authState == .authenticated @@ -468,7 +479,7 @@ struct AppScene: View { await PrivatePaykitService.shared.prepareSavedContacts(publicKeys, wallet: wallet) await refreshIncomingPaykitPaymentRequests() } - } + }) .onReceive(PaykitPaymentProofService.proofStateChangedPublisher) { Task { await refreshIncomingPaykitPaymentRequests() } } @@ -1474,27 +1485,29 @@ struct AppScene: View { } private func retryPendingPaykitEndpointRemoval() async { - let privateCleanupPending = UserDefaults.standard.bool(forKey: PrivatePaykitService.cleanupPendingKey) - await PrivatePaykitService.shared.retryPendingEndpointReconciliation( - wallet: wallet, - savedPublicKeys: contactsManager.contacts.map(\.publicKey) - ) - if privateCleanupPending, !UserDefaults.standard.bool(forKey: PrivatePaykitService.cleanupPendingKey) { - PublicPaykitService.setCleanupPending(true) - } + await ContactPaymentsService.reconcilePendingEndpoints { + let privateCleanupPending = UserDefaults.standard.bool(forKey: PrivatePaykitService.cleanupPendingKey) + await PrivatePaykitService.shared.retryPendingEndpointReconciliation( + wallet: wallet, + savedPublicKeys: contactsManager.contacts.map(\.publicKey) + ) + if privateCleanupPending, !UserDefaults.standard.bool(forKey: PrivatePaykitService.cleanupPendingKey) { + PublicPaykitService.setCleanupPending(true) + } - if PublicPaykitService.isCleanupPending { - do { - switch PublicPaykitService.pendingReconciliationMode() { - case .publishEndpoints: - try await PublicPaykitService.syncCurrentPublishedEndpoints(wallet: wallet) - case .removePublishedState: - try await PublicPaykitService.removePublishedEndpoints() - try await PublicPaykitService.syncPaykitApp() + if PublicPaykitService.isCleanupPending { + do { + switch PublicPaykitService.pendingReconciliationMode() { + case .publishEndpoints: + try await PublicPaykitService.syncCurrentPublishedEndpoints(wallet: wallet) + case .removePublishedState: + try await PublicPaykitService.removePublishedEndpoints() + try await PublicPaykitService.syncPaykitApp() + } + PublicPaykitService.setCleanupPending(false) + } catch { + Logger.warn("Failed to reconcile public Paykit state: \(error)", context: "AppScene") } - PublicPaykitService.setCleanupPending(false) - } catch { - Logger.warn("Failed to reconcile public Paykit state: \(error)", context: "AppScene") } } } diff --git a/Bitkit/Services/ContactPaymentsService.swift b/Bitkit/Services/ContactPaymentsService.swift index 2af69c6d5..1c3aa426d 100644 --- a/Bitkit/Services/ContactPaymentsService.swift +++ b/Bitkit/Services/ContactPaymentsService.swift @@ -3,6 +3,9 @@ import Foundation enum ContactPaymentsService { static let confirmedPreferenceKey = "hasConfirmedPublicPaykitEndpoints" + @MainActor private static var isOperationActive = false + @MainActor private static var operationWaiters: [CheckedContinuation] = [] + struct Operations { let syncPaykitApp: (_ privatePaymentsEnabled: Bool) async throws -> Void let syncPublicEndpoints: (_ publish: Bool) async throws -> Void @@ -80,6 +83,10 @@ enum ContactPaymentsService { operations: Operations, defaults: UserDefaults = .standard ) async throws { + await acquireOperation() + defer { releaseOperation() } + try Task.checkCancellation() + enableAllPaymentOptions(defaults: defaults) if !enabled { @@ -116,6 +123,32 @@ enum ContactPaymentsService { } } + @MainActor + static func reconcilePendingEndpoints(_ reconcile: () async -> Void) async { + guard !isOperationActive, !Task.isCancelled else { return } + isOperationActive = true + defer { releaseOperation() } + await reconcile() + } + + @MainActor + private static func acquireOperation() async { + guard isOperationActive else { + isOperationActive = true + return + } + await withCheckedContinuation { operationWaiters.append($0) } + } + + @MainActor + private static func releaseOperation() { + guard !operationWaiters.isEmpty else { + isOperationActive = false + return + } + operationWaiters.removeFirst().resume() + } + @MainActor private static func enable( contactPublicKeys: [String], diff --git a/Bitkit/Services/PaykitPaymentRequestService.swift b/Bitkit/Services/PaykitPaymentRequestService.swift index b700301de..c12a0d438 100644 --- a/Bitkit/Services/PaykitPaymentRequestService.swift +++ b/Bitkit/Services/PaykitPaymentRequestService.swift @@ -490,6 +490,7 @@ enum PaykitPaymentRequestError: LocalizedError, Equatable { } protocol PaykitPaymentRequestSdkHandling: Sendable { + func processOutboundPrivateMessages(counterparty: String) async throws -> Paykit.OutboundPrivateSendReport func processPendingPrivateMessages() async throws -> [Paykit.OutboundPrivateCounterpartySendReport] func receivePrivateMessagesFromLinkedPeers() async throws -> [Paykit.PrivateStreamCounterpartyIntakeReport] func paymentRequests() async throws -> [Paykit.PaymentRequestRecord] @@ -697,7 +698,7 @@ struct PaykitPaymentRequestService { terms: terms, expectedIdentity: expectedIdentity ) - let reports = await (try? processPendingMessages()) ?? [] + let reports = await (try? processPendingMessages(to: target.publicKey)) ?? [] let deliveryStatus = proposalWasSent(record, reports: reports) ? PaykitPaymentRequest.DeliveryStatus.sent : .queued return PaykitPaymentRequest( createdRecord: record, @@ -773,7 +774,7 @@ struct PaykitPaymentRequestService { terms: terms, expectedIdentity: expectedIdentity ) - let reports = await (try? processPendingMessages()) ?? [] + let reports = await (try? processPendingMessages(to: target.publicKey)) ?? [] let deliveryStatus = proposalWasSent(record, reports: reports) ? PaykitPaymentRequest.DeliveryStatus.sent : .queued @@ -939,9 +940,19 @@ struct PaykitPaymentRequestService { } @discardableResult - private func processPendingMessages() async throws -> [Paykit.OutboundPrivateCounterpartySendReport] { + private func processPendingMessages(to counterparty: String? = nil) async throws -> [Paykit.OutboundPrivateCounterpartySendReport] { do { - let reports = try await sdk.processPendingPrivateMessages() + let reports: [Paykit.OutboundPrivateCounterpartySendReport] + if let counterparty { + let report = try await sdk.processOutboundPrivateMessages(counterparty: counterparty) + reports = [Paykit.OutboundPrivateCounterpartySendReport( + counterparty: counterparty, + report: report, + error: nil + )] + } else { + reports = try await sdk.processPendingPrivateMessages() + } for report in reports { if let error = report.error { logWarning( diff --git a/Bitkit/Services/PubkyService.swift b/Bitkit/Services/PubkyService.swift index d687dd95e..9dda3b888 100644 --- a/Bitkit/Services/PubkyService.swift +++ b/Bitkit/Services/PubkyService.swift @@ -785,6 +785,13 @@ actor PaykitSdkService { } } + @discardableResult + func processOutboundPrivateMessages(counterparty: String) async throws -> OutboundPrivateSendReport { + try await withStateRevisionTracking { sdk in + try await sdk.processOutboundPrivateMessages(counterparty: counterparty) + } + } + @discardableResult func processPendingPrivateMessages() async throws -> [OutboundPrivateCounterpartySendReport] { try await withStateRevisionTracking { sdk in diff --git a/BitkitTests/ContactPaymentsServiceTests.swift b/BitkitTests/ContactPaymentsServiceTests.swift index 060510045..7ea555124 100644 --- a/BitkitTests/ContactPaymentsServiceTests.swift +++ b/BitkitTests/ContactPaymentsServiceTests.swift @@ -260,6 +260,182 @@ final class ContactPaymentsServiceTests: XCTestCase { } } + func testForegroundReconciliationSkipsBothPhasesOfActiveDisable() async throws { + try await withIsolatedDefaultsAsync { defaults in + let operations = OperationsSpy() + let privateRemovalStarted = expectation(description: "Private removal started") + let publicRemovalStarted = expectation(description: "Public removal started") + let (privateRemoval, finishPrivateRemoval) = AsyncStream.makeStream() + let (publicRemoval, finishPublicRemoval) = AsyncStream.makeStream() + defer { + finishPrivateRemoval.finish() + finishPublicRemoval.finish() + } + operations.onRemovePrivateEndpoints = { + privateRemovalStarted.fulfill() + for await _ in privateRemoval {} + } + operations.onSyncPublicEndpoints = { _ in + publicRemovalStarted.fulfill() + for await _ in publicRemoval {} + } + + let disable = Task { + try await ContactPaymentsService.setEnabled( + false, contactPublicKeys: [], canUsePrivatePayments: true, + operations: operations.makeOperations(defaults: defaults), defaults: defaults + ) + } + await fulfillment(of: [privateRemovalStarted], timeout: 2) + XCTAssertFalse(ContactPaymentsService.isEnabled(defaults: defaults)) + XCTAssertTrue(defaults.bool(forKey: PrivatePaykitService.cleanupPendingKey)) + XCTAssertTrue(defaults.bool(forKey: PublicPaykitService.cleanupPendingKey)) + await ContactPaymentsService.reconcilePendingEndpoints { + XCTFail("Foreground cleanup must not restart private withdrawal") + } + + finishPrivateRemoval.finish() + await fulfillment(of: [publicRemovalStarted], timeout: 2) + XCTAssertFalse(defaults.bool(forKey: PrivatePaykitService.cleanupPendingKey)) + XCTAssertTrue(defaults.bool(forKey: PublicPaykitService.cleanupPendingKey)) + await ContactPaymentsService.reconcilePendingEndpoints { + XCTFail("Foreground cleanup must not duplicate public withdrawal") + } + + finishPublicRemoval.finish() + try await disable.value + XCTAssertEqual(operations.calls, ["private:remove", "public:false"]) + XCTAssertFalse(defaults.bool(forKey: PublicPaykitService.cleanupPendingKey)) + var reconciled = false + await ContactPaymentsService.reconcilePendingEndpoints { reconciled = true } + XCTAssertTrue(reconciled) + } + } + + func testEnablingWaitsForSuccessfulOrFailedDisable() async throws { + for removalFails in [false, true] { + try await withIsolatedDefaultsAsync { defaults in + let operations = OperationsSpy() + operations.privateRemovalFailures = removalFails ? [1] : [] + let removalStarted = expectation(description: "Withdrawal started") + let enableRequested = expectation(description: "Enable requested during withdrawal") + let (removal, finishRemoval) = AsyncStream.makeStream() + defer { finishRemoval.finish() } + operations.onRemovePrivateEndpoints = { + removalStarted.fulfill() + for await _ in removal {} + } + + let disable = Task { + try await ContactPaymentsService.setEnabled( + false, contactPublicKeys: [], canUsePrivatePayments: true, + operations: operations.makeOperations(defaults: defaults), defaults: defaults + ) + } + await fulfillment(of: [removalStarted], timeout: 2) + let enable = Task { + enableRequested.fulfill() + try await ContactPaymentsService.setEnabled( + true, contactPublicKeys: ["contact-a"], canUsePrivatePayments: true, + operations: operations.makeOperations(defaults: defaults), defaults: defaults + ) + } + await fulfillment(of: [enableRequested], timeout: 2) + XCTAssertFalse(ContactPaymentsService.isEnabled(defaults: defaults)) + XCTAssertEqual(operations.calls, ["private:remove"]) + + finishRemoval.finish() + switch await disable.result { + case .success: + XCTAssertFalse(removalFails) + case let .failure(error): + XCTAssertTrue(removalFails) + XCTAssertEqual(error as? TestError, .operationFailed) + } + try await enable.value + XCTAssertEqual(operations.calls, ["private:remove", "public:false", "app:true", "public:true", "private:publish"]) + XCTAssertTrue(ContactPaymentsService.isEnabled(defaults: defaults)) + XCTAssertFalse(defaults.bool(forKey: PrivatePaykitService.cleanupPendingKey)) + XCTAssertFalse(defaults.bool(forKey: PublicPaykitService.cleanupPendingKey)) + } + } + } + + func testForegroundReconciliationCoalescesAndSerializesSharingChanges() async throws { + try await withIsolatedDefaultsAsync { defaults in + let operations = OperationsSpy() + let reconciliationStarted = expectation(description: "Reconciliation started") + let enableRequested = expectation(description: "Enable requested during reconciliation") + let (reconciliation, finishReconciliation) = AsyncStream.makeStream() + defer { finishReconciliation.finish() } + var reconciliations = 0 + let retry = Task { + await ContactPaymentsService.reconcilePendingEndpoints { + reconciliations += 1 + reconciliationStarted.fulfill() + for await _ in reconciliation {} + } + } + await fulfillment(of: [reconciliationStarted], timeout: 2) + await ContactPaymentsService.reconcilePendingEndpoints { reconciliations += 1 } + let enable = Task { + enableRequested.fulfill() + try await ContactPaymentsService.setEnabled( + true, contactPublicKeys: [], canUsePrivatePayments: true, + operations: operations.makeOperations(defaults: defaults), defaults: defaults + ) + } + await fulfillment(of: [enableRequested], timeout: 2) + XCTAssertEqual(reconciliations, 1) + XCTAssertTrue(operations.calls.isEmpty) + + finishReconciliation.finish() + await retry.value + try await enable.value + XCTAssertTrue(ContactPaymentsService.isEnabled(defaults: defaults)) + XCTAssertEqual(operations.publicPublicationValues, [true]) + } + } + + func testCancelledSharingChangeDoesNotPublishAfterReconciliation() async throws { + try await withIsolatedDefaultsAsync { defaults in + let operations = OperationsSpy() + let reconciliationStarted = expectation(description: "Reconciliation started") + let enableRequested = expectation(description: "Enable requested during reconciliation") + let (reconciliation, finishReconciliation) = AsyncStream.makeStream() + defer { finishReconciliation.finish() } + let retry = Task { + await ContactPaymentsService.reconcilePendingEndpoints { + reconciliationStarted.fulfill() + for await _ in reconciliation {} + } + } + await fulfillment(of: [reconciliationStarted], timeout: 2) + let enable = Task { + enableRequested.fulfill() + try await ContactPaymentsService.setEnabled( + true, contactPublicKeys: [], canUsePrivatePayments: true, + operations: operations.makeOperations(defaults: defaults), defaults: defaults + ) + } + await fulfillment(of: [enableRequested], timeout: 2) + enable.cancel() + finishReconciliation.finish() + await retry.value + switch await enable.result { + case .success: + XCTFail("Cancelled sharing change must not publish") + case let .failure(error): + XCTAssertTrue(error is CancellationError) + } + XCTAssertTrue(operations.calls.isEmpty) + XCTAssertFalse(ContactPaymentsService.isEnabled(defaults: defaults)) + var reconciled = false + await ContactPaymentsService.reconcilePendingEndpoints { reconciled = true } + XCTAssertTrue(reconciled) + } + } + private func withIsolatedDefaults(_ body: (UserDefaults) throws -> Void) throws { let suiteName = "ContactPaymentsServiceTests.\(UUID().uuidString)" let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) @@ -299,6 +475,8 @@ final class ContactPaymentsServiceTests: XCTestCase { var privateRemovalFailures: Set = [] var onPreparePrivateEndpoints: (() -> Void)? var preparePrivateEndpoints: (([String], Bool) async -> Error?)? + var onRemovePrivateEndpoints: (() async -> Void)? + var onSyncPublicEndpoints: ((Bool) async -> Void)? func makeOperations(defaults: UserDefaults? = nil) -> ContactPaymentsService.Operations { ContactPaymentsService.Operations( @@ -308,6 +486,7 @@ final class ContactPaymentsServiceTests: XCTestCase { syncPublicEndpoints: { publish in self.calls.append("public:\(publish)") self.publicPublicationValues.append(publish) + await self.onSyncPublicEndpoints?(publish) if self.publicPublicationFailures.contains(self.publicPublicationValues.count) { throw TestError.operationFailed } @@ -329,6 +508,7 @@ final class ContactPaymentsServiceTests: XCTestCase { removePrivateEndpoints: { self.calls.append("private:remove") self.privateRemovalCount += 1 + await self.onRemovePrivateEndpoints?() if self.privateRemovalFailures.contains(self.privateRemovalCount) { throw TestError.operationFailed } diff --git a/BitkitTests/PaykitContactKeysObserverTests.swift b/BitkitTests/PaykitContactKeysObserverTests.swift new file mode 100644 index 000000000..fdb3b592f --- /dev/null +++ b/BitkitTests/PaykitContactKeysObserverTests.swift @@ -0,0 +1,102 @@ +@testable import Bitkit +import SwiftUI +import XCTest + +@MainActor +final class PaykitContactKeysObserverTests: XCTestCase { + func testStableKeysDoNotReplayWhenBodyRebuilds() async throws { + let state = ContactKeysTestState(publicKeys: ["alice", "bob"]) + var received: [[String]] = [] + var bodyCount = 0 + let window = host(state: state, onBody: { bodyCount += 1 }) { received.append($0) } + defer { unmount(window) } + try await settle() + + for revision in 1 ... 5 { + state.revision = revision + state.publicKeys = revision.isMultiple(of: 2) ? ["alice", "bob"] : ["bob", "alice"] + try await settle() + } + + XCTAssertGreaterThan(bodyCount, 5) + XCTAssertEqual(received, [["alice", "bob"]]) + } + + func testInitialEmptyKeysAndMembershipChangesAreDelivered() async throws { + let state = ContactKeysTestState(publicKeys: []) + var received: [[String]] = [] + let window = host(state: state) { received.append($0) } + defer { unmount(window) } + try await settle() + + for keys in [["alice"], ["bob", "alice"], ["bob"], []] { + state.publicKeys = keys + try await settle() + } + + XCTAssertEqual(received, [[], ["alice"], ["alice", "bob"], ["bob"], []]) + } + + func testCallbackStateUpdatesDoNotRetriggerContactPreparation() async throws { + let state = ContactKeysTestState(publicKeys: ["alice"]) + var preparationCount = 0 + var bodyCount = 0 + let window = host(state: state, onBody: { bodyCount += 1 }) { _ in + preparationCount += 1 + if preparationCount < 5 { + Task { @MainActor in state.revision += 1 } + } + } + defer { unmount(window) } + try await Task.sleep(for: .milliseconds(500)) + + XCTAssertGreaterThan(bodyCount, 1) + XCTAssertEqual(preparationCount, 1) + } + + private func host( + state: ContactKeysTestState, + onBody: @escaping () -> Void = {}, + onKeysChange: @escaping ([String]) -> Void + ) -> UIWindow { + let window = UIWindow(frame: UIScreen.main.bounds) + window.rootViewController = UIHostingController(rootView: ContactKeysTestView( + state: state, + onBody: onBody, + onKeysChange: onKeysChange + )) + window.makeKeyAndVisible() + return window + } + + private func unmount(_ window: UIWindow) { + window.isHidden = true + window.rootViewController = nil + } + + private func settle() async throws { + try await Task.sleep(for: .milliseconds(100)) + } +} + +@MainActor +private final class ContactKeysTestState: ObservableObject { + @Published var publicKeys: [String] + @Published var revision = 0 + + init(publicKeys: [String]) { + self.publicKeys = publicKeys + } +} + +private struct ContactKeysTestView: View { + @ObservedObject var state: ContactKeysTestState + let onBody: () -> Void + let onKeysChange: ([String]) -> Void + + var body: some View { + let _ = onBody() + Text("Revision \(state.revision)") + .modifier(PaykitContactKeysObserver(publicKeys: state.publicKeys, onChange: onKeysChange)) + } +} diff --git a/BitkitTests/PaykitPaymentRequestServiceTests.swift b/BitkitTests/PaykitPaymentRequestServiceTests.swift index 7fa6c1bad..b4a5f646c 100644 --- a/BitkitTests/PaykitPaymentRequestServiceTests.swift +++ b/BitkitTests/PaykitPaymentRequestServiceTests.swift @@ -5229,10 +5229,11 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { XCTAssertTrue(snapshot.proposedRequests.first?.endpointIdentifiers.allSatisfy { PublicPaykitService.MethodId(rawValue: $0)?.onchainNetwork.map { $0 == Env.network } ?? true } == true) - XCTAssertEqual(snapshot.processCallCount, 1) + XCTAssertEqual(snapshot.processCallCount, 0) + XCTAssertEqual(snapshot.processedCounterparties, [publicKey]) } - func testProposalsOnlyInspectSelectedSavedTarget() async throws { + func testProposalsOnlyInspectAndDrainSelectedSavedTarget() async throws { let publicKey = "pubky\(String(repeating: "y", count: 52))" let unrelatedKey = "pubky\(String(repeating: "a", count: 52))" let expectedIdentity = "pubky\(String(repeating: "z", count: 52))" @@ -5251,31 +5252,52 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { requestCapabilitiesByPublicKey: [publicKey: true, unrelatedKey: true] ) await sdk.setCapabilityLookupFailing(true, for: unrelatedKey) - try await sdk.setProposalResult(paymentRequestRecord(counterparty: publicKey, role: .payee)) + try await sdk.setProposalResult(paymentRequestRecord( + counterparty: publicKey, role: .payee, proposalOutboundMessageId: 7 + )) + await sdk.setProcessReports([OutboundPrivateCounterpartySendReport( + counterparty: publicKey, + report: OutboundPrivateSendReport( + attempted: [7], sent: [7], failed: [], reservationCleanupFailures: [], recoveryMarkerFailures: [] + ), + error: nil + )]) + await sdk.pauseNextProcess(for: unrelatedKey) let service = PaykitPaymentRequestService( sdk: sdk, now: { now }, isPrivatePaymentPublishingEnabled: { true }, logWarning: { _ in } ) - if isSubscription { - _ = try await service.proposeSubscription( - PaykitSubscriptionDraft( - amountSats: 1000, name: "Support", description: "", frequency: .month, - expiresAt: expiresAt, iconData: nil - ), - to: target, savedPublicKeys: savedPublicKeys, expectedIdentity: expectedIdentity, - validateBeforeProposing: {} - ) - } else { - _ = try await service.propose( - PaykitPaymentRequestDraft(amountSats: 1000, note: "Support", expiresAt: expiresAt), - to: target, savedPublicKeys: savedPublicKeys, expectedIdentity: expectedIdentity - ) + let completed = expectation(description: "Selected proposal completes without unrelated delivery") + let proposal = Task { + let status: PaykitPaymentRequest.DeliveryStatus? = if isSubscription { + try await service.proposeSubscription( + PaykitSubscriptionDraft( + amountSats: 1000, name: "Support", description: "", frequency: .month, + expiresAt: expiresAt, iconData: nil + ), + to: target, savedPublicKeys: savedPublicKeys, expectedIdentity: expectedIdentity, + validateBeforeProposing: {} + ).deliveryStatus + } else { + try await service.propose( + PaykitPaymentRequestDraft(amountSats: 1000, note: "Support", expiresAt: expiresAt), + to: target, savedPublicKeys: savedPublicKeys, expectedIdentity: expectedIdentity + ).deliveryStatus + } + completed.fulfill() + return status } + await fulfillment(of: [completed], timeout: 1) + await sdk.resumeProcess() + let status = try await proposal.value + XCTAssertEqual(status, .sent) let lookups = await sdk.capabilityLookupPublicKeys XCTAssertEqual(lookups, isSubscription ? [publicKey, publicKey] : [publicKey]) let snapshot = await sdk.snapshot() XCTAssertEqual(snapshot.proposedRequests.map(\.counterparty), [publicKey]) + XCTAssertEqual(snapshot.processedCounterparties, [publicKey]) + XCTAssertEqual(snapshot.processCallCount, 0) } } @@ -5356,7 +5378,8 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { XCTAssertEqual(manager.outgoingRequests.map(\.paymentRequestId), ["outgoing"]) let snapshot = await sdk.snapshot() XCTAssertEqual(snapshot.proposedRequests.count, 1) - XCTAssertEqual(snapshot.processCallCount, 1) + XCTAssertEqual(snapshot.processCallCount, 0) + XCTAssertEqual(snapshot.processedCounterparties, [publicKey]) } func testProposeRevalidatesTargetBeforeEnqueueing() async throws { @@ -5811,11 +5834,13 @@ private actor PaymentRequestSdkMock: PaykitPaymentRequestSdkHandling, PaykitPaym private var isUploadPaused = false private var uploadContinuation: CheckedContinuation? private var processCallCount = 0 + private var processedCounterparties: [String] = [] private var receiveCallCount = 0 private var processFailuresRemaining = 0 private var processCancellationsRemaining = 0 private var processReports: [OutboundPrivateCounterpartySendReport] = [] private var shouldPauseNextProcess = false + private var pausedProcessCounterparty: String? private var isProcessPaused = false private var processContinuation: CheckedContinuation? private var receiveError: PaymentRequestSdkMockError? @@ -5846,7 +5871,22 @@ private actor PaymentRequestSdkMock: PaykitPaymentRequestSdkHandling, PaykitPaym func processPendingPrivateMessages() async throws -> [OutboundPrivateCounterpartySendReport] { processCallCount += 1 - if shouldPauseNextProcess { + try await processMessages() + return processReports + } + + func processOutboundPrivateMessages(counterparty: String) async throws -> OutboundPrivateSendReport { + processedCounterparties.append(counterparty) + try await processMessages(counterparty: counterparty) + return processReports.first { $0.counterparty == counterparty }?.report ?? OutboundPrivateSendReport( + attempted: [], sent: [], failed: [], reservationCleanupFailures: [], recoveryMarkerFailures: [] + ) + } + + private func processMessages(counterparty: String? = nil) async throws { + if shouldPauseNextProcess, + counterparty == nil || pausedProcessCounterparty == nil || counterparty == pausedProcessCounterparty + { shouldPauseNextProcess = false isProcessPaused = true await withCheckedContinuation { processContinuation = $0 } @@ -5856,7 +5896,7 @@ private actor PaymentRequestSdkMock: PaykitPaymentRequestSdkHandling, PaykitPaym processCancellationsRemaining -= 1 throw CancellationError() } - guard processFailuresRemaining > 0 else { return processReports } + guard processFailuresRemaining > 0 else { return } processFailuresRemaining -= 1 throw PaymentRequestSdkMockError.process } @@ -6068,8 +6108,9 @@ private actor PaymentRequestSdkMock: PaykitPaymentRequestSdkHandling, PaykitPaym acceptanceResponseError = error } - func pauseNextProcess() { + func pauseNextProcess(for counterparty: String? = nil) { shouldPauseNextProcess = true + pausedProcessCounterparty = counterparty } func processIsPaused() -> Bool { @@ -6205,6 +6246,7 @@ private actor PaymentRequestSdkMock: PaykitPaymentRequestSdkHandling, PaykitPaym PaymentRequestSdkSnapshot( uploadCount: uploadCount, processCallCount: processCallCount, + processedCounterparties: processedCounterparties, receiveCallCount: receiveCallCount, acceptedRequests: acceptedRequests, rejectedRequests: rejectedRequests, @@ -6229,6 +6271,7 @@ private actor PaymentRequestSdkMock: PaykitPaymentRequestSdkHandling, PaykitPaym private struct PaymentRequestSdkSnapshot { let uploadCount: Int let processCallCount: Int + let processedCounterparties: [String] let receiveCallCount: Int let acceptedRequests: [PaymentRequestInvocation] let rejectedRequests: [PaymentRequestInvocation] diff --git a/journeys/contacts/README.md b/journeys/contacts/README.md index 9d8fcbd82..48cb12649 100644 --- a/journeys/contacts/README.md +++ b/journeys/contacts/README.md @@ -40,3 +40,7 @@ update fail as well. Turn off contact payments. The app must report the failure, and retain both cleanup jobs without republishing cleared private lists. Restore connectivity and foreground the app. Cleanup must finish while sharing stays off. Repeat with only the trailing public endpoint or app-registry update failing. + +Hold withdrawal in progress and foreground the app. It must not start another cleanup. Request +sharing on again before withdrawal finishes: publication must wait until the earlier cleanup ends, +then leave sharing on. Repeat while a foreground cleanup is already running. From c334a6f281e425a166294badb3d57b0a06258323 Mon Sep 17 00:00:00 2001 From: benk10 Date: Fri, 2 Oct 2026 21:43:41 -0500 Subject: [PATCH 35/68] fix: keep contact payments responsive during background sync --- Bitkit/AppScene.swift | 8 +- .../PaykitPaymentRequestService.swift | 74 +++++-- .../PrivatePaykitService+Contacts.swift | 46 +++- Bitkit/Services/PubkyService.swift | 88 ++------ .../PaykitPaymentRequestServiceTests.swift | 205 +++++++++++++++++- BitkitTests/PaykitSdkOperationLockTests.swift | 177 +++++++++------ BitkitTests/PrivatePaykitServiceTests.swift | 118 +++++++++- journeys/contacts/README.md | 14 ++ 8 files changed, 562 insertions(+), 168 deletions(-) diff --git a/Bitkit/AppScene.swift b/Bitkit/AppScene.swift index f27a2868b..f44878a4f 100644 --- a/Bitkit/AppScene.swift +++ b/Bitkit/AppScene.swift @@ -475,6 +475,7 @@ struct AppScene: View { wallet.walletExists == true, pubkyProfile.authState == .authenticated else { return } + paykitPaymentRequestManager.updateSavedPublicKeys(publicKeys) Task { await PrivatePaykitService.shared.prepareSavedContacts(publicKeys, wallet: wallet) await refreshIncomingPaykitPaymentRequests() @@ -1119,12 +1120,9 @@ struct AppScene: View { return } + guard !Task.isCancelled else { return } + paykitPaymentRequestManager.updateSavedPublicKeys(contactsManager.contacts.map(\.publicKey)) if refreshMaintenance { - do { - try await PrivatePaykitService.shared.awaitContactPreparation() - } catch { - return - } await PaykitPaymentProofService.shared.reconcile() } guard let identity = pubkyProfile.publicKey else { return } diff --git a/Bitkit/Services/PaykitPaymentRequestService.swift b/Bitkit/Services/PaykitPaymentRequestService.swift index c12a0d438..5a0b311c7 100644 --- a/Bitkit/Services/PaykitPaymentRequestService.swift +++ b/Bitkit/Services/PaykitPaymentRequestService.swift @@ -381,6 +381,7 @@ struct PaykitPaymentRequestTarget: Identifiable, Equatable, Hashable { struct PaykitPaymentRequestTargetDiscovery: Equatable { let targets: [PaykitPaymentRequestTarget] let isComplete: Bool + let capabilityCheckedPublicKeys: Set } struct PaykitPaymentRequestDraft: Hashable { @@ -618,7 +619,7 @@ struct PaykitPaymentRequestService { expectedIdentity: String, previousTargets: [PaykitPaymentRequestTarget] = [] ) async throws -> PaykitPaymentRequestTargetDiscovery { - let unavailable = PaykitPaymentRequestTargetDiscovery(targets: [], isComplete: true) + let unavailable = PaykitPaymentRequestTargetDiscovery(targets: [], isComplete: true, capabilityCheckedPublicKeys: []) guard isPrivatePaymentPublishingEnabled(), !Self.acceptedPaymentEndpointIdentifiers().isEmpty else { return unavailable } guard let identityStatus = try await sdk.identityStatus(), identityStatus.capability == .privateLinkCapable, @@ -630,18 +631,48 @@ struct PaykitPaymentRequestService { } let linkedPeers = try await sdk.linkedPeers().filter { $0.state == .linked } let linkedKeys = Set(linkedPeers.compactMap { PubkyPublicKeyFormat.normalized($0.counterparty) }) - - var targets: [PaykitPaymentRequestTarget] = [] - var isComplete = true - for publicKey in savedKeys { - guard linkedKeys.contains(publicKey) else { continue } - let canReceive: Bool + let candidates = savedKeys.filter { linkedKeys.contains($0) } + let lookup: @Sendable (String) async throws -> (String, Result) = { [sdk] publicKey in do { try Task.checkCancellation() - canReceive = try await sdk.canReceivePaymentRequests(publicKey: publicKey) + let canReceive = try await sdk.canReceivePaymentRequests(publicKey: publicKey) + try Task.checkCancellation() + return (publicKey, .success(canReceive)) } catch is CancellationError { throw CancellationError() } catch { + try Task.checkCancellation() + return (publicKey, .failure(error)) + } + } + let capabilities = try await withThrowingTaskGroup(of: (String, Result).self) { group in + var remaining = candidates.makeIterator() + for _ in 0 ..< 8 { + guard let publicKey = remaining.next() else { break } + group.addTask { try await lookup(publicKey) } + } + var results: [String: Result] = [:] + while let (publicKey, result) = try await group.next() { + try Task.checkCancellation() + results[publicKey] = result + if let nextKey = remaining.next() { + group.addTask { try await lookup(nextKey) } + } + } + return results + } + try Task.checkCancellation() + + var targets: [PaykitPaymentRequestTarget] = [] + var isComplete = true + var capabilityCheckedPublicKeys = Set() + for publicKey in candidates { + guard let result = capabilities[publicKey] else { continue } + switch result { + case let .success(canReceive): + capabilityCheckedPublicKeys.insert(publicKey) + if canReceive { targets.append(PaykitPaymentRequestTarget(publicKey: publicKey)) } + case let .failure(error): isComplete = false logWarning("Failed to inspect Paykit payment request support for \(PubkyPublicKeyFormat.redacted(publicKey)): \(error)") if let previousTarget = previousTargets.first(where: { @@ -649,12 +680,11 @@ struct PaykitPaymentRequestService { }) { targets.append(previousTarget) } - continue } - guard canReceive else { continue } - targets.append(PaykitPaymentRequestTarget(publicKey: publicKey)) } - return PaykitPaymentRequestTargetDiscovery(targets: targets, isComplete: isComplete) + return PaykitPaymentRequestTargetDiscovery( + targets: targets, isComplete: isComplete, capabilityCheckedPublicKeys: capabilityCheckedPublicKeys + ) } func propose( @@ -1266,11 +1296,20 @@ final class PaykitPaymentRequestManager { activeIdentity = normalizedIdentity } + func updateSavedPublicKeys(_ publicKeys: [String]) { + guard publicKeys != savedPublicKeys else { return } + eligibilityGeneration += 1 + savedPublicKeys = publicKeys + eligibleTargets.removeAll { target in + !publicKeys.contains { PubkyPublicKeyFormat.matches($0, target.publicKey) } + } + } + func refreshEligibleTargets(savedPublicKeys: [String]) async { + updateSavedPublicKeys(savedPublicKeys) eligibilityGeneration += 1 let generation = eligibilityGeneration let currentStateGeneration = stateGeneration - self.savedPublicKeys = savedPublicKeys guard isAvailable(), let activeIdentity else { eligibleTargets = [] return @@ -1297,7 +1336,8 @@ final class PaykitPaymentRequestManager { if discovery.isComplete { let checkedAt = now() for publicKey in savedPublicKeys { - eligibilityCheckDates[Self.eligibilityKey(publicKey)] = checkedAt + let key = Self.eligibilityKey(publicKey) + eligibilityCheckDates[key] = discovery.capabilityCheckedPublicKeys.contains(key) ? checkedAt : nil } } } catch is CancellationError { @@ -1335,7 +1375,8 @@ final class PaykitPaymentRequestManager { lastFullEligibilityWriteGeneration <= generation else { return eligibleTarget(publicKey: publicKey) } singleEligibilityWriteGenerations[Self.eligibilityKey(publicKey)] = generation - eligibilityCheckDates[Self.eligibilityKey(publicKey)] = now() + let key = Self.eligibilityKey(publicKey) + eligibilityCheckDates[key] = discovery.capabilityCheckedPublicKeys.contains(key) ? now() : nil var targets = eligibleTargets.filter { !PubkyPublicKeyFormat.matches($0.publicKey, publicKey) } if let target = discovery.targets.first { targets.append(target) @@ -1373,8 +1414,7 @@ final class PaykitPaymentRequestManager { } /// Returns the known target at once, otherwise waits at most `timeout` for a refresh without blocking on a slow SDK call. - /// The lookup shares the SDK lock with payment resolution, so a contact checked in the last 30 seconds is not looked up again, - /// and a lookup that outlives the timeout is cancelled. + /// A contact checked in the last 30 seconds is not looked up again, and a lookup that outlives the timeout is cancelled. func eligibleTarget(publicKey: String, waitingAtMost timeout: Duration) async -> PaykitPaymentRequestTarget? { if let target = eligibleTarget(publicKey: publicKey) { return target diff --git a/Bitkit/Services/PrivatePaykitService+Contacts.swift b/Bitkit/Services/PrivatePaykitService+Contacts.swift index d97c45a4c..81e7c1905 100644 --- a/Bitkit/Services/PrivatePaykitService+Contacts.swift +++ b/Bitkit/Services/PrivatePaykitService+Contacts.swift @@ -26,15 +26,15 @@ extension PrivatePaykitService { let ensureLink: (String) async throws -> Void let pendingOutbound: () async throws -> [String] let linkedPeers: () async throws -> [LinkedPeerRecord] - let processPending: () async throws -> Void - let receive: () async throws -> Void + let processPending: (String) async throws -> Void + let receive: (String) async throws -> Void static let live = PrivateMessageDrainOperations( ensureLink: { _ = try await PaykitSdkService.shared.ensureLinkWithPeer($0) }, pendingOutbound: { try await PaykitSdkService.shared.pendingOutboundPrivateCounterparties() }, linkedPeers: { try await PaykitSdkService.shared.linkedPeers() }, - processPending: { try await PaykitSdkService.shared.processPendingPrivateMessages() }, - receive: { try await PaykitSdkService.shared.receivePrivateMessagesFromLinkedPeers() } + processPending: { _ = try await PaykitSdkService.shared.processOutboundPrivateMessages(counterparty: $0) }, + receive: { _ = try await PaykitSdkService.shared.receivePrivateMessages(counterparty: $0) } ) } @@ -616,10 +616,12 @@ extension PrivatePaykitService { advancing retryKeys: [String], operations: PrivateMessageDrainOperations = .live ) async { + let retryKeys = Set(retryKeys.map { PubkyPublicKeyFormat.normalized($0) ?? $0 }) + guard !retryKeys.isEmpty, !Task.isCancelled else { return } + let generation = preparationGeneration do { - let generation = preparationGeneration - for retryKey in Set(retryKeys) { - guard generation == preparationGeneration else { break } + for retryKey in retryKeys.sorted() { + guard generation == preparationGeneration, !Task.isCancelled else { return } if let retryAt = unavailableLinkRetryAt[retryKey], retryAt > Date() { continue } do { try await operations.ensureLink(retryKey) @@ -630,11 +632,33 @@ extension PrivatePaykitService { ) } } - if try await !operations.pendingOutbound().isEmpty { - try await operations.processPending() + guard generation == preparationGeneration, !Task.isCancelled else { return } + let pendingKeys = try await retryKeys.intersection(operations.pendingOutbound().map { PubkyPublicKeyFormat.normalized($0) ?? $0 }) + for publicKey in pendingKeys.sorted() { + guard generation == preparationGeneration, !Task.isCancelled else { return } + do { + try await operations.processPending(publicKey) + } catch { + Logger.warn( + "Failed to send private Paykit messages during \(reason): \(PaykitResolutionFailureDiagnostics.reason(for: error))", + context: "PrivatePaykit" + ) + } } - if try await operations.linkedPeers().contains(where: { $0.state == .linked }) { - try await operations.receive() + guard generation == preparationGeneration, !Task.isCancelled else { return } + let linkedKeys = try await Set(operations.linkedPeers().filter { $0.state == .linked }.map { + PubkyPublicKeyFormat.normalized($0.counterparty) ?? $0.counterparty + }) + for publicKey in retryKeys.intersection(linkedKeys).sorted() { + guard generation == preparationGeneration, !Task.isCancelled else { return } + do { + try await operations.receive(publicKey) + } catch { + Logger.warn( + "Failed to receive private Paykit messages during \(reason): \(PaykitResolutionFailureDiagnostics.reason(for: error))", + context: "PrivatePaykit" + ) + } } } catch { Logger.warn("Failed to process pending private Paykit messages during \(reason): \(error)", context: "PrivatePaykit") diff --git a/Bitkit/Services/PubkyService.swift b/Bitkit/Services/PubkyService.swift index 9dda3b888..3c76d594b 100644 --- a/Bitkit/Services/PubkyService.swift +++ b/Bitkit/Services/PubkyService.swift @@ -713,11 +713,9 @@ actor PaykitSdkService { } } - /// Takes the SDK lock per read and drops out of its queue once cancelled, so an abandoned eligibility check - /// holds up a payment for at most the one read already in flight. func canReceivePaymentRequests(publicKey: String) async throws -> Bool { - try Task.checkCancellation() - return try await operationLock.withCancellableLock { + // The public registry read does not touch the session or shared state. + try await operationLock.withPublicRead { try await handle().paykitAppRegistry(publicKey: publicKey)?.apps.contains { $0.capabilities.paymentRequests && $0.capabilities.outgoingPayments } == true @@ -785,6 +783,13 @@ actor PaykitSdkService { } } + @discardableResult + func receivePrivateMessages(counterparty: String) async throws -> PrivateStreamIntakeReport { + try await withStateRevisionTracking { sdk in + try await sdk.receivePrivateMessages(counterparty: counterparty) + } + } + @discardableResult func processOutboundPrivateMessages(counterparty: String) async throws -> OutboundPrivateSendReport { try await withStateRevisionTracking { sdk in @@ -1304,14 +1309,9 @@ actor PaykitSdkService { } final class PaykitSdkOperationLock: @unchecked Sendable { - private enum Waiter { - case uncancellable(CheckedContinuation) - case cancellable(UUID, CheckedContinuation) - } - private let lock = NSLock() private var isLocked = false - private var waiters: [Waiter] = [] + private var waiters: [CheckedContinuation] = [] private var generation = 0 private var isWiping = false private var activeWipeID: UUID? @@ -1331,6 +1331,15 @@ final class PaykitSdkOperationLock: @unchecked Sendable { try admit() } + func withPublicRead(_ operation: () async throws -> T) async throws -> T { + let admittedGeneration = try admit() + try Task.checkCancellation() + let result = try await operation() + try validate(admittedGeneration) + try Task.checkCancellation() + return result + } + func withWalletWipe(_ operation: () async throws -> T) async throws -> T { let wipeID = try beginWipe() defer { endWipe() } @@ -1383,23 +1392,11 @@ final class PaykitSdkOperationLock: @unchecked Sendable { .Storage(code: "wallet_wipe_in_progress", context: "Paykit operation interrupted by wallet wipe") } - /// Leaves the queue as soon as the caller is cancelled and never runs `operation` for a cancelled caller, - /// so an abandoned read cannot take the lock ahead of work queued after it. - func withCancellableLock(_ operation: () async throws -> T) async throws -> T { - if ownsWipe() { return try await operation() } - let admittedGeneration = try admit() - try await acquireCancellable() - defer { release() } - try validate(admittedGeneration) - try Task.checkCancellation() - return try await operation() - } - private func acquire() async { await withCheckedContinuation { continuation in lock.lock() if isLocked { - waiters.append(.uncancellable(continuation)) + waiters.append(continuation) lock.unlock() } else { isLocked = true @@ -1409,42 +1406,8 @@ final class PaykitSdkOperationLock: @unchecked Sendable { } } - private func acquireCancellable() async throws { - let id = UUID() - try await withTaskCancellationHandler { - try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in - lock.lock() - if Task.isCancelled { - lock.unlock() - continuation.resume(throwing: CancellationError()) - } else if isLocked { - waiters.append(.cancellable(id, continuation)) - lock.unlock() - } else { - isLocked = true - lock.unlock() - continuation.resume() - } - } - } onCancel: { - removeWaiter(id: id)?.resume(throwing: CancellationError()) - } - } - - private func removeWaiter(id: UUID) -> CheckedContinuation? { - lock.lock() - defer { lock.unlock() } - guard let index = waiters.firstIndex(where: { - if case let .cancellable(waiterID, _) = $0 { return waiterID == id } - return false - }), - case let .cancellable(_, continuation) = waiters.remove(at: index) - else { return nil } - return continuation - } - private func release() { - let nextWaiter: Waiter? + let nextWaiter: CheckedContinuation? lock.lock() if waiters.isEmpty { isLocked = false @@ -1453,14 +1416,7 @@ final class PaykitSdkOperationLock: @unchecked Sendable { nextWaiter = waiters.removeFirst() } lock.unlock() - switch nextWaiter { - case let .uncancellable(continuation): - continuation.resume() - case let .cancellable(_, continuation): - continuation.resume() - case nil: - break - } + nextWaiter?.resume() } } diff --git a/BitkitTests/PaykitPaymentRequestServiceTests.swift b/BitkitTests/PaykitPaymentRequestServiceTests.swift index b4a5f646c..750d539e3 100644 --- a/BitkitTests/PaykitPaymentRequestServiceTests.swift +++ b/BitkitTests/PaykitPaymentRequestServiceTests.swift @@ -4789,6 +4789,77 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { ) } + func testCapabilityDiscoveryBoundsConcurrentReadsWithoutWaitingForSlowPeer() async throws { + let keys = "ybndrfg8ejkmcpqxot".map { "pubky" + String(repeating: String($0), count: 52) } + let sdk = PaymentRequestSdkMock(records: []) + var supported = Dictionary(uniqueKeysWithValues: keys.map { ($0, true) }) + supported[keys[5]] = false + await sdk.configureRecipients( + peers: keys.map { linkedPeer(counterparty: $0, state: .linked) }, + requestCapabilitiesByPublicKey: supported + ) + await sdk.setCapabilityLookupFailing(true, for: keys[2]) + let (slowGate, releaseSlow) = AsyncStream.makeStream() + let (batchGate, releaseBatch) = AsyncStream.makeStream() + defer { releaseSlow.finish(); releaseBatch.finish() } + await sdk.setCapabilityLookupGate { key in + let gate = key == keys[0] ? slowGate : batchGate + for await _ in gate {} + } + let service = PaykitPaymentRequestService(sdk: sdk, isPrivatePaymentPublishingEnabled: { true }, logWarning: { _ in }) + let discovery = Task { + try await service.discoverEligibleTargets( + savedPublicKeys: keys + [keys[0]], + expectedIdentity: "pubky\(String(repeating: "z", count: 52))", + previousTargets: [PaykitPaymentRequestTarget(publicKey: keys[2])] + ) + } + try await waitUntil { await sdk.activeCapabilityLookups == 8 } + let initialLookups = await sdk.capabilityLookupPublicKeys + XCTAssertEqual(Set(initialLookups), Set(keys.prefix(8))) + releaseBatch.finish() + try await waitUntil { + let lookups = await sdk.capabilityLookups() + let active = await sdk.activeCapabilityLookups + return lookups == keys.count && active == 1 + } + let highWaterMark = await sdk.maxConcurrentCapabilityLookups + XCTAssertEqual(highWaterMark, 8) + + releaseSlow.finish() + let result = try await discovery.value + XCTAssertEqual(result.targets.map(\.publicKey), keys.filter { $0 != keys[5] }) + XCTAssertFalse(result.isComplete) + XCTAssertEqual(result.capabilityCheckedPublicKeys, Set(keys).subtracting([keys[2]])) + } + + func testCancelledCapabilityDiscoveryDoesNotStartAnotherBatch() async throws { + let keys = "ybndrfg8ejkmcpqxot".map { "pubky" + String(repeating: String($0), count: 52) } + let sdk = PaymentRequestSdkMock(records: []) + await sdk.configureRecipients( + peers: keys.map { linkedPeer(counterparty: $0, state: .linked) }, + requestCapabilitiesByPublicKey: Dictionary(uniqueKeysWithValues: keys.map { ($0, true) }) + ) + let (gate, release) = AsyncStream.makeStream() + defer { release.finish() } + await sdk.setCapabilityLookupGate { _ in for await _ in gate {} } + let service = PaykitPaymentRequestService(sdk: sdk, isPrivatePaymentPublishingEnabled: { true }) + let discovery = Task { + try await service.discoverEligibleTargets( + savedPublicKeys: keys, expectedIdentity: "pubky\(String(repeating: "z", count: 52))" + ) + } + try await waitUntil { await sdk.activeCapabilityLookups == 8 } + discovery.cancel() + release.finish() + do { + _ = try await discovery.value + XCTFail("Cancelled discovery must not return partial eligibility") + } catch is CancellationError {} + let lookups = await sdk.capabilityLookups() + XCTAssertEqual(lookups, 8) + } + func testEligibleTargetsRequireLivePaykitSession() async { let savedKey = "pubky\(String(repeating: "y", count: 52))" let sdk = PaymentRequestSdkMock(records: []) @@ -4898,6 +4969,43 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { ) } + func testSelectedRecipientCanRefreshBeforeFullContactDiscovery() async { + let selected = "pubky\(String(repeating: "a", count: 52))" + let unrelated = "pubky\(String(repeating: "b", count: 52))" + let sdk = PaymentRequestSdkMock(records: []) + await sdk.configureRecipients( + peers: [linkedPeer(counterparty: selected, state: .linked), linkedPeer(counterparty: unrelated, state: .linked)], + requestCapabilitiesByPublicKey: [selected: true, unrelated: true] + ) + let manager = paymentRequestManager(sdk: sdk) + manager.updateSavedPublicKeys([selected, unrelated]) + + let target = await manager.refreshEligibleTarget(publicKey: selected) + + XCTAssertEqual(target, PaykitPaymentRequestTarget(publicKey: selected)) + let lookups = await sdk.capabilityLookupPublicKeys + XCTAssertEqual(lookups, [selected]) + } + + func testSavedContactUpdateImmediatelyRemovesTargetWithoutNetworkDiscovery() async { + let key = "pubky\(String(repeating: "a", count: 52))" + let sdk = PaymentRequestSdkMock(records: []) + await sdk.configureRecipients( + peers: [linkedPeer(counterparty: key, state: .linked)], + requestCapabilitiesByPublicKey: [key: true] + ) + let manager = paymentRequestManager(sdk: sdk) + await manager.refreshEligibleTargets(savedPublicKeys: [key]) + + manager.updateSavedPublicKeys([]) + let target = await manager.refreshEligibleTarget(publicKey: key) + + XCTAssertNil(target) + XCTAssertTrue(manager.eligibleTargets.isEmpty) + let lookups = await sdk.capabilityLookupPublicKeys + XCTAssertEqual(lookups, [key]) + } + func testSingleEligibilityRefreshRemovesContactThatIsNoLongerLinked() async { let savedKey = "pubky\(String(repeating: "y", count: 52))" let sdk = PaymentRequestSdkMock(records: []) @@ -5005,6 +5113,57 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { ) } + func testFailedFullRefreshCannotRemoveNewlySavedEligibleTarget() async throws { + let firstKey = "pubky\(String(repeating: "a", count: 52))" + let addedKey = "pubky\(String(repeating: "b", count: 52))" + let sdk = PaymentRequestSdkMock(records: []) + await sdk.configureRecipients( + peers: [linkedPeer(counterparty: addedKey, state: .linked)], + requestCapabilitiesByPublicKey: [addedKey: true] + ) + await sdk.setLinkedPeersError(.linkedPeers) + await sdk.pauseNextLinkedPeers() + let manager = paymentRequestManager(sdk: sdk) + + let fullRefresh = Task { + await manager.refreshEligibleTargets(savedPublicKeys: [firstKey]) + } + try await waitUntil { await sdk.linkedPeersIsPaused() } + manager.updateSavedPublicKeys([firstKey, addedKey]) + await sdk.setLinkedPeersError(nil) + let target = await manager.refreshEligibleTarget(publicKey: addedKey) + await sdk.resumeLinkedPeers() + await fullRefresh.value + + XCTAssertEqual(target, PaykitPaymentRequestTarget(publicKey: addedKey)) + XCTAssertEqual(manager.eligibleTargets, [PaykitPaymentRequestTarget(publicKey: addedKey)]) + let immediateTarget = await manager.eligibleTarget(publicKey: addedKey, waitingAtMost: .seconds(2)) + XCTAssertEqual(immediateTarget, target) + let lookups = await sdk.capabilityLookupPublicKeys + XCTAssertEqual(lookups, [addedKey]) + } + + func testUnchangedSavedKeysAllowPendingEligibilityRefreshToComplete() async throws { + let savedKey = "pubky\(String(repeating: "y", count: 52))" + let sdk = PaymentRequestSdkMock(records: []) + await sdk.configureRecipients( + peers: [linkedPeer(counterparty: savedKey, state: .linked)], + requestCapabilitiesByPublicKey: [savedKey: true] + ) + await sdk.pauseNextLinkedPeers() + let manager = paymentRequestManager(sdk: sdk) + + let fullRefresh = Task { + await manager.refreshEligibleTargets(savedPublicKeys: [savedKey]) + } + try await waitUntil { await sdk.linkedPeersIsPaused() } + manager.updateSavedPublicKeys([savedKey]) + await sdk.resumeLinkedPeers() + await fullRefresh.value + + XCTAssertEqual(manager.eligibleTargets, [PaykitPaymentRequestTarget(publicKey: savedKey)]) + } + func testSingleEligibilityRefreshCannotOverwriteNewerFullRefresh() async throws { let savedKey = "pubky\(String(repeating: "y", count: 52))" let sdk = PaymentRequestSdkMock(records: []) @@ -5141,6 +5300,34 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { XCTAssertEqual(callsAfterWait, callsAfterRefresh) } + func testWaitingForEligibleTargetRechecksContactThatWasStillLinking() async { + let savedKey = "pubky\(String(repeating: "y", count: 52))" + for fullRefresh in [false, true] { + let sdk = PaymentRequestSdkMock(records: []) + await sdk.configureRecipients( + peers: [linkedPeer(counterparty: savedKey, state: .linking)], + requestCapabilitiesByPublicKey: [savedKey: true] + ) + let manager = paymentRequestManager(sdk: sdk) + manager.updateSavedPublicKeys([savedKey]) + if fullRefresh { + await manager.refreshEligibleTargets(savedPublicKeys: [savedKey]) + } else { + _ = await manager.refreshEligibleTarget(publicKey: savedKey) + } + await sdk.configureRecipients( + peers: [linkedPeer(counterparty: savedKey, state: .linked)], + requestCapabilitiesByPublicKey: [savedKey: true] + ) + + let target = await manager.eligibleTarget(publicKey: savedKey, waitingAtMost: .seconds(2)) + + XCTAssertEqual(target, PaykitPaymentRequestTarget(publicKey: savedKey)) + let lookups = await sdk.capabilityLookupPublicKeys + XCTAssertEqual(lookups, [savedKey]) + } + } + func testWaitingForEligibleTargetRechecksAfterRecentWindow() async { let savedKey = "pubky\(String(repeating: "y", count: 52))" let clock = PaymentRequestTestClock(Date()) @@ -5827,6 +6014,9 @@ private actor PaymentRequestSdkMock: PaykitPaymentRequestSdkHandling, PaykitPaym private var linkedPeersError: PaymentRequestSdkMockError? private var linkedPeersCallCount = 0 private(set) var capabilityLookupPublicKeys: [String] = [] + private(set) var activeCapabilityLookups = 0 + private(set) var maxConcurrentCapabilityLookups = 0 + private var capabilityLookupGate: (@Sendable (String) async -> Void)? private var failingCapabilityKeys: Set = [] private var proposalResult: PaymentRequestRecord? private var uploadCount = 0 @@ -5939,9 +6129,7 @@ private actor PaymentRequestSdkMock: PaykitPaymentRequestSdkHandling, PaykitPaym func linkedPeers() async throws -> [LinkedPeerRecord] { linkedPeersCallCount += 1 - if let linkedPeersError { - throw linkedPeersError - } + let error = linkedPeersError let snapshot = peerRecords if shouldPauseNextLinkedPeers { shouldPauseNextLinkedPeers = false @@ -5949,11 +6137,16 @@ private actor PaymentRequestSdkMock: PaykitPaymentRequestSdkHandling, PaykitPaym await withCheckedContinuation { linkedPeersContinuation = $0 } isLinkedPeersPaused = false } + if let error { throw error } return snapshot } - func canReceivePaymentRequests(publicKey: String) throws -> Bool { + func canReceivePaymentRequests(publicKey: String) async throws -> Bool { capabilityLookupPublicKeys.append(publicKey) + activeCapabilityLookups += 1 + maxConcurrentCapabilityLookups = max(maxConcurrentCapabilityLookups, activeCapabilityLookups) + defer { activeCapabilityLookups -= 1 } + await capabilityLookupGate?(publicKey) if failingCapabilityKeys.contains(publicKey) { throw PaymentRequestSdkMockError.receive } @@ -6184,6 +6377,10 @@ private actor PaymentRequestSdkMock: PaykitPaymentRequestSdkHandling, PaykitPaym capabilityLookupPublicKeys.count } + func setCapabilityLookupGate(_ gate: @escaping @Sendable (String) async -> Void) { + capabilityLookupGate = gate + } + func setLinkedPeersError(_ error: PaymentRequestSdkMockError?) { linkedPeersError = error } diff --git a/BitkitTests/PaykitSdkOperationLockTests.swift b/BitkitTests/PaykitSdkOperationLockTests.swift index ef9cd2fda..fae77bd05 100644 --- a/BitkitTests/PaykitSdkOperationLockTests.swift +++ b/BitkitTests/PaykitSdkOperationLockTests.swift @@ -11,6 +11,112 @@ final class PaykitSdkOperationLockTests: XCTestCase { } } + func testPublicCapabilityReadDoesNotWaitForSerializedSdkWork() async throws { + let (gate, release) = AsyncStream.makeStream() + defer { release.finish() } + let started = expectation(description: "Serialized work started") + let read = expectation(description: "Public capability read completes while serialized work is suspended") + let sdk = PublicReadSdk(noPointer: .init()) + sdk.lockedRead = { + started.fulfill() + for await _ in gate {} + } + let service = PaykitSdkService(sdkFactory: { sdk }) + let holder = Task { try await service.resolveContactProfile(publicKey: "peer", allowPubkyProfileFallback: false) } + await fulfillment(of: [started], timeout: 2) + + let lookup = Task { + let result = try await service.canReceivePaymentRequests(publicKey: "peer") + XCTAssertFalse(result) + read.fulfill() + } + await fulfillment(of: [read], timeout: 0.5) + release.finish() + _ = try await holder.value + try await lookup.value + } + + func testSlowPublicCapabilityReadDoesNotHoldSerializedSdkQueue() async throws { + let (gate, release) = AsyncStream.makeStream() + defer { release.finish() } + let started = expectation(description: "Public capability read started") + let completed = expectation(description: "Serialized work completes while public read is suspended") + let sdk = PublicReadSdk(noPointer: .init()) + sdk.publicRead = { + started.fulfill() + for await _ in gate {} + } + let service = PaykitSdkService(sdkFactory: { sdk }) + let lookup = Task { try await service.canReceivePaymentRequests(publicKey: "peer") } + await fulfillment(of: [started], timeout: 2) + + let work = Task { + _ = try await service.resolveContactProfile(publicKey: "peer", allowPubkyProfileFallback: false) + completed.fulfill() + } + await fulfillment(of: [completed], timeout: 0.5) + release.finish() + try await work.value + _ = try await lookup.value + } + + func testPublicReadRejectsResultAfterWalletWipe() async throws { + let lock = PaykitSdkOperationLock() + let (gate, release) = AsyncStream.makeStream() + defer { release.finish() } + let started = expectation(description: "Public read started") + let lookup = Task { + try await lock.withPublicRead { + started.fulfill() + for await _ in gate {} + return true + } + } + await fulfillment(of: [started], timeout: 2) + try await lock.withWalletWipe { + do { + try await lock.withPublicRead { XCTFail("Public reads must not start during a wipe") } + XCTFail("Expected wipe rejection") + } catch let PaykitError.Storage(code, _) { + XCTAssertEqual(code, "wallet_wipe_in_progress") + } + } + release.finish() + do { + _ = try await lookup.value + XCTFail("Expected result from the previous wallet generation to be rejected") + } catch let PaykitError.Storage(code, _) { + XCTAssertEqual(code, "wallet_wipe_in_progress") + } + } + + func testPublicReadDiscardsCancelledResult() async throws { + let lock = PaykitSdkOperationLock() + let lookup = Task { + try await lock.withPublicRead { + withUnsafeCurrentTask { $0?.cancel() } + return true + } + } + do { + _ = try await lookup.value + XCTFail("Expected cancelled result to be rejected") + } catch is CancellationError {} + try await lock.withLock {} + } + + func testAlreadyCancelledPublicReadDoesNotStart() async throws { + let lock = PaykitSdkOperationLock() + let lookup = Task { + withUnsafeCurrentTask { $0?.cancel() } + try await lock.withPublicRead { XCTFail("Cancelled public read must not start") } + } + do { + try await lookup.value + XCTFail("Expected cancellation") + } catch is CancellationError {} + } + func testWipeDrainsActiveWorkRejectsQueuedWorkAndAllowsCleanupAndFreshWork() async throws { let lock = PaykitSdkOperationLock() let recorder = Recorder() @@ -105,70 +211,19 @@ final class PaykitSdkOperationLockTests: XCTestCase { } catch KeychainError.failedToDelete {} try await lock.withLock {} } +} - func testCancelledQueuedReadDoesNotRunAheadOfLaterWork() async throws { - let lock = PaykitSdkOperationLock() - let recorder = Recorder() - let (holderGate, releaseHolder) = AsyncStream.makeStream() - let (holderStarted, holderStartedContinuation) = AsyncStream.makeStream() - - let holder = Task { - try await lock.withLock { - holderStartedContinuation.yield() - for await _ in holderGate { - break - } - } - } - for await _ in holderStarted { - break - } - - let lookup = Task { - try await lock.withCancellableLock { - await recorder.record("lookup") - } - } - try await Task.sleep(for: .milliseconds(50)) - let payment = Task { - try await lock.withLock { - await recorder.record("payment") - } - } - try await Task.sleep(for: .milliseconds(50)) - - lookup.cancel() - releaseHolder.yield() - try await holder.value - try await payment.value +private final class PublicReadSdk: PaykitSdk, @unchecked Sendable { + var publicRead: () async -> Void = {} + var lockedRead: () async -> Void = {} - do { - try await lookup.value - XCTFail("Expected the cancelled lookup to throw") - } catch is CancellationError {} - let events = await recorder.events - XCTAssertEqual(events, ["payment"]) + override func paykitAppRegistry(publicKey _: String) async throws -> PaykitAppRegistry? { + await publicRead() + return nil } - func testAlreadyCancelledReadDoesNotRunOrHoldTheLock() async throws { - let lock = PaykitSdkOperationLock() - let recorder = Recorder() - - let lookup = Task { - withUnsafeCurrentTask { $0?.cancel() } - return try await lock.withCancellableLock { - await recorder.record("lookup") - } - } - - do { - try await lookup.value - XCTFail("Expected the cancelled lookup to throw") - } catch is CancellationError {} - try await lock.withLock { - await recorder.record("payment") - } - let events = await recorder.events - XCTAssertEqual(events, ["payment"]) + override func resolveProfile(publicKey _: String, allowPubkyProfileFallback _: Bool) async throws -> ProfileResolution? { + await lockedRead() + return nil } } diff --git a/BitkitTests/PrivatePaykitServiceTests.swift b/BitkitTests/PrivatePaykitServiceTests.swift index e3d7bd2ad..7256c454f 100644 --- a/BitkitTests/PrivatePaykitServiceTests.swift +++ b/BitkitTests/PrivatePaykitServiceTests.swift @@ -215,8 +215,14 @@ final class PrivatePaykitServiceTests: XCTestCase { remoteRecoveryAttemptId: nil, remoteRecoveryMarkerObservedAt: nil )] }, - processPending: { processed += 1 }, - receive: { received += 1 } + processPending: { key in + XCTAssertEqual(key, publicKey) + processed += 1 + }, + receive: { key in + XCTAssertEqual(key, publicKey) + received += 1 + } )) XCTAssertTrue(advanced) @@ -226,6 +232,110 @@ final class PrivatePaykitServiceTests: XCTestCase { } } + func testPrivateMessageDrainDoesNotVisitUnrelatedPeers() async { + let service = PrivatePaykitService() + let selected = "selected" + let unrelated = (0 ..< 60).map { "unrelated-\($0)" } + var sent: [String] = [] + var received: [String] = [] + await service.drainPendingPrivateMessages(reason: "test", advancing: [selected, selected], operations: .init( + ensureLink: { XCTAssertEqual($0, selected) }, + pendingOutbound: { unrelated + [selected] }, + linkedPeers: { (unrelated + [selected]).map { self.drainPeer($0) } }, + processPending: { sent.append($0) }, + receive: { received.append($0) } + )) + XCTAssertEqual(sent, [selected]) + XCTAssertEqual(received, [selected]) + } + + func testPrivateMessageDrainMatchesNormalizedRetryAndSdkKeys() async { + let rawKey = String(repeating: "y", count: 52) + let publicKey = "pubky" + rawKey + for reportedKey in [rawKey, publicKey.uppercased()] { + let service = PrivatePaykitService() + var advanced: [String] = [] + var sent: [String] = [] + var received: [String] = [] + await service.drainPendingPrivateMessages( + reason: "test", advancing: [rawKey.uppercased(), publicKey], operations: .init( + ensureLink: { advanced.append($0) }, + pendingOutbound: { [reportedKey, "unrelated"] }, + linkedPeers: { [self.drainPeer(reportedKey), self.drainPeer("unrelated")] }, + processPending: { sent.append($0) }, + receive: { received.append($0) } + ) + ) + XCTAssertEqual(advanced, [publicKey]) + XCTAssertEqual(sent, [publicKey]) + XCTAssertEqual(received, [publicKey]) + } + } + + func testPrivateMessageDrainStopsAfterInvalidationAtEachSuspension() async throws { + for stage in ["link", "outbound", "send", "peers", "receive"] { + let service = PrivatePaykitService() + var events: [String] = [] + let record: (String) async -> Void = { event in + events.append(event) + if event == stage { await service.invalidateContactPreparation() } + } + await service.drainPendingPrivateMessages(reason: "test", advancing: ["first", "second"], operations: .init( + ensureLink: { _ in await record("link") }, + pendingOutbound: { await record("outbound"); return ["first", "second"] }, + linkedPeers: { await record("peers"); return [self.drainPeer("first"), self.drainPeer("second")] }, + processPending: { _ in await record("send") }, + receive: { _ in await record("receive") } + )) + let order = ["link", "link", "outbound", "send", "send", "peers", "receive", "receive"] + XCTAssertEqual(events, try Array(order.prefix(through: XCTUnwrap(order.firstIndex(of: stage)))), stage) + } + } + + func testPrivateMessageDrainContinuesOtherSelectedPeersAfterFailure() async { + let service = PrivatePaykitService() + var sent: [String] = [] + var received: [String] = [] + await service.drainPendingPrivateMessages(reason: "test", advancing: ["first", "second"], operations: .init( + ensureLink: { _ in }, + pendingOutbound: { ["first", "second"] }, + linkedPeers: { [self.drainPeer("first"), self.drainPeer("second")] }, + processPending: { key in + sent.append(key) + if key == "first" { throw PrivatePaykitError.privateUnavailable } + }, + receive: { key in + received.append(key) + if key == "first" { throw PrivatePaykitError.privateUnavailable } + } + )) + XCTAssertEqual(sent, ["first", "second"]) + XCTAssertEqual(received, ["first", "second"]) + } + + func testPrivateMessageDrainStopsAfterCallerCancellation() async { + let service = PrivatePaykitService() + let task = Task { + await service.drainPendingPrivateMessages(reason: "test", advancing: ["first", "second"], operations: .init( + ensureLink: { _ in withUnsafeCurrentTask { $0?.cancel() } }, + pendingOutbound: { XCTFail("Cancelled retry must not inspect outbound work"); return [] }, + linkedPeers: { XCTFail("Cancelled retry must not inspect peers"); return [] }, + processPending: { _ in XCTFail("Cancelled retry must not send") }, + receive: { _ in XCTFail("Cancelled retry must not receive") } + )) + } + await task.value + } + + private func drainPeer(_ publicKey: String) -> LinkedPeerRecord { + LinkedPeerRecord( + counterparty: publicKey, state: .linked, + lastSyncAt: nil, lastPrivateReceiveAt: nil, failureCount: 0, + localRecoveryAttemptId: nil, localRecoveryMarkerCreatedAt: nil, localRecoveryMarkerLastError: nil, + remoteRecoveryAttemptId: nil, remoteRecoveryMarkerObservedAt: nil + ) + } + private func withIsolatedDefaults(_ body: (UserDefaults) throws -> Void) throws { let suiteName = "PrivatePaykitServiceTests.\(UUID().uuidString)" let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) @@ -1237,8 +1347,8 @@ final class PrivatePaykitServiceTests: XCTestCase { ensureLink: { _ in attempts += 1 }, pendingOutbound: { [] }, linkedPeers: peers, - processPending: { XCTFail("No outbound messages are queued") }, - receive: { XCTFail("The handshake has not completed") } + processPending: { _ in XCTFail("No outbound messages are queued") }, + receive: { _ in XCTFail("The handshake has not completed") } )) XCTAssertEqual(attempts, stateAfterFailure == nil || stateAfterFailure == .notLinked ? 2 : 3) diff --git a/journeys/contacts/README.md b/journeys/contacts/README.md index 48cb12649..ad325f6e3 100644 --- a/journeys/contacts/README.md +++ b/journeys/contacts/README.md @@ -44,3 +44,17 @@ public endpoint or app-registry update failing. Hold withdrawal in progress and foreground the app. It must not start another cleanup. Request sharing on again before withdrawal finishes: publication must wait until the earlier cleanup ends, then leave sharing on. Repeat while a foreground cleanup is already running. + +## Foreground wait isolation + +Hold an unrelated contact's background preparation in progress, then open a saved, linked contact +and request or pay it. The selected contact must be eligible for its own lookup before the full +contact scan finishes. Hold its public capability lookup separately: this public read must not +retain the shared-state operation queue used by payment resolution, withdrawal, and wallet backup. +Identity, link, and request execution checks still run and may wait for shared-state access. + +Retry private messages for one contact while other contacts have pending outbound work. Only the +selected retry contacts should be sent to or read from in that drain. Repeat during sharing OFF, +with one withdrawal failing: OFF remains immediate, cleanup remains pending on failure, and no new +publication starts. Record action-to-result time separately from SDK lock and network waits; these +fault-injection checks do not establish staging latency or a guaranteed completion deadline. From 10b85a1e6b833538f3753a027f362a822b53d236 Mon Sep 17 00:00:00 2001 From: benk10 Date: Sat, 3 Oct 2026 07:45:53 -0500 Subject: [PATCH 36/68] fix: adopt Paykit rc60 and preserve contact eligibility --- Bitkit.xcodeproj/project.pbxproj | 2 +- .../xcshareddata/swiftpm/Package.resolved | 4 ++-- .../PaykitPaymentRequestService.swift | 9 ++++---- Bitkit/Services/PubkyService.swift | 5 ++++- .../PaykitPaymentRequestServiceTests.swift | 7 ++++--- BitkitTests/PaykitSdkClientConfigTests.swift | 21 ++++++++++++++++--- Docs/pubky-auth-companion-claims.md | 2 ++ 7 files changed, 36 insertions(+), 14 deletions(-) diff --git a/Bitkit.xcodeproj/project.pbxproj b/Bitkit.xcodeproj/project.pbxproj index 151c3ee8f..ee0adb572 100644 --- a/Bitkit.xcodeproj/project.pbxproj +++ b/Bitkit.xcodeproj/project.pbxproj @@ -1178,7 +1178,7 @@ repositoryURL = "https://github.com/pubky/paykit-rs"; requirement = { kind = exactVersion; - version = "0.1.0-rc59"; + version = "0.1.0-rc60"; }; }; 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 53cd3483f..9c63de1ca 100644 --- a/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -33,8 +33,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pubky/paykit-rs", "state" : { - "revision" : "7b75d28c2679656a0d1c6e1ef6d8ee9881c9be07", - "version" : "0.1.0-rc59" + "revision" : "072a2db347571d676125ee711cf7fe9402015d76", + "version" : "0.1.0-rc60" } }, { diff --git a/Bitkit/Services/PaykitPaymentRequestService.swift b/Bitkit/Services/PaykitPaymentRequestService.swift index 5a0b311c7..760e06aa1 100644 --- a/Bitkit/Services/PaykitPaymentRequestService.swift +++ b/Bitkit/Services/PaykitPaymentRequestService.swift @@ -1297,9 +1297,10 @@ final class PaykitPaymentRequestManager { } func updateSavedPublicKeys(_ publicKeys: [String]) { - guard publicKeys != savedPublicKeys else { return } - eligibilityGeneration += 1 + let changed = Set(publicKeys.map(Self.eligibilityKey)) != Set(savedPublicKeys.map(Self.eligibilityKey)) savedPublicKeys = publicKeys + guard changed else { return } + eligibilityGeneration += 1 eligibleTargets.removeAll { target in !publicKeys.contains { PubkyPublicKeyFormat.matches($0, target.publicKey) } } @@ -1323,7 +1324,7 @@ final class PaykitPaymentRequestManager { ) guard generation == eligibilityGeneration, currentStateGeneration == stateGeneration, - savedPublicKeys == self.savedPublicKeys, + Set(savedPublicKeys.map(Self.eligibilityKey)) == Set(self.savedPublicKeys.map(Self.eligibilityKey)), isAvailable(), PubkyPublicKeyFormat.matches(self.activeIdentity, activeIdentity) else { return } @@ -1488,7 +1489,7 @@ final class PaykitPaymentRequestManager { if actionGeneration == stateGeneration, isAvailable(), PubkyPublicKeyFormat.matches(self.activeIdentity, activeIdentity), - savedPublicKeysSnapshot == savedPublicKeys + Set(savedPublicKeysSnapshot.map(Self.eligibilityKey)) == Set(savedPublicKeys.map(Self.eligibilityKey)) { invalidateRefresh() historyRequests.removeAll { $0.id == request.id } diff --git a/Bitkit/Services/PubkyService.swift b/Bitkit/Services/PubkyService.swift index 3c76d594b..5a053f75f 100644 --- a/Bitkit/Services/PubkyService.swift +++ b/Bitkit/Services/PubkyService.swift @@ -1020,7 +1020,7 @@ actor PaykitSdkService { } nonisolated static func requiredCapabilities() throws -> String { - Paykit.requiredSessionCapabilities() + Paykit.paykitAuthorizerSessionCapabilities() } private func handle() throws -> PaykitSdk { @@ -1204,6 +1204,9 @@ actor PaykitSdkService { try await refreshPaykitKey() sdk = try handle() _ = try await sdk.initialize() + if result.sessionAccess.exportLocalSecretKey() != nil { + _ = try await sdk.publishPaykitNoiseKeyAuthorization() + } } catch { var rollbackError: Error? for (key, value) in zip(keys, previousValues) { diff --git a/BitkitTests/PaykitPaymentRequestServiceTests.swift b/BitkitTests/PaykitPaymentRequestServiceTests.swift index 750d539e3..518f668d5 100644 --- a/BitkitTests/PaykitPaymentRequestServiceTests.swift +++ b/BitkitTests/PaykitPaymentRequestServiceTests.swift @@ -5143,8 +5143,9 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { XCTAssertEqual(lookups, [addedKey]) } - func testUnchangedSavedKeysAllowPendingEligibilityRefreshToComplete() async throws { + func testReorderedSavedKeysAllowPendingEligibilityRefreshToComplete() async throws { let savedKey = "pubky\(String(repeating: "y", count: 52))" + let otherKey = "pubky\(String(repeating: "a", count: 52))" let sdk = PaymentRequestSdkMock(records: []) await sdk.configureRecipients( peers: [linkedPeer(counterparty: savedKey, state: .linked)], @@ -5154,10 +5155,10 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { let manager = paymentRequestManager(sdk: sdk) let fullRefresh = Task { - await manager.refreshEligibleTargets(savedPublicKeys: [savedKey]) + await manager.refreshEligibleTargets(savedPublicKeys: [savedKey, otherKey]) } try await waitUntil { await sdk.linkedPeersIsPaused() } - manager.updateSavedPublicKeys([savedKey]) + manager.updateSavedPublicKeys([otherKey, savedKey]) await sdk.resumeLinkedPeers() await fullRefresh.value diff --git a/BitkitTests/PaykitSdkClientConfigTests.swift b/BitkitTests/PaykitSdkClientConfigTests.swift index f47fa6914..947342c82 100644 --- a/BitkitTests/PaykitSdkClientConfigTests.swift +++ b/BitkitTests/PaykitSdkClientConfigTests.swift @@ -438,7 +438,7 @@ final class PaykitSdkClientConfigTests: XCTestCase { XCTAssertEqual(manager.publicKey, "pubky\(originalKey)") XCTAssertEqual(try Keychain.loadString(key: .paykitSession), "new-session") XCTAssertEqual(try Keychain.loadString(key: .pubkySecretKey), secret) - XCTAssertEqual(sdk.activationEvents, ["registry", "initialize"]) + XCTAssertEqual(sdk.activationEvents, ["registry", "initialize", "authorize"]) if previousKey == differentKey { XCTAssertNil(manager.displayName) XCTAssertNil(manager.displayImageUri) @@ -506,7 +506,7 @@ final class PaykitSdkClientConfigTests: XCTestCase { SharedPubkyKeychain.publishOwn(pubky: sharedPubky, secretKeyHex: oldSecret) let overrides = [previousKey: PubkyProfileData(name: "Private label", bio: "", image: nil, links: [], tags: [])] for newSecret in [oldSecret, differentSecret] { - for failingStep in ["registry", "initialize"] { + for failingStep in ["registry", "initialize", "authorize"] { defaults.set("Original profile", forKey: metadataKeys[0]) defaults.set("pubky://original/avatar", forKey: metadataKeys[1]) ContactsManager.restoreContactProfileOverrides(overrides) @@ -517,6 +517,7 @@ final class PaykitSdkClientConfigTests: XCTestCase { let failure = PubkyServiceError.authFailed("\(failingStep) unavailable") sdk.registryError = failingStep == "registry" ? failure : nil sdk.initializationError = failingStep == "initialize" ? failure : nil + sdk.authorizationError = failingStep == "authorize" ? failure : nil let service = PaykitSdkService(sdkFactory: { sdk }) { _, _ in CacheActivationBootstrap(noPointer: .init()) } let session = CacheActivationSession(noPointer: .init()) session.localSecretKey = try PaykitSdkService.localSecretKey(fromHex: newSecret) @@ -533,7 +534,10 @@ final class PaykitSdkClientConfigTests: XCTestCase { XCTAssertEqual(SharedPubkyKeychain.loadSecret(sourceApp: SharedPubkyKeychain.ownSourceApp, pubky: sharedPubky), oldSecret) XCTAssertEqual(try Keychain.loadString(key: .paykitSession), "previous-session") XCTAssertEqual(try Keychain.loadString(key: .pubkySecretKey), oldSecret) - XCTAssertEqual(sdk.activationEvents, failingStep == "registry" ? ["registry"] : ["registry", "initialize"]) + let expectedEvents = ["registry", "initialize", "authorize"].prefix(while: { event in + event != failingStep + }) + [failingStep] + XCTAssertEqual(sdk.activationEvents, Array(expectedEvents)) let relaunched = UnavailableProfileManager() XCTAssertEqual(relaunched.displayName, "Original profile") XCTAssertEqual(relaunched.displayImageUri, "pubky://original/avatar") @@ -709,6 +713,7 @@ private final class CacheActivationSdk: PaykitSdk, @unchecked Sendable { var operationCalls = 0 var publicationError: Error? var publicationCalls = 0 + var authorizationError: Error? var backupError: Error? override func ensureLinkWithPeer(counterparty: String, maxAdvanceSteps: UInt32) async throws -> LinkedPeerHandshakeReport { @@ -768,6 +773,12 @@ private final class CacheActivationSdk: PaykitSdk, @unchecked Sendable { return registry ?? PaykitAppRegistry(keyGeneration: 1, noisePublicKey: nil, apps: [], defaultAppId: nil, defaultAppsByEndpoint: [:]) } + override func publishPaykitNoiseKeyAuthorization() async throws -> PaykitNoiseKeyAuthorization { + activationEvents.append("authorize") + if let authorizationError { throw authorizationError } + return PaykitNoiseKeyAuthorization(owner: "identity", noisePublicKey: "noise", noiseStaticPublicKey: "static", keyGeneration: 1) + } + override func backupStateRevision() async throws -> String { if let backupError { throw backupError } return "unchanged" @@ -828,6 +839,10 @@ private final class RecoverySdk: PaykitSdk, @unchecked Sendable { IdentityStatus(publicKey: nil, capability: .signedOut) } + override func publishPaykitNoiseKeyAuthorization() async throws -> PaykitNoiseKeyAuthorization { + PaykitNoiseKeyAuthorization(owner: "identity", noisePublicKey: "noise", noiseStaticPublicKey: "static", keyGeneration: 1) + } + override func backupStateRevision() async throws -> String { "unchanged" } diff --git a/Docs/pubky-auth-companion-claims.md b/Docs/pubky-auth-companion-claims.md index e7ebe4b5f..d4f863a4c 100644 --- a/Docs/pubky-auth-companion-claims.md +++ b/Docs/pubky-auth-companion-claims.md @@ -2,6 +2,8 @@ Bitkit can authorize a Pubky session and share Paykit access, a watch-only Bitcoin account, or both. The request explicitly selects the material to share; homeserver write permissions alone never imply key export. +Bitkit's own session includes the Paykit authorizer scope. On identity activation it publishes the identity-signed Noise key before advertising private Paykit capabilities. Sessions granted to other apps keep the normal Paykit scope; they cannot replace this authorization record. + ## Request - The Pubky Auth URL includes exactly one `x-bitkit-claim` parameter containing a dot-separated list of independent requested items: From 39b2d62b49f0c25ed1576ad6945a0e6bb91a1c46 Mon Sep 17 00:00:00 2001 From: benk10 Date: Sat, 3 Oct 2026 10:13:24 -0500 Subject: [PATCH 37/68] perf: use rc61 and separate inbox polling from maintenance --- Bitkit.xcodeproj/project.pbxproj | 2 +- .../xcshareddata/swiftpm/Package.resolved | 4 +- Bitkit/AppScene.swift | 20 +-- Bitkit/Managers/PubkyProfileManager.swift | 10 ++ .../PaykitPaymentRequestService.swift | 32 +++-- .../PaykitPaymentRequestServiceTests.swift | 136 +++++++++++++----- BitkitTests/PubkyProfileManagerTests.swift | 21 +++ 7 files changed, 162 insertions(+), 63 deletions(-) diff --git a/Bitkit.xcodeproj/project.pbxproj b/Bitkit.xcodeproj/project.pbxproj index ee0adb572..b0bf968ce 100644 --- a/Bitkit.xcodeproj/project.pbxproj +++ b/Bitkit.xcodeproj/project.pbxproj @@ -1178,7 +1178,7 @@ repositoryURL = "https://github.com/pubky/paykit-rs"; requirement = { kind = exactVersion; - version = "0.1.0-rc60"; + version = "0.1.0-rc61"; }; }; 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 9c63de1ca..7a91cde06 100644 --- a/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -33,8 +33,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pubky/paykit-rs", "state" : { - "revision" : "072a2db347571d676125ee711cf7fe9402015d76", - "version" : "0.1.0-rc60" + "revision" : "be7e82ab6f62e738c284297143a22b75a187756f", + "version" : "0.1.0-rc61" } }, { diff --git a/Bitkit/AppScene.swift b/Bitkit/AppScene.swift index f44878a4f..9d23cb544 100644 --- a/Bitkit/AppScene.swift +++ b/Bitkit/AppScene.swift @@ -1111,7 +1111,7 @@ struct AppScene: View { } } - private func refreshIncomingPaykitPaymentRequests(presentItems: Bool = true, refreshMaintenance: Bool = true) async { + private func refreshIncomingPaykitPaymentRequests(presentItems: Bool = true, mode: PaykitPaymentRequestRefreshMode = .full) async { guard PaykitFeatureFlags.isUIEnabled, wallet.walletExists == true, pubkyProfile.authState == .authenticated @@ -1122,11 +1122,11 @@ struct AppScene: View { guard !Task.isCancelled else { return } paykitPaymentRequestManager.updateSavedPublicKeys(contactsManager.contacts.map(\.publicKey)) - if refreshMaintenance { + if mode == .full { await PaykitPaymentProofService.shared.reconcile() } guard let identity = pubkyProfile.publicKey else { return } - await paykitPaymentRequestManager.refresh(syncPrivateMessages: refreshMaintenance) + await paykitPaymentRequestManager.refresh(mode: mode) guard pubkyProfile.authState == .authenticated, PubkyPublicKeyFormat.matches(identity, pubkyProfile.publicKey) else { return } @@ -1145,7 +1145,7 @@ struct AppScene: View { if presentItems { await presentNextIncomingPaykitItem() } - if refreshMaintenance { + if mode == .full { await paykitPaymentRequestManager.refreshEligibleTargets(savedPublicKeys: contactsManager.contacts.map(\.publicKey)) } } @@ -1193,23 +1193,23 @@ struct AppScene: View { guard !isWalletBackupRestoreRunning, !BackupService.shared.hasPendingWalletRestore() else { continue } - let refreshMaintenance: Bool + let mode: PaykitPaymentRequestRefreshMode switch schedule.takeRound(isConnected: network.isConnected) { case .skip: continue case .refreshInbox: - refreshMaintenance = false + mode = .inbox case .refreshInboxAndMaintenance: - refreshMaintenance = true + mode = .full } - if refreshMaintenance { + if mode == .full { await PubkyService.republishIdentityIfNeeded(publicKey: pubkyProfile.publicKey) await PrivatePaykitService.shared.refreshKnownSavedContactEndpoints( wallet: wallet, reason: "payment request polling" ) } - await refreshIncomingPaykitPaymentRequests(refreshMaintenance: refreshMaintenance) + await refreshIncomingPaykitPaymentRequests(mode: mode) } } @@ -1440,7 +1440,7 @@ struct AppScene: View { !sheets.isReplacingSheet, app.contactPaymentContext == nil else { return } - await refreshIncomingPaykitPaymentRequests(presentItems: false, refreshMaintenance: false) + await refreshIncomingPaykitPaymentRequests(presentItems: false, mode: .stored) guard let request = paykitPaymentRequestManager.pendingRequests.first(where: target.matches) else { if paykitPaymentRequestManager.historyRequests.contains(where: target.matches) { PaykitSubscriptionNotificationTargetStore.clear() diff --git a/Bitkit/Managers/PubkyProfileManager.swift b/Bitkit/Managers/PubkyProfileManager.swift index e6c52af92..a1c9dda8e 100644 --- a/Bitkit/Managers/PubkyProfileManager.swift +++ b/Bitkit/Managers/PubkyProfileManager.swift @@ -1,4 +1,5 @@ import Foundation +import enum Paykit.PaykitError import struct Paykit.PubkySessionBootstrapResult import SwiftUI @@ -1308,6 +1309,15 @@ class PubkyProfileManager: ObservableObject { let publicKey = try await importSession(savedSessionSecret) return .restored(publicKey: publicKey) } catch { + if let error = error as? PaykitError { + switch error { + case .ConcurrentUpdate, .SharedStateBusy, .Transport: + Logger.warn("Deferred session restoration, keeping saved session", context: "PubkyProfileManager") + return .restorationFailed + default: + break + } + } Logger.warn("Failed to import saved session, attempting re-sign-in: \(error)", context: "PubkyProfileManager") } } diff --git a/Bitkit/Services/PaykitPaymentRequestService.swift b/Bitkit/Services/PaykitPaymentRequestService.swift index 760e06aa1..c6909e7e5 100644 --- a/Bitkit/Services/PaykitPaymentRequestService.swift +++ b/Bitkit/Services/PaykitPaymentRequestService.swift @@ -524,6 +524,12 @@ protocol PaykitPaymentRequestSdkHandling: Sendable { extension PaykitSdkService: PaykitPaymentRequestSdkHandling {} +enum PaykitPaymentRequestRefreshMode: Comparable { + case stored + case inbox + case full +} + struct PaykitPaymentRequestService { private let sdk: any PaykitPaymentRequestSdkHandling private let now: @Sendable () -> Date @@ -550,9 +556,11 @@ struct PaykitPaymentRequestService { self.logWarning = logWarning } - func synchronize(syncPrivateMessages: Bool = true) async throws -> PaykitPaymentRequestSnapshot { - if syncPrivateMessages { + func synchronize(mode: PaykitPaymentRequestRefreshMode = .full) async throws -> PaykitPaymentRequestSnapshot { + if mode == .full { try await processPendingMessages() + } + if mode >= .inbox { let intakeReports = try await sdk.receivePrivateMessagesFromLinkedPeers() logIntakeFailures(intakeReports) } @@ -1179,7 +1187,7 @@ final class PaykitPaymentRequestManager { private var expiredRequestedPresentations: [PaykitPaymentRequest] = [] private var unavailableRequestedPresentations: [PaykitPaymentRequest] = [] private var isPresentingRequests = false - private var refreshTask: (syncPrivateMessages: Bool, task: Task)? + private var refreshTask: (mode: PaykitPaymentRequestRefreshMode, task: Task)? private var expirationTask: Task? private var presentationRetryTask: Task? private var refreshGeneration = 0 @@ -1549,8 +1557,8 @@ final class PaykitPaymentRequestManager { return subscription } - func refresh(syncPrivateMessages: Bool = true) async { - await refresh(excludingProtectedRequestId: nil, syncPrivateMessages: syncPrivateMessages) + func refresh(mode: PaykitPaymentRequestRefreshMode = .full) async { + await refresh(excludingProtectedRequestId: nil, mode: mode) } /// Applies a changed subscription clock offset: waits for a refresh already reading the old clock, then refreshes again. @@ -1574,12 +1582,12 @@ final class PaykitPaymentRequestManager { ) } - private func refresh(excludingProtectedRequestId: PaykitPaymentRequest.ID?, syncPrivateMessages: Bool = true) async { + private func refresh(excludingProtectedRequestId: PaykitPaymentRequest.ID?, mode: PaykitPaymentRequestRefreshMode = .full) async { if let refreshTask { let generation = stateGeneration await refreshTask.task.value - if syncPrivateMessages, !refreshTask.syncPrivateMessages, generation == stateGeneration, !Task.isCancelled { - await refresh(excludingProtectedRequestId: excludingProtectedRequestId, syncPrivateMessages: true) + if mode > refreshTask.mode, generation == stateGeneration, !Task.isCancelled { + await refresh(excludingProtectedRequestId: excludingProtectedRequestId, mode: mode) } return } @@ -1591,12 +1599,12 @@ final class PaykitPaymentRequestManager { await performRefresh( generation: generation, excludingProtectedRequestId: excludingProtectedRequestId, - syncPrivateMessages: syncPrivateMessages + mode: mode ) guard generation == refreshGeneration else { return } refreshTask = nil } - refreshTask = (syncPrivateMessages, task) + refreshTask = (mode, task) await task.value } @@ -2121,10 +2129,10 @@ final class PaykitPaymentRequestManager { private func performRefresh( generation: Int, excludingProtectedRequestId: PaykitPaymentRequest.ID?, - syncPrivateMessages: Bool + mode: PaykitPaymentRequestRefreshMode ) async { do { - let snapshot = try await service.synchronize(syncPrivateMessages: syncPrivateMessages) + let snapshot = try await service.synchronize(mode: mode) guard generation == refreshGeneration, let activeIdentity else { return } async let completedProofKinds = completedPaymentProofKinds(activeIdentity) async let inFlightRequestIds = inFlightPaymentRequestIds(activeIdentity) diff --git a/BitkitTests/PaykitPaymentRequestServiceTests.swift b/BitkitTests/PaykitPaymentRequestServiceTests.swift index 518f668d5..8e14573ae 100644 --- a/BitkitTests/PaykitPaymentRequestServiceTests.swift +++ b/BitkitTests/PaykitPaymentRequestServiceTests.swift @@ -9,48 +9,97 @@ import XCTest @MainActor final class PaykitPaymentRequestServiceTests: XCTestCase { - func testInboxRefreshReadsSharedStateWithoutPrivateMessageSync() async throws { - let record = try paymentRequestRecord() - let sdk = PaymentRequestSdkMock(records: []) - let manager = paymentRequestManager(sdk: sdk) + func testRefreshModesLoadStoredRequestsAndControlPrivateMessageSync() async throws { + let stored = try paymentRequestRecord(id: "stored") + let incoming = try paymentRequestRecord(id: "incoming") + let modes: [PaykitPaymentRequestRefreshMode] = [.stored, .inbox, .full] + for mode in modes { + let sdk = PaymentRequestSdkMock(records: [stored]) + await sdk.setIncomingRecords([incoming]) + let manager = paymentRequestManager(sdk: sdk) - await manager.refresh() - await sdk.setRecords([record]) - await manager.refresh(syncPrivateMessages: false) + await manager.refresh(mode: mode) - XCTAssertEqual(manager.pendingRequests.map(\.paymentRequestId), [record.paymentRequestId]) - let snapshot = await sdk.snapshot() - XCTAssertEqual(snapshot.processCallCount, 1) - XCTAssertEqual(snapshot.receiveCallCount, 1) + let expectedIds = mode == .stored ? [stored.paymentRequestId] : [stored.paymentRequestId, incoming.paymentRequestId] + XCTAssertEqual(Set(manager.pendingRequests.map(\.paymentRequestId)), Set(expectedIds), "\(mode)") + let snapshot = await sdk.snapshot() + XCTAssertEqual(snapshot.processCallCount, mode == .full ? 1 : 0, "\(mode)") + XCTAssertEqual(snapshot.receiveCallCount, mode >= .inbox ? 1 : 0, "\(mode)") + XCTAssertEqual(snapshot.paymentRequestListCallCount, 1, "\(mode)") + } } - func testFullRefreshAfterOverlappingInboxRefreshDrainsOutboundWork() async throws { - let sdk = PaymentRequestSdkMock(records: []) - let manager = paymentRequestManager(sdk: sdk) - await sdk.pauseNextPaymentRequestList() - let inboxRefresh = Task { await manager.refresh(syncPrivateMessages: false) } - try await waitUntil { await sdk.paymentRequestListIsPaused() } - let fullRefreshStarted = expectation(description: "Full refresh started") - let fullRefresh = Task { - fullRefreshStarted.fulfill() - await manager.refresh() + func testOverlappingRefreshesUpgradeOnlyForStrongerModes() async throws { + let modes: [PaykitPaymentRequestRefreshMode] = [.stored, .inbox, .full] + for initialMode in modes { + for requestedMode in modes { + let context = "\(initialMode) then \(requestedMode)" + let sdk = PaymentRequestSdkMock(records: []) + let manager = paymentRequestManager(sdk: sdk) + await sdk.pauseNextPaymentRequestList() + let initialRefresh = Task { await manager.refresh(mode: initialMode) } + try await waitUntil { await sdk.paymentRequestListIsPaused() } + let overlappingRefreshStarted = expectation(description: context) + let overlappingRefresh = Task { + overlappingRefreshStarted.fulfill() + await manager.refresh(mode: requestedMode) + } + await fulfillment(of: [overlappingRefreshStarted], timeout: 1) + + let pausedSnapshot = await sdk.snapshot() + XCTAssertEqual(pausedSnapshot.processCallCount, initialMode == .full ? 1 : 0, context) + XCTAssertEqual(pausedSnapshot.receiveCallCount, initialMode >= .inbox ? 1 : 0, context) + XCTAssertEqual(pausedSnapshot.paymentRequestListCallCount, 1, context) + await sdk.resumePaymentRequestList() + await initialRefresh.value + await overlappingRefresh.value + + let upgrades = requestedMode > initialMode + let snapshot = await sdk.snapshot() + XCTAssertEqual(snapshot.processCallCount, max(initialMode, requestedMode) == .full ? 1 : 0, context) + XCTAssertEqual(snapshot.receiveCallCount, (initialMode >= .inbox ? 1 : 0) + (upgrades ? 1 : 0), context) + XCTAssertEqual(snapshot.paymentRequestListCallCount, upgrades ? 2 : 1, context) + } } - await fulfillment(of: [fullRefreshStarted], timeout: 1) - - await sdk.resumePaymentRequestList() - await inboxRefresh.value - await fullRefresh.value + } - let snapshot = await sdk.snapshot() - XCTAssertEqual(snapshot.processCallCount, 1) - XCTAssertEqual(snapshot.receiveCallCount, 1) + func testClearInvalidatesStrongerRefreshWaitingForWeakerMode() async throws { + let modes: [PaykitPaymentRequestRefreshMode] = [.stored, .inbox, .full] + for initialMode in modes { + for requestedMode in modes where requestedMode > initialMode { + let context = "\(initialMode) then \(requestedMode)" + let sdk = try PaymentRequestSdkMock(records: [paymentRequestRecord()]) + let manager = paymentRequestManager(sdk: sdk) + await sdk.pauseNextPaymentRequestList() + let initialRefresh = Task { await manager.refresh(mode: initialMode) } + try await waitUntil { await sdk.paymentRequestListIsPaused() } + let overlappingRefreshStarted = expectation(description: context) + let overlappingRefresh = Task { + overlappingRefreshStarted.fulfill() + await manager.refresh(mode: requestedMode) + } + await fulfillment(of: [overlappingRefreshStarted], timeout: 1) + + manager.clear() + await sdk.resumePaymentRequestList() + await initialRefresh.value + await overlappingRefresh.value + + XCTAssertTrue(manager.pendingRequests.isEmpty, context) + XCTAssertTrue(manager.historyRequests.isEmpty, context) + let snapshot = await sdk.snapshot() + XCTAssertEqual(snapshot.processCallCount, 0, context) + XCTAssertEqual(snapshot.receiveCallCount, initialMode == .inbox ? 1 : 0, context) + XCTAssertEqual(snapshot.paymentRequestListCallCount, 1, context) + } + } } - func testClearInvalidatesFullRefreshWaitingForInbox() async throws { + func testCanceledRefreshDoesNotUpgradeInFlightStoredRefresh() async throws { let sdk = try PaymentRequestSdkMock(records: [paymentRequestRecord()]) let manager = paymentRequestManager(sdk: sdk) await sdk.pauseNextPaymentRequestList() - let inboxRefresh = Task { await manager.refresh(syncPrivateMessages: false) } + let storedRefresh = Task { await manager.refresh(mode: .stored) } try await waitUntil { await sdk.paymentRequestListIsPaused() } let fullRefreshStarted = expectation(description: "Full refresh started") let fullRefresh = Task { @@ -59,16 +108,16 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { } await fulfillment(of: [fullRefreshStarted], timeout: 1) - manager.clear() + fullRefresh.cancel() await sdk.resumePaymentRequestList() - await inboxRefresh.value + await storedRefresh.value await fullRefresh.value - XCTAssertTrue(manager.pendingRequests.isEmpty) - XCTAssertTrue(manager.historyRequests.isEmpty) + XCTAssertEqual(manager.pendingRequests.count, 1) let snapshot = await sdk.snapshot() XCTAssertEqual(snapshot.processCallCount, 0) XCTAssertEqual(snapshot.receiveCallCount, 0) + XCTAssertEqual(snapshot.paymentRequestListCallCount, 1) } func testReceivedPaymentContactsIncludeSharedServerRequestsAndLatePayments() throws { @@ -1268,7 +1317,7 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { ) } - func testInboxRefreshMapsActiveRecurringRequestAndCurrentUnpaidPeriod() async throws { + func testStoredRefreshMapsActiveRecurringRequestAndCurrentUnpaidPeriod() async throws { let now = try XCTUnwrap(ISO8601DateFormatter().date(from: "2027-01-15T08:00:00Z")) let recurrence = PaymentRequestRecurrence( every: 1, @@ -1286,7 +1335,7 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { let sdk = PaymentRequestSdkMock(records: [record]) let manager = paymentRequestManager(sdk: sdk, clock: PaymentRequestTestClock(now)) - await manager.refresh(syncPrivateMessages: false) + await manager.refresh(mode: .stored) let snapshot = await sdk.snapshot() XCTAssertEqual(snapshot.processCallCount, 0) @@ -1388,7 +1437,7 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { try await waitUntil { await sdk.linkedPeersIsPaused() } record.state = .canceled await sdk.setRecords([record]) - await manager.refresh(syncPrivateMessages: false) + await manager.refresh(mode: .stored) await sdk.resumeLinkedPeers() XCTAssertFalse(manager.isApprovedForPayment(request)) @@ -6009,6 +6058,8 @@ private final class PaymentRequestSubscriptionStateMemoryStore: PaykitSubscripti private actor PaymentRequestSdkMock: PaykitPaymentRequestSdkHandling, PaykitPaymentProofSdkHandling { private var activeIdentity = "pubky\(String(repeating: "z", count: 52))" private var records: [PaymentRequestRecord] + private var incomingRecords: [PaymentRequestRecord] = [] + private var paymentRequestListCallCount = 0 private var peerRecords: [LinkedPeerRecord] = [] private var requestCapabilitiesByPublicKey: [String: Bool] = [:] private var liveSessionAvailable = true @@ -6097,6 +6148,8 @@ private actor PaymentRequestSdkMock: PaykitPaymentRequestSdkHandling, PaykitPaym if let receiveError { throw receiveError } + records.append(contentsOf: incomingRecords) + incomingRecords = [] return receiveReports } @@ -6105,6 +6158,7 @@ private actor PaymentRequestSdkMock: PaykitPaymentRequestSdkHandling, PaykitPaym } func sharedPaymentRequests() async -> [PaymentRequestRecord] { + paymentRequestListCallCount += 1 let snapshot = records guard shouldPauseNextPaymentRequestList else { return snapshot } @@ -6358,6 +6412,10 @@ private actor PaymentRequestSdkMock: PaykitPaymentRequestSdkHandling, PaykitPaym self.records = records } + func setIncomingRecords(_ records: [PaymentRequestRecord]) { + incomingRecords = records + } + func configureRecipients( peers: [LinkedPeerRecord], requestCapabilitiesByPublicKey: [String: Bool] @@ -6446,6 +6504,7 @@ private actor PaymentRequestSdkMock: PaykitPaymentRequestSdkHandling, PaykitPaym processCallCount: processCallCount, processedCounterparties: processedCounterparties, receiveCallCount: receiveCallCount, + paymentRequestListCallCount: paymentRequestListCallCount, acceptedRequests: acceptedRequests, rejectedRequests: rejectedRequests, proposedRequests: proposedRequests @@ -6471,6 +6530,7 @@ private struct PaymentRequestSdkSnapshot { let processCallCount: Int let processedCounterparties: [String] let receiveCallCount: Int + let paymentRequestListCallCount: Int let acceptedRequests: [PaymentRequestInvocation] let rejectedRequests: [PaymentRequestInvocation] let proposedRequests: [ProposedPaymentRequestInvocation] diff --git a/BitkitTests/PubkyProfileManagerTests.swift b/BitkitTests/PubkyProfileManagerTests.swift index 965f5d978..4bd6f5df2 100644 --- a/BitkitTests/PubkyProfileManagerTests.swift +++ b/BitkitTests/PubkyProfileManagerTests.swift @@ -1,4 +1,5 @@ @testable import Bitkit +import enum Paykit.PaykitError import class Paykit.PubkySessionAccess import struct Paykit.PubkySessionBootstrapResult import XCTest @@ -1441,6 +1442,26 @@ final class PubkyProfileManagerTests: XCTestCase { XCTAssertEqual(result, .restored(publicKey: "pubky_saved")) } + func testResolveSessionInitializationKeepsSessionOnTemporaryFailure() async { + let errors: [PaykitError] = [ + .ConcurrentUpdate(code: "concurrent_update", context: "Locked"), + .SharedStateBusy(code: "shared_state_busy", context: "Pending write"), + .Transport(code: "transport_error", context: "Offline"), + ] + for error in errors { + let result = await PubkyProfileManager.resolveSessionInitialization( + savedSessionSecret: "saved-session", + storedSecretKeyHex: "local-secret", + importSession: { _ in throw error }, + signInWithSecretKey: { _ in + XCTFail("Temporary failures should retry the saved session") + return "unused-session" + } + ) + XCTAssertEqual(result, .restorationFailed) + } + } + func testResolveSessionInitializationSignsInWhenOnlySecretKeyExists() async { let result = await PubkyProfileManager.resolveSessionInitialization( savedSessionSecret: nil, From 134dce100c6a66583b5294e506472f3403626c93 Mon Sep 17 00:00:00 2001 From: benk10 Date: Sat, 3 Oct 2026 11:21:38 -0500 Subject: [PATCH 38/68] chore: update Paykit to rc62 --- Bitkit.xcodeproj/project.pbxproj | 2 +- .../project.xcworkspace/xcshareddata/swiftpm/Package.resolved | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Bitkit.xcodeproj/project.pbxproj b/Bitkit.xcodeproj/project.pbxproj index b0bf968ce..a70b08854 100644 --- a/Bitkit.xcodeproj/project.pbxproj +++ b/Bitkit.xcodeproj/project.pbxproj @@ -1178,7 +1178,7 @@ repositoryURL = "https://github.com/pubky/paykit-rs"; requirement = { kind = exactVersion; - version = "0.1.0-rc61"; + version = "0.1.0-rc62"; }; }; 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 7a91cde06..712dcf93c 100644 --- a/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -33,8 +33,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pubky/paykit-rs", "state" : { - "revision" : "be7e82ab6f62e738c284297143a22b75a187756f", - "version" : "0.1.0-rc61" + "revision" : "f2c5f7127b1cb0f29052ce76e43f3c0ae468fd12", + "version" : "0.1.0-rc62" } }, { From 7bdfe8dc9bd48b74b2cc76fd651cd9912d649533 Mon Sep 17 00:00:00 2001 From: benk10 Date: Sun, 4 Oct 2026 18:29:01 -0500 Subject: [PATCH 39/68] fix: refresh requests after payment state changes --- Bitkit/AppScene.swift | 10 ++- .../PaykitPaymentRequestService.swift | 17 ++-- .../PaykitPaymentRequestServiceTests.swift | 80 +++++++++++++++++++ 3 files changed, 96 insertions(+), 11 deletions(-) diff --git a/Bitkit/AppScene.swift b/Bitkit/AppScene.swift index 9d23cb544..c492bffd4 100644 --- a/Bitkit/AppScene.swift +++ b/Bitkit/AppScene.swift @@ -482,7 +482,7 @@ struct AppScene: View { } }) .onReceive(PaykitPaymentProofService.proofStateChangedPublisher) { - Task { await refreshIncomingPaykitPaymentRequests() } + Task { await refreshIncomingPaykitPaymentRequests(forceFresh: true) } } .onReceive(PaykitPaymentProofService.onchainPaymentResolutionPublisher) { resolution in Task { await associateResolvedPaykitOnchainPayment(resolution) } @@ -1111,7 +1111,11 @@ struct AppScene: View { } } - private func refreshIncomingPaykitPaymentRequests(presentItems: Bool = true, mode: PaykitPaymentRequestRefreshMode = .full) async { + private func refreshIncomingPaykitPaymentRequests( + presentItems: Bool = true, + mode: PaykitPaymentRequestRefreshMode = .full, + forceFresh: Bool = false + ) async { guard PaykitFeatureFlags.isUIEnabled, wallet.walletExists == true, pubkyProfile.authState == .authenticated @@ -1126,7 +1130,7 @@ struct AppScene: View { await PaykitPaymentProofService.shared.reconcile() } guard let identity = pubkyProfile.publicKey else { return } - await paykitPaymentRequestManager.refresh(mode: mode) + await paykitPaymentRequestManager.refresh(mode: mode, forceFresh: forceFresh) guard pubkyProfile.authState == .authenticated, PubkyPublicKeyFormat.matches(identity, pubkyProfile.publicKey) else { return } diff --git a/Bitkit/Services/PaykitPaymentRequestService.swift b/Bitkit/Services/PaykitPaymentRequestService.swift index c6909e7e5..dde9966a9 100644 --- a/Bitkit/Services/PaykitPaymentRequestService.swift +++ b/Bitkit/Services/PaykitPaymentRequestService.swift @@ -1557,16 +1557,13 @@ final class PaykitPaymentRequestManager { return subscription } - func refresh(mode: PaykitPaymentRequestRefreshMode = .full) async { - await refresh(excludingProtectedRequestId: nil, mode: mode) + func refresh(mode: PaykitPaymentRequestRefreshMode = .full, forceFresh: Bool = false) async { + await refresh(excludingProtectedRequestId: nil, mode: mode, forceFresh: forceFresh) } /// Applies a changed subscription clock offset: waits for a refresh already reading the old clock, then refreshes again. func refreshAfterSubscriptionClockChange() async { - if let refreshTask { - await refreshTask.task.value - } - await refresh() + await refresh(forceFresh: true) } func synchronizeSubscriptionNotifications(enabled: Bool) async { @@ -1582,11 +1579,15 @@ final class PaykitPaymentRequestManager { ) } - private func refresh(excludingProtectedRequestId: PaykitPaymentRequest.ID?, mode: PaykitPaymentRequestRefreshMode = .full) async { + private func refresh( + excludingProtectedRequestId: PaykitPaymentRequest.ID?, + mode: PaykitPaymentRequestRefreshMode = .full, + forceFresh: Bool = false + ) async { if let refreshTask { let generation = stateGeneration await refreshTask.task.value - if mode > refreshTask.mode, generation == stateGeneration, !Task.isCancelled { + if forceFresh || mode > refreshTask.mode, generation == stateGeneration, !Task.isCancelled { await refresh(excludingProtectedRequestId: excludingProtectedRequestId, mode: mode) } return diff --git a/BitkitTests/PaykitPaymentRequestServiceTests.swift b/BitkitTests/PaykitPaymentRequestServiceTests.swift index 8e14573ae..4b16d08e1 100644 --- a/BitkitTests/PaykitPaymentRequestServiceTests.swift +++ b/BitkitTests/PaykitPaymentRequestServiceTests.swift @@ -63,6 +63,57 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { } } + func testFreshRefreshRereadsProofStateChangedDuringNotificationSynchronization() async throws { + actor InFlightPayments { + var ids: Set + + init(_ id: PaykitPaymentRequest.ID) { + ids = [id] + } + + func clear() { + ids = [] + } + } + + let record = try paymentRequestRecord() + let requestId = PaykitPaymentRequest.ID(paymentRequestId: record.paymentRequestId, counterparty: record.counterparty) + let sdk = PaymentRequestSdkMock(records: [record]) + let inFlightPayments = InFlightPayments(requestId) + let center = PaykitSubscriptionNotificationCenterMock() + let manager = PaykitPaymentRequestManager( + service: PaykitPaymentRequestService(sdk: sdk, logWarning: { _ in }), + presentationStore: PaymentRequestPresentationMemoryStore(), + acceptanceStore: PaymentRequestPresentationMemoryStore(), + subscriptionStateStore: PaymentRequestSubscriptionStateMemoryStore(), + subscriptionNotificationScheduler: PaykitSubscriptionNotificationScheduler(center: center), + completedPaymentProofKinds: { _ in [:] }, + inFlightPaymentRequestIds: { _ in await inFlightPayments.ids }, + isAvailable: { true }, + logWarning: { _ in } + ) + manager.activate(identity: "pubky\(String(repeating: "z", count: 52))") + await center.pauseNextPendingRequests() + let first = Task { await manager.refresh() } + try await waitUntil { await center.isPendingRequestsPaused } + XCTAssertTrue(manager.pendingRequests.isEmpty) + + await inFlightPayments.clear() + let refreshStarted = expectation(description: "Proof state refresh started") + let afterFailure = Task { + refreshStarted.fulfill() + await manager.refresh(forceFresh: true) + } + await fulfillment(of: [refreshStarted], timeout: 1) + await center.resumePendingRequests() + await first.value + await afterFailure.value + + XCTAssertEqual(manager.pendingRequests.map(\.id), [requestId]) + let snapshot = await sdk.snapshot() + XCTAssertEqual(snapshot.paymentRequestListCallCount, 2) + } + func testClearInvalidatesStrongerRefreshWaitingForWeakerMode() async throws { let modes: [PaykitPaymentRequestRefreshMode] = [.stored, .inbox, .full] for initialMode in modes { @@ -120,6 +171,35 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { XCTAssertEqual(snapshot.paymentRequestListCallCount, 1) } + func testFreshRefreshDoesNotOutliveCancellationOrIdentityChange() async throws { + for invalidation in ["cancel", "clear", "identity"] { + let sdk = PaymentRequestSdkMock(records: []) + let manager = paymentRequestManager(sdk: sdk) + await sdk.pauseNextPaymentRequestList() + let first = Task { await manager.refresh() } + try await waitUntil { await sdk.paymentRequestListIsPaused() } + let refreshStarted = expectation(description: invalidation) + let fresh = Task { + refreshStarted.fulfill() + await manager.refresh(forceFresh: true) + } + await fulfillment(of: [refreshStarted], timeout: 1) + + switch invalidation { + case "cancel": fresh.cancel() + case "clear": manager.clear() + default: manager.activate(identity: "pubky\(String(repeating: "y", count: 52))") + } + await sdk.resumePaymentRequestList() + await first.value + await fresh.value + + let snapshot = await sdk.snapshot() + XCTAssertEqual(snapshot.paymentRequestListCallCount, 1, invalidation) + XCTAssertTrue(manager.pendingRequests.isEmpty, invalidation) + } + } + func testReceivedPaymentContactsIncludeSharedServerRequestsAndLatePayments() throws { let payer = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" var record = try receivedPaymentRecord(counterparty: payer) From 97fbbf5cd9cca461ef32f955d65572ac10c5e4b2 Mon Sep 17 00:00:00 2001 From: benk10 Date: Sun, 4 Oct 2026 19:37:52 -0500 Subject: [PATCH 40/68] Batch private payment withdrawal and skip idle drains --- .../PrivatePaykitService+Contacts.swift | 32 +++---- Bitkit/Services/PubkyService.swift | 22 +++-- BitkitTests/PaykitContactLifecycleTests.swift | 53 +++++++++++- BitkitTests/PrivatePaykitServiceTests.swift | 83 ++++++++++++++++--- 4 files changed, 152 insertions(+), 38 deletions(-) diff --git a/Bitkit/Services/PrivatePaykitService+Contacts.swift b/Bitkit/Services/PrivatePaykitService+Contacts.swift index 81e7c1905..f6c060a63 100644 --- a/Bitkit/Services/PrivatePaykitService+Contacts.swift +++ b/Bitkit/Services/PrivatePaykitService+Contacts.swift @@ -40,7 +40,7 @@ extension PrivatePaykitService { struct EndpointCleanupOperations { let linkedPeers: () async throws -> [LinkedPeerRecord] - let clearPaymentList: (_ publicKey: String) async throws -> PrivatePaymentListDeliveryReport? + let clearPaymentLists: (_ publicKeys: [String]) async throws -> PrivatePaymentListDeliveryReport? let drainMessages: (_ publicKeys: [String]) async -> Void let pendingDrainKeys: (_ publicKeys: [String]) async -> Set let syncApp: () async throws -> Void @@ -205,7 +205,7 @@ extension PrivatePaykitService { func removePublishedEndpoints(for publicKeys: [String]? = nil) async throws { try await removePublishedEndpoints(for: publicKeys, operations: EndpointCleanupOperations( linkedPeers: { try await PaykitSdkService.shared.linkedPeers() }, - clearPaymentList: { try await PaykitSdkService.shared.clearPrivatePaymentList(to: $0) }, + clearPaymentLists: { try await PaykitSdkService.shared.clearPrivatePaymentLists(to: $0) }, drainMessages: { await self.drainPendingPrivateMessages(reason: "cleanup", advancing: $0) }, pendingDrainKeys: { await self.pendingPrivateMessageDrainKeys($0) }, syncApp: { try await PublicPaykitService.syncPaykitApp() } @@ -250,28 +250,32 @@ extension PrivatePaykitService { var firstError: Error? var failedPublicKeys = Set() var clearedRetryKeys = [String]() - for publicKey in cleanupKeys { + if !cleanupKeys.isEmpty { do { - guard let report = try await operations.clearPaymentList(publicKey) else { continue } - logPrivatePaymentListDeliveryFailures(report, reason: "cleanup") - if !report.failedToQueue.isEmpty || !report.failedToDeliver.isEmpty { - throw PrivatePaykitError.privateUnavailable + if let report = try await operations.clearPaymentLists(cleanupKeys) { + logPrivatePaymentListDeliveryFailures(report, reason: "cleanup") + failedPublicKeys.formUnion((report.failedToQueue.map(\.counterparty) + report.failedToDeliver.map(\.counterparty)) + .compactMap { PubkyPublicKeyFormat.normalized($0) }) + clearedRetryKeys = report.cleared.compactMap { PubkyPublicKeyFormat.normalized($0.counterparty) } + .filter { !failedPublicKeys.contains($0) } + if !failedPublicKeys.isEmpty { firstError = PrivatePaykitError.privateUnavailable } } - clearedRetryKeys.append(publicKey) } catch { Logger.warn( - "Failed to clear private Paykit endpoints for \(PubkyPublicKeyFormat.redacted(publicKey)): " + - PaykitResolutionFailureDiagnostics.reason(for: error), + "Failed to clear private Paykit endpoints: \(PaykitResolutionFailureDiagnostics.reason(for: error))", context: "PrivatePaykit" ) - failedPublicKeys.insert(publicKey) - firstError = firstError ?? error + failedPublicKeys.formUnion(cleanupKeys) + firstError = error } } if !clearedRetryKeys.isEmpty { - await operations.drainMessages(clearedRetryKeys) - let pendingRetryKeys = await operations.pendingDrainKeys(clearedRetryKeys) + var pendingRetryKeys = await operations.pendingDrainKeys(clearedRetryKeys) + if !pendingRetryKeys.isEmpty { + await operations.drainMessages(Array(pendingRetryKeys)) + pendingRetryKeys = await operations.pendingDrainKeys(clearedRetryKeys) + } if !pendingRetryKeys.isEmpty { Logger.warn( "Private Paykit endpoint withdrawal remains pending for \(pendingRetryKeys.map(PubkyPublicKeyFormat.redacted))", diff --git a/Bitkit/Services/PubkyService.swift b/Bitkit/Services/PubkyService.swift index 5a053f75f..329b218e9 100644 --- a/Bitkit/Services/PubkyService.swift +++ b/Bitkit/Services/PubkyService.swift @@ -757,22 +757,26 @@ actor PaykitSdkService { } } - func clearPrivatePaymentList( - to counterparty: String + func clearPrivatePaymentLists( + to counterparties: [String] ) async throws -> PrivatePaymentListDeliveryReport? { - try await withStateRevisionTracking { sdk in - if try await sdk.linkedPeers().contains(where: { - $0.state == .blocked && PubkyPublicKeyFormat.matches($0.counterparty, counterparty) - }) { - return nil - } + guard !counterparties.isEmpty else { return nil } + return try await withStateRevisionTracking { sdk in + let blockedPeers = try await sdk.linkedPeers().filter { $0.state == .blocked } + let updates = counterparties.filter { counterparty in + !blockedPeers.contains { PubkyPublicKeyFormat.matches($0.counterparty, counterparty) } + }.map { PrivatePaymentListReservationUpdateInput(counterparty: $0, reservations: []) } + guard !updates.isEmpty else { return nil } if let publicKey = try await sdk.identityStatus()?.publicKey, let app = try await sdk.paykitAppRegistry(publicKey: publicKey)?.apps.first(where: { $0.appId == "bitkit" }), !app.capabilities.privatePayments { return nil } - return try await sdk.clearPrivatePaymentListAndProcessOutbound(counterparty: counterparty) + return try await sdk.syncPrivatePaymentListsWithReservationsAndProcessOutbound( + updates: updates, + clearUnlistedLinkedPeers: false + ) } } diff --git a/BitkitTests/PaykitContactLifecycleTests.swift b/BitkitTests/PaykitContactLifecycleTests.swift index 72f1f782c..e1b2437ac 100644 --- a/BitkitTests/PaykitContactLifecycleTests.swift +++ b/BitkitTests/PaykitContactLifecycleTests.swift @@ -194,7 +194,7 @@ final class PaykitContactLifecycleTests: XCTestCase { let service = PaykitSdkService(sdkFactory: { sdk }) _ = try await service.removeContact(publicKey: sdk.publicKey) let eventsBeforeCleanup = sdk.events - let report = try await service.clearPrivatePaymentList(to: sdk.publicKey) + let report = try await service.clearPrivatePaymentLists(to: [sdk.publicKey]) XCTAssertNil(report) XCTAssertEqual(sdk.events, eventsBeforeCleanup) } @@ -204,7 +204,7 @@ final class PaykitContactLifecycleTests: XCTestCase { sdk.capabilities.privatePayments = false let service = PaykitSdkService(sdkFactory: { sdk }) - let report = try await service.clearPrivatePaymentList(to: sdk.publicKey) + let report = try await service.clearPrivatePaymentLists(to: [sdk.publicKey]) XCTAssertNil(report) XCTAssertTrue(sdk.events.isEmpty) @@ -222,6 +222,32 @@ final class PaykitContactLifecycleTests: XCTestCase { XCTAssertFalse(sdk.capabilities.privatePayments) } + + func testWithdrawalBatchesPreflightAndDelegatesRepeatedEmptyLists() async throws { + let sdk = ContactLifecycleSdk(noPointer: .init()) + let other = "pubky5rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" + var blocked = try XCTUnwrap(sdk.peers.first) + blocked.counterparty = "pubky6rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" + blocked.state = .blocked + sdk.peers.append(blocked) + let service = PaykitSdkService(sdkFactory: { sdk }) + let empty = try await service.clearPrivatePaymentLists(to: []) + XCTAssertNil(empty) + XCTAssertEqual(sdk.peerReads, 0) + + for client in [service, service, PaykitSdkService(sdkFactory: { sdk })] { + let report = try await client.clearPrivatePaymentLists(to: [sdk.publicKey, other, blocked.counterparty]) + XCTAssertEqual(report?.cleared.map(\.counterparty), [sdk.publicKey, other]) + } + + XCTAssertEqual(sdk.peerReads, 3) + XCTAssertEqual(sdk.identityReads, 3) + XCTAssertEqual(sdk.registryReads, 3) + XCTAssertEqual(sdk.withdrawals.count, 3) + XCTAssertTrue(sdk.withdrawals.allSatisfy { updates in + updates.map(\.counterparty) == [sdk.publicKey, other] && updates.allSatisfy(\.reservations.isEmpty) + }) + } } private final class ContactLifecycleSdk: PaykitSdk, @unchecked Sendable { @@ -233,6 +259,10 @@ private final class ContactLifecycleSdk: PaykitSdk, @unchecked Sendable { var failLinkedPeers = false var failUnblock = false var failSaveContact = false + var peerReads = 0 + var identityReads = 0 + var registryReads = 0 + var withdrawals = [[PrivatePaymentListReservationUpdateInput]]() var capabilities = PaykitAppCapabilities(privatePayments: true, paymentRequests: true, receipts: false, outgoingPayments: true) lazy var record: ContactRecord? = ContactRecord( publicKey: publicKey, label: "Contact", profile: nil, @@ -248,7 +278,8 @@ private final class ContactLifecycleSdk: PaykitSdk, @unchecked Sendable { ] override func paykitAppRegistry(publicKey _: String) async throws -> PaykitAppRegistry? { - registry + registryReads += 1 + return registry } private var registry: PaykitAppRegistry { @@ -258,7 +289,8 @@ private final class ContactLifecycleSdk: PaykitSdk, @unchecked Sendable { } override func identityStatus() async throws -> IdentityStatus? { - IdentityStatus(publicKey: publicKey, capability: .privateLinkCapable) + identityReads += 1 + return IdentityStatus(publicKey: publicKey, capability: .privateLinkCapable) } override func publishPaykitApp(displayName _: String, capabilities: PaykitAppCapabilities) async throws -> PaykitAppRegistry { @@ -283,10 +315,23 @@ private final class ContactLifecycleSdk: PaykitSdk, @unchecked Sendable { } override func linkedPeers() async throws -> [LinkedPeerRecord] { + peerReads += 1 if failLinkedPeers { throw PubkyServiceError.profileNotFound } return peers } + override func syncPrivatePaymentListsWithReservationsAndProcessOutbound( + updates: [PrivatePaymentListReservationUpdateInput], + clearUnlistedLinkedPeers: Bool + ) async throws -> PrivatePaymentListDeliveryReport { + XCTAssertFalse(clearUnlistedLinkedPeers) + withdrawals.append(updates) + return .init( + queued: [], cleared: updates.map { .init(counterparty: $0.counterparty, outboundMessageId: 1, error: nil) }, + failedToQueue: [], failedToDeliver: [] + ) + } + override func clearPrivatePaymentListAndProcessOutbound( counterparty: String ) async throws -> PrivatePaymentListDeliveryReport { diff --git a/BitkitTests/PrivatePaykitServiceTests.swift b/BitkitTests/PrivatePaykitServiceTests.swift index 7256c454f..3b1d8a819 100644 --- a/BitkitTests/PrivatePaykitServiceTests.swift +++ b/BitkitTests/PrivatePaykitServiceTests.swift @@ -843,7 +843,7 @@ final class PrivatePaykitServiceTests: XCTestCase { if shouldFail, failureStage == "lookup" { throw PrivatePaykitError.privateUnavailable } return [] }, - clearPaymentList: { _ in + clearPaymentLists: { _ in if shouldFail, failureStage == "withdraw" { throw PrivatePaykitError.privateUnavailable } if shouldFail, failureStage == "queue" { return PrivatePaymentListDeliveryReport( @@ -852,9 +852,15 @@ final class PrivatePaykitServiceTests: XCTestCase { failedToDeliver: [] ) } - return PrivatePaymentListDeliveryReport(queued: [], cleared: [], failedToQueue: [], failedToDeliver: []) + return PrivatePaymentListDeliveryReport( + queued: [], cleared: [.init(counterparty: publicKey, outboundMessageId: 1, error: nil)], + failedToQueue: [], failedToDeliver: [] + ) + }, + drainMessages: { keys in + XCTAssertTrue(shouldFail && failureStage == "pending") + XCTAssertEqual(keys, [publicKey]) }, - drainMessages: { _ in }, pendingDrainKeys: { _ in shouldFail && failureStage == "pending" ? [publicKey] : [] }, syncApp: { registryUpdates += 1 @@ -905,11 +911,14 @@ final class PrivatePaykitServiceTests: XCTestCase { if failLookup { throw PrivatePaykitError.privateUnavailable } return [peer, linkingPeer, recoveringPeer] }, - clearPaymentList: { - cleared.append($0) - return PrivatePaymentListDeliveryReport(queued: [], cleared: [], failedToQueue: [], failedToDeliver: []) + clearPaymentLists: { + cleared.append(contentsOf: $0) + return PrivatePaymentListDeliveryReport( + queued: [], cleared: $0.map { .init(counterparty: $0, outboundMessageId: 1, error: nil) }, + failedToQueue: [], failedToDeliver: [] + ) }, - drainMessages: { XCTAssertEqual($0, [publicKey]) }, + drainMessages: { _ in XCTFail("Delivered withdrawal needs no drain") }, pendingDrainKeys: { _ in [] }, syncApp: { XCTAssertEqual(cleared, [publicKey]) @@ -931,6 +940,49 @@ final class PrivatePaykitServiceTests: XCTestCase { XCTAssertEqual(registryUpdates, 1) } + func testBatchCleanupRetainsOnlyFailedContacts() async throws { + let failed = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" + let successful = "pubky5rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" + for failQueue in [true, false] { + let service = PrivatePaykitService() + var contact = PrivatePaykitService.ContactState() + contact.hasPublishedPrivatePaymentList = true + for key in [failed, successful] { + await service.setTestContactState(contact, publicKey: key) + } + PrivatePaykitService.markDeletedContactCleanupPending([failed, successful]) + var batches = 0 + do { + try await service.removePublishedEndpoints(for: [failed, successful], operations: .init( + linkedPeers: { [] }, + clearPaymentLists: { keys in + batches += 1 + XCTAssertEqual(Set(keys), [failed, successful]) + return .init( + queued: [], cleared: [.init(counterparty: successful, outboundMessageId: 1, error: nil)], + failedToQueue: failQueue ? [.init(counterparty: failed, outboundMessageId: nil, error: nil)] : [], + failedToDeliver: failQueue ? [] : [.init( + counterparty: failed, outboundMessageId: 2, reservationId: nil, + error: CleanupDeliveryError(noPointer: .init()) + )] + ) + }, + drainMessages: { _ in XCTFail("Delivered withdrawal needs no drain") }, + pendingDrainKeys: { _ in [] }, + syncApp: { XCTFail("Failed withdrawal must retain the private capability") } + )) + XCTFail("Expected partial failure") + } catch {} + XCTAssertEqual(batches, 1) + let failedState = await service.testContactState(publicKey: failed) + let successfulState = await service.testContactState(publicKey: successful) + XCTAssertTrue(failedState?.hasPublishedPrivatePaymentList == true) + XCTAssertNil(successfulState) + XCTAssertTrue(PrivatePaykitService.pendingDeletedContactCleanupKeys().contains(failed)) + XCTAssertFalse(PrivatePaykitService.pendingDeletedContactCleanupKeys().contains(successful)) + } + } + func testCleanupSkipsNeverLinkedContactsWithoutPublishedDetails() async { let service = PrivatePaykitService() var publishedState = PrivatePaykitService.ContactState() @@ -1145,13 +1197,16 @@ final class PrivatePaykitServiceTests: XCTestCase { var registryUpdates = 0 let cleanup = PrivatePaykitService.EndpointCleanupOperations( linkedPeers: { [] }, - clearPaymentList: { key in - XCTAssertEqual(key, publicKey) + clearPaymentLists: { keys in + XCTAssertEqual(keys, [publicKey]) withdrawing.fulfill() for await _ in resume { break } - return .init(queued: [], cleared: [], failedToQueue: [], failedToDeliver: []) + return .init( + queued: [], cleared: [.init(counterparty: publicKey, outboundMessageId: 1, error: nil)], + failedToQueue: [], failedToDeliver: [] + ) }, drainMessages: { XCTAssertEqual($0, [publicKey]) }, pendingDrainKeys: { _ in [] }, @@ -1240,7 +1295,7 @@ final class PrivatePaykitServiceTests: XCTestCase { let cleanup = Task { try await service.removePublishedEndpoints(operations: .init( linkedPeers: { [] }, - clearPaymentList: { _ in nil }, + clearPaymentLists: { _ in nil }, drainMessages: { _ in }, pendingDrainKeys: { _ in [] }, syncApp: {} @@ -1408,6 +1463,12 @@ final class PrivatePaykitServiceTests: XCTestCase { } } +private final class CleanupDeliveryError: PrivateOperationError, @unchecked Sendable { + override func redactedContext() -> String { + "Delivery failed" + } +} + private extension PrivatePaykitService { func setTestContactState(_ contactState: ContactState, publicKey: String) { state.contacts[publicKey] = contactState From 8b51eb45406ac96e478654ab95fb6f1f9f6ce739 Mon Sep 17 00:00:00 2001 From: benk10 Date: Sun, 4 Oct 2026 20:01:48 -0500 Subject: [PATCH 41/68] Publish Paykit app once when enabling sharing --- Bitkit/Services/ContactPaymentsService.swift | 5 --- Bitkit/Services/PublicPaykitService.swift | 25 ++++++++--- BitkitTests/ContactPaymentsServiceTests.swift | 12 ++--- BitkitTests/PrivatePaykitServiceTests.swift | 1 - BitkitTests/PublicPaykitServiceTests.swift | 45 +++++++++++++++++++ 5 files changed, 72 insertions(+), 16 deletions(-) diff --git a/Bitkit/Services/ContactPaymentsService.swift b/Bitkit/Services/ContactPaymentsService.swift index 1c3aa426d..d6199eea6 100644 --- a/Bitkit/Services/ContactPaymentsService.swift +++ b/Bitkit/Services/ContactPaymentsService.swift @@ -7,7 +7,6 @@ enum ContactPaymentsService { @MainActor private static var operationWaiters: [CheckedContinuation] = [] struct Operations { - let syncPaykitApp: (_ privatePaymentsEnabled: Bool) async throws -> Void let syncPublicEndpoints: (_ publish: Bool) async throws -> Void let preparePrivateEndpoints: (_ contactPublicKeys: [String], _ requireImmediatePublication: Bool) async -> Error? let removePrivateEndpoints: () async throws -> Void @@ -17,9 +16,6 @@ enum ContactPaymentsService { @MainActor static func live(wallet: WalletViewModel) -> Operations { Operations( - syncPaykitApp: { enabled in - try await PublicPaykitService.syncPaykitApp(privateSharingEnabled: enabled) - }, syncPublicEndpoints: { publish in try await PublicPaykitService.syncPublishedEndpoints(wallet: wallet, publish: publish) }, @@ -163,7 +159,6 @@ enum ContactPaymentsService { defaults.set(canUsePrivatePayments, forKey: PrivatePaykitService.publishingEnabledKey) defaults.set(true, forKey: confirmedPreferenceKey) - try await operations.syncPaykitApp(canUsePrivatePayments) try await operations.syncPublicEndpoints(true) operations.setPublicCleanupPending(false) if canUsePrivatePayments { diff --git a/Bitkit/Services/PublicPaykitService.swift b/Bitkit/Services/PublicPaykitService.swift index 791b68707..320a7a7eb 100644 --- a/Bitkit/Services/PublicPaykitService.swift +++ b/Bitkit/Services/PublicPaykitService.swift @@ -318,16 +318,31 @@ enum PublicPaykitService { static func syncPublishedEndpoints( wallet: WalletViewModel, publish: Bool + ) async throws { + try await syncPublishedEndpoints( + publish: publish, + buildEndpoints: { try await buildWalletEndpoints(wallet: wallet, refreshIfNeeded: true, requireEndpoint: true) }, + syncApp: { try await syncPaykitApp() }, + applyEndpoints: applyPublishedEndpoints + ) + } + + @MainActor + static func syncPublishedEndpoints( + publish: Bool, + buildEndpoints: () async throws -> [Endpoint], + syncApp: () async throws -> Void, + applyEndpoints: ([Endpoint]) async throws -> Void ) async throws { guard publish else { var firstError: Error? do { - try await removePublishedEndpoints() + try await applyEndpoints([]) } catch { firstError = firstError ?? error } do { - try await syncPaykitApp() + try await syncApp() } catch { firstError = firstError ?? error } @@ -337,9 +352,9 @@ enum PublicPaykitService { return } - let desiredEndpoints = try await buildWalletEndpoints(wallet: wallet, refreshIfNeeded: true, requireEndpoint: true) - try await syncPaykitApp() - try await applyPublishedEndpoints(desiredEndpoints) + try await syncApp() + let desiredEndpoints = try await buildEndpoints() + try await applyEndpoints(desiredEndpoints) } @MainActor diff --git a/BitkitTests/ContactPaymentsServiceTests.swift b/BitkitTests/ContactPaymentsServiceTests.swift index 7ea555124..6db2e7ff9 100644 --- a/BitkitTests/ContactPaymentsServiceTests.swift +++ b/BitkitTests/ContactPaymentsServiceTests.swift @@ -57,7 +57,7 @@ final class ContactPaymentsServiceTests: XCTestCase { XCTAssertEqual(operations.privatePublications.count, 1) XCTAssertEqual(operations.privatePublications[0].contactPublicKeys, ["contact-a", "contact-b"]) XCTAssertFalse(operations.privatePublications[0].requiresImmediatePublication) - XCTAssertEqual(operations.calls, ["app:true", "public:true", "private:publish"]) + XCTAssertEqual(operations.calls, ["public:true", "private:publish"]) XCTAssertEqual(operations.privateRemovalCount, 0) XCTAssertEqual(operations.publicCleanupValues, [false]) XCTAssertEqual(operations.privateCleanupValues, [false]) @@ -155,6 +155,11 @@ final class ContactPaymentsServiceTests: XCTestCase { try await withIsolatedDefaultsAsync { defaults in defaults.set(true, forKey: PrivatePaykitService.publishingEnabledKey) let operations = OperationsSpy() + operations.onSyncPublicEndpoints = { publish in + XCTAssertTrue(publish) + XCTAssertFalse(defaults.bool(forKey: PrivatePaykitService.publishingEnabledKey)) + XCTAssertEqual(operations.privateCleanupValues, [true]) + } try await ContactPaymentsService.setEnabled( true, @@ -353,7 +358,7 @@ final class ContactPaymentsServiceTests: XCTestCase { XCTAssertEqual(error as? TestError, .operationFailed) } try await enable.value - XCTAssertEqual(operations.calls, ["private:remove", "public:false", "app:true", "public:true", "private:publish"]) + XCTAssertEqual(operations.calls, ["private:remove", "public:false", "public:true", "private:publish"]) XCTAssertTrue(ContactPaymentsService.isEnabled(defaults: defaults)) XCTAssertFalse(defaults.bool(forKey: PrivatePaykitService.cleanupPendingKey)) XCTAssertFalse(defaults.bool(forKey: PublicPaykitService.cleanupPendingKey)) @@ -480,9 +485,6 @@ final class ContactPaymentsServiceTests: XCTestCase { func makeOperations(defaults: UserDefaults? = nil) -> ContactPaymentsService.Operations { ContactPaymentsService.Operations( - syncPaykitApp: { enabled in - self.calls.append("app:\(enabled)") - }, syncPublicEndpoints: { publish in self.calls.append("public:\(publish)") self.publicPublicationValues.append(publish) diff --git a/BitkitTests/PrivatePaykitServiceTests.swift b/BitkitTests/PrivatePaykitServiceTests.swift index 3b1d8a819..a77c13a43 100644 --- a/BitkitTests/PrivatePaykitServiceTests.swift +++ b/BitkitTests/PrivatePaykitServiceTests.swift @@ -1220,7 +1220,6 @@ final class PrivatePaykitServiceTests: XCTestCase { try await ContactPaymentsService.setEnabled( false, contactPublicKeys: [publicKey], canUsePrivatePayments: true, operations: .init( - syncPaykitApp: { _ in XCTFail("OFF must withdraw private lists before downgrading") }, syncPublicEndpoints: { publish in XCTAssertFalse(publish) XCTAssertEqual(registryUpdates, 1) diff --git a/BitkitTests/PublicPaykitServiceTests.swift b/BitkitTests/PublicPaykitServiceTests.swift index 3f2d68f18..071bcfff4 100644 --- a/BitkitTests/PublicPaykitServiceTests.swift +++ b/BitkitTests/PublicPaykitServiceTests.swift @@ -267,6 +267,51 @@ final class PublicPaykitServiceTests: XCTestCase { } } + @MainActor + func testPublicationSyncsAppOnceBeforeBuildingAndApplyingEndpoints() async throws { + let desiredEndpoints = [endpoint(.bitcoinOnchainP2wpkh, value: "bc1qaddress")] + var calls: [String] = [] + + try await PublicPaykitService.syncPublishedEndpoints( + publish: true, + buildEndpoints: { + calls.append("build") + return desiredEndpoints + }, + syncApp: { calls.append("app") }, + applyEndpoints: { + calls.append("apply") + XCTAssertEqual($0, desiredEndpoints) + } + ) + + XCTAssertEqual(calls, ["app", "build", "apply"]) + } + + @MainActor + func testPublicationSyncsAppWhenEndpointBuildFails() async { + var calls: [String] = [] + + do { + try await PublicPaykitService.syncPublishedEndpoints( + publish: true, + buildEndpoints: { + calls.append("build") + throw PublicPaykitError.walletNotReady + }, + syncApp: { calls.append("app") }, + applyEndpoints: { _ in XCTFail("Failed endpoint construction must not publish endpoints") } + ) + XCTFail("Expected endpoint construction to fail") + } catch { + guard case PublicPaykitError.walletNotReady = error else { + return XCTFail("Unexpected error: \(error)") + } + } + + XCTAssertEqual(calls, ["app", "build"]) + } + private func endpoint(_ methodId: PublicPaykitService.MethodId, value: String) -> PublicPaykitService.Endpoint { PublicPaykitService.Endpoint( methodId: methodId, From ee467cf00ee93b9ebd3bb8e2bf239fb01f5158d3 Mon Sep 17 00:00:00 2001 From: benk10 Date: Sun, 4 Oct 2026 20:11:01 -0500 Subject: [PATCH 42/68] Avoid duplicate publication during receive refresh --- Bitkit/ViewModels/WalletViewModel.swift | 6 +-- BitkitTests/WalletViewModelReceiveTests.swift | 45 +++++++++++++++++++ 2 files changed, 48 insertions(+), 3 deletions(-) diff --git a/Bitkit/ViewModels/WalletViewModel.swift b/Bitkit/ViewModels/WalletViewModel.swift index 1b7b1c4d3..83d7f98c9 100644 --- a/Bitkit/ViewModels/WalletViewModel.swift +++ b/Bitkit/ViewModels/WalletViewModel.swift @@ -1318,7 +1318,7 @@ class WalletViewModel: ObservableObject { private func refreshPaykitEndpointsAfterChannelAvailabilityChanged(reason: String, forceRefreshLightning: Bool = false) async { await refreshAndSyncState() - try? await refreshBip21(forceRefreshBolt11: forceRefreshLightning) + try? await refreshBip21(forceRefreshBolt11: forceRefreshLightning, syncPublicPaykit: false) guard isPaykitUIActive else { return } @@ -1382,7 +1382,7 @@ class WalletViewModel: ObservableObject { clearPublicPaykitBolt11() } - func refreshBip21(forceRefreshBolt11: Bool = false) async throws { + func refreshBip21(forceRefreshBolt11: Bool = false, syncPublicPaykit: Bool = true) async throws { // Get old payment ID and tags before refreshing (which may change payment ID) let oldPaymentId = await paymentId() var tagsToMigrate: [String] = [] @@ -1436,7 +1436,7 @@ class WalletViewModel: ObservableObject { // Persist metadata with migrated tags await persistPreActivityMetadata(tags: tagsToMigrate) - if isPaykitUIActive, sharesPublicPaykitEndpoints { + if syncPublicPaykit, isPaykitUIActive, sharesPublicPaykitEndpoints { do { try await PublicPaykitService.syncCurrentPublishedEndpoints(wallet: self) } catch { diff --git a/BitkitTests/WalletViewModelReceiveTests.swift b/BitkitTests/WalletViewModelReceiveTests.swift index e62c13eaf..4a4e233e9 100644 --- a/BitkitTests/WalletViewModelReceiveTests.swift +++ b/BitkitTests/WalletViewModelReceiveTests.swift @@ -24,4 +24,49 @@ final class WalletViewModelReceiveTests: XCTestCase { XCTAssertTrue(wallet.canCreateReceiveLightningInvoice(amountSats: 25000)) XCTAssertFalse(wallet.canCreateReceiveLightningInvoice(amountSats: 25001)) } + + func testReceiveRefreshCanSkipPublicSyncWithoutChangingReceiveDetails() async throws { + snapshotAppDefaultsDomain() + UserDefaults.standard.set(true, forKey: PaykitFeatureFlags.uiEnabledKey) + UserDefaults.standard.set(true, forKey: PublicPaykitService.publishingEnabledKey) + let wallet = ReceiveWalletSpy() + let address = "receive-\(UUID().uuidString)" + wallet.onchainAddress = address + wallet.bolt11 = "stale-invoice" + wallet.channels = [] + wallet.invoiceAmountSats = 1234 + wallet.invoiceNote = "test note" + let expectedBip21 = "bitcoin:\(address)?amount=0.00001234&message=test%20note" + + try await wallet.refreshBip21(syncPublicPaykit: false) + + XCTAssertEqual(wallet.bip21, expectedBip21) + XCTAssertTrue(wallet.bolt11.isEmpty) + XCTAssertEqual(wallet.metadataPersistenceCount, 1) + XCTAssertEqual(wallet.publicEndpointRefreshCount, 0) + + try await wallet.refreshBip21() + + XCTAssertEqual(wallet.bip21, expectedBip21) + XCTAssertEqual(wallet.metadataPersistenceCount, 2) + XCTAssertEqual(wallet.publicEndpointRefreshCount, 1) + } + + private final class ReceiveWalletSpy: WalletViewModel { + var metadataPersistenceCount = 0 + var publicEndpointRefreshCount = 0 + + override func persistPreActivityMetadata(tags: [String]) async { + metadataPersistenceCount += 1 + } + + override func refreshPublicPaykitEndpoints( + forceRefreshBolt11: Bool, + includeOnchain: Bool, + includeLightning: Bool + ) async throws -> (onchainAddress: String, bolt11: String) { + publicEndpointRefreshCount += 1 + throw PublicPaykitError.walletNotReady + } + } } From f2349c9333e511a4caa1b514edd23b0cfbb336a8 Mon Sep 17 00:00:00 2001 From: benk10 Date: Sun, 4 Oct 2026 21:29:52 -0500 Subject: [PATCH 43/68] Avoid redundant Paykit session and backup reads --- Bitkit/Services/PubkyService.swift | 54 ++++++++++--------- .../PaykitBackupStateTrackingTests.swift | 4 +- BitkitTests/PaykitSdkClientConfigTests.swift | 7 ++- 3 files changed, 37 insertions(+), 28 deletions(-) diff --git a/Bitkit/Services/PubkyService.swift b/Bitkit/Services/PubkyService.swift index 329b218e9..a4b7a2ab6 100644 --- a/Bitkit/Services/PubkyService.swift +++ b/Bitkit/Services/PubkyService.swift @@ -362,8 +362,9 @@ actor PaykitSdkService { private func initializeLocked() async throws { try await refreshPaykitKey(force: true) var sdk = try handle() + let status: IdentityStatus do { - _ = try await sdk.initialize() + status = try await sdk.initialize() } catch { invalidatePaykitKeyIfNeeded(after: error) guard try sessionProvider.canDeferStaleSession(error: error) else { throw error } @@ -373,24 +374,28 @@ actor PaykitSdkService { resetRuntime() do { sdk = try handle() - _ = try await sdk.initialize() + status = try await sdk.initialize() } catch { sessionProvider.resumeStoredSessionAccess() throw error } sessionProvider.resumeStoredSessionAccess() } - await publishAppIfLiveSessionAvailable(using: sdk) + await publishAppIfLiveSessionAvailable(using: sdk, status: status) } /// Keep credential reads and fallback activation atomic with sign-out and identity changes. func restorePersistedSession() async throws -> PubkyProfileManager.SessionInitializationResult { Task { await republishIdentityIfNeeded() } return try await operationLock.withLock { - try await initializeLocked() - return try await PubkyProfileManager.resolveSessionInitialization( - savedSessionSecret: Keychain.loadString(key: .paykitSession), - storedSecretKeyHex: PubkyProfileManager.activeSecretKeyHex(), + let savedSessionSecret = try Keychain.loadString(key: .paykitSession) + let storedSecretKeyHex = PubkyProfileManager.activeSecretKeyHex() + if savedSessionSecret == nil, storedSecretKeyHex == nil { + try await initializeLocked() + } + return await PubkyProfileManager.resolveSessionInitialization( + savedSessionSecret: savedSessionSecret, + storedSecretKeyHex: storedSecretKeyHex, importSession: { try await self.importSessionLocked(secret: $0).publicKey }, signInWithSecretKey: { try await self.signInLocked(secretKeyHex: $0).publicKey } ) @@ -724,7 +729,7 @@ actor PaykitSdkService { func syncPaykitApp(privatePaymentsEnabled: Bool) async throws { try await withStateRevisionTracking { sdk in - var capabilities = try await appCapabilities(using: sdk) + var capabilities = try await appCapabilities(for: sdk.identityStatus()) capabilities.privatePayments = capabilities.privatePayments && privatePaymentsEnabled _ = try await sdk.publishPaykitApp(displayName: "Bitkit", capabilities: capabilities) } @@ -1095,25 +1100,26 @@ actor PaykitSdkService { try? await readRevision() } let previousStateRevision = try? readStateRevision() - let result: Result + let result: T do { - result = try await .success(operation()) + result = try await operation() } catch { - result = .failure(error) + // A failed write can change remote state without advancing the local revision. + onSnapshot(nil) + await onChange() + throw error } let nextStateRevision = try? readStateRevision() - if case .success = result, - let previousStateRevision, + if let previousStateRevision, previousStateRevision == nextStateRevision, let previousRevision { onSnapshot(BackupStateSnapshot(stateRevision: previousStateRevision, backupRevision: previousRevision)) - return try result.get() + return result } - // An unconfirmed remote write can fail without advancing the local revision. let nextRevision = try? await readRevision() - if case .success = result, let stateRevision = try? readStateRevision(), let nextRevision { + if let stateRevision = try? readStateRevision(), let nextRevision { onSnapshot(BackupStateSnapshot(stateRevision: stateRevision, backupRevision: nextRevision)) } else { onSnapshot(nil) @@ -1121,7 +1127,7 @@ actor PaykitSdkService { if previousRevision == nil || nextRevision == nil || previousRevision != nextRevision { await onChange() } - return try result.get() + return result } private func markWalletBackupDataChanged() { @@ -1201,13 +1207,14 @@ actor PaykitSdkService { let keys: [KeychainEntryType] = [.paykitSession, .pubkySecretKey] let previousValues = try keys.map { try Keychain.load(key: $0) } let sdk: PaykitSdk + let status: IdentityStatus do { try persistSessionAccess(result.sessionAccess) sessionProvider.setLiveSessionAccess(result.sessionAccess) resetRuntime() try await refreshPaykitKey() sdk = try handle() - _ = try await sdk.initialize() + status = try await sdk.initialize() if result.sessionAccess.exportLocalSecretKey() != nil { _ = try await sdk.publishPaykitNoiseKeyAuthorization() } @@ -1232,13 +1239,13 @@ actor PaykitSdkService { if AdoptedPubkyReference.current != nil || result.sessionAccess.exportLocalSecretKey() == nil { SharedPubkyKeychain.removeAllOwn() } - await publishAppIfLiveSessionAvailable(using: sdk) + await publishAppIfLiveSessionAvailable(using: sdk, status: status) await republishIdentityIfNeeded(publicKey: result.publicKey) } - private func publishAppIfLiveSessionAvailable(using sdk: PaykitSdk) async { + private func publishAppIfLiveSessionAvailable(using sdk: PaykitSdk, status: IdentityStatus) async { do { - var capabilities = try await appCapabilities(using: sdk) + var capabilities = appCapabilities(for: status) guard capabilities.privatePayments else { return } capabilities.privatePayments = UserDefaults.standard.bool(forKey: PrivatePaykitService.publishingEnabledKey) _ = try await sdk.publishPaykitApp(displayName: "Bitkit", capabilities: capabilities) @@ -1248,9 +1255,8 @@ actor PaykitSdkService { } } - private func appCapabilities(using sdk: PaykitSdk) async throws -> Paykit.PaykitAppCapabilities { - let status = try await sdk.identityStatus() - return Paykit.PaykitAppCapabilities( + private func appCapabilities(for status: IdentityStatus?) -> Paykit.PaykitAppCapabilities { + Paykit.PaykitAppCapabilities( privatePayments: status?.capability == .privateLinkCapable, paymentRequests: status?.capability == .privateLinkCapable, receipts: false, diff --git a/BitkitTests/PaykitBackupStateTrackingTests.swift b/BitkitTests/PaykitBackupStateTrackingTests.swift index f17202d68..81cc26ff8 100644 --- a/BitkitTests/PaykitBackupStateTrackingTests.swift +++ b/BitkitTests/PaykitBackupStateTrackingTests.swift @@ -121,7 +121,7 @@ final class PaykitBackupStateTrackingTests: XCTestCase { } } - func testFailedWriteChecksBackupDespiteUnchangedLocalRevision() async { + func testFailedWriteInvalidatesBackupWithoutAnotherRemoteRead() async { var snapshot: PaykitSdkService.BackupStateSnapshot? = .init(stateRevision: "state", backupRevision: "before") var reads = 0 var changes = 0 @@ -138,7 +138,7 @@ final class PaykitBackupStateTrackingTests: XCTestCase { } catch { XCTAssertEqual(error as? Failure, .operation) } - XCTAssertEqual(reads, 1) + XCTAssertEqual(reads, 0) XCTAssertEqual(changes, 1) XCTAssertNil(snapshot) } diff --git a/BitkitTests/PaykitSdkClientConfigTests.swift b/BitkitTests/PaykitSdkClientConfigTests.swift index 947342c82..6b5d2dbba 100644 --- a/BitkitTests/PaykitSdkClientConfigTests.swift +++ b/BitkitTests/PaykitSdkClientConfigTests.swift @@ -596,6 +596,7 @@ final class PaykitSdkClientConfigTests: XCTestCase { let result = try await recovery.value try await forget.value XCTAssertEqual(result, .restored(publicKey: bootstrap.result.publicKey)) + XCTAssertEqual(sdk.initializationCalls, 1) XCTAssertNil(try Keychain.load(key: .paykitSession)) XCTAssertNil(try Keychain.load(key: .pubkySecretKey)) let afterForget = try await service.restorePersistedSession() @@ -753,7 +754,7 @@ private final class CacheActivationSdk: PaykitSdk, @unchecked Sendable { if let initializationError { throw initializationError } - return IdentityStatus(publicKey: previousKey, capability: .signedOut) + return IdentityStatus(publicKey: previousKey, capability: capability) } override func contactRecords() async throws -> [ContactRecord] { @@ -826,6 +827,7 @@ private final class CacheActivationSession: PubkySessionAccess, @unchecked Senda private final class RecoverySdk: PaykitSdk, @unchecked Sendable { var onForget: () -> Void = {} + var initializationCalls = 0 override func identityStatus() async throws -> IdentityStatus? { IdentityStatus(publicKey: nil, capability: .signedOut) @@ -836,7 +838,8 @@ private final class RecoverySdk: PaykitSdk, @unchecked Sendable { } override func initialize() async throws -> IdentityStatus { - IdentityStatus(publicKey: nil, capability: .signedOut) + initializationCalls += 1 + return IdentityStatus(publicKey: nil, capability: .signedOut) } override func publishPaykitNoiseKeyAuthorization() async throws -> PaykitNoiseKeyAuthorization { From ec2071f554d64e222037be5f98d916535b44be7a Mon Sep 17 00:00:00 2001 From: benk10 Date: Sun, 4 Oct 2026 23:29:27 -0500 Subject: [PATCH 44/68] Skip linked-peer handshake advancement during drains --- .../PrivatePaykitService+Contacts.swift | 4 + BitkitTests/PrivatePaykitServiceTests.swift | 74 ++++++++++++++++--- 2 files changed, 69 insertions(+), 9 deletions(-) diff --git a/Bitkit/Services/PrivatePaykitService+Contacts.swift b/Bitkit/Services/PrivatePaykitService+Contacts.swift index f6c060a63..90c4dca13 100644 --- a/Bitkit/Services/PrivatePaykitService+Contacts.swift +++ b/Bitkit/Services/PrivatePaykitService+Contacts.swift @@ -624,8 +624,12 @@ extension PrivatePaykitService { guard !retryKeys.isEmpty, !Task.isCancelled else { return } let generation = preparationGeneration do { + let alreadyLinkedKeys = try await Set(operations.linkedPeers().filter { $0.state == .linked }.map { + PubkyPublicKeyFormat.normalized($0.counterparty) ?? $0.counterparty + }) for retryKey in retryKeys.sorted() { guard generation == preparationGeneration, !Task.isCancelled else { return } + if alreadyLinkedKeys.contains(retryKey) { continue } if let retryAt = unavailableLinkRetryAt[retryKey], retryAt > Date() { continue } do { try await operations.ensureLink(retryKey) diff --git a/BitkitTests/PrivatePaykitServiceTests.swift b/BitkitTests/PrivatePaykitServiceTests.swift index a77c13a43..be2b1419c 100644 --- a/BitkitTests/PrivatePaykitServiceTests.swift +++ b/BitkitTests/PrivatePaykitServiceTests.swift @@ -207,9 +207,8 @@ final class PrivatePaykitServiceTests: XCTestCase { return hasOutbound ? [publicKey] : [] }, linkedPeers: { - XCTAssertTrue(advanced) return [LinkedPeerRecord( - counterparty: publicKey, state: isLinked ? .linked : .linking, + counterparty: publicKey, state: advanced && isLinked ? .linked : .linking, lastSyncAt: nil, lastPrivateReceiveAt: nil, failureCount: 0, localRecoveryAttemptId: nil, localRecoveryMarkerCreatedAt: nil, localRecoveryMarkerLastError: nil, remoteRecoveryAttemptId: nil, remoteRecoveryMarkerObservedAt: nil @@ -232,6 +231,59 @@ final class PrivatePaykitServiceTests: XCTestCase { } } + func testPrivateMessageDrainSkipsLinkedPeersWithoutSkippingSendOrReceive() async { + let service = PrivatePaykitService() + let keys = ["first", "second"] + var advanced: [String] = [] + var sent: [String] = [] + var received: [String] = [] + for _ in 0 ..< 2 { + await service.drainPendingPrivateMessages(reason: "test", advancing: keys, operations: .init( + ensureLink: { advanced.append($0) }, + pendingOutbound: { keys }, + linkedPeers: { keys.map { self.drainPeer($0) } }, + processPending: { sent.append($0) }, + receive: { received.append($0) } + )) + } + XCTAssertEqual(advanced, []) + XCTAssertEqual(sent, keys + keys) + XCTAssertEqual(received, keys + keys) + } + + func testPrivateMessageDrainAdvancesRecoveryDetectedDuringSendOnNextPass() async { + let service = PrivatePaykitService() + let publicKey = "peer" + var state = LinkedPeerState.linked + var advances = 0 + var sends = 0 + var receives = 0 + let operations = PrivatePaykitService.PrivateMessageDrainOperations( + ensureLink: { _ in + XCTAssertEqual(state, .recoveryRequired) + advances += 1 + state = .linked + }, + pendingOutbound: { [publicKey] }, + linkedPeers: { [self.drainPeer(publicKey, state: state)] }, + processPending: { _ in + sends += 1 + if sends == 1 { + state = .recoveryRequired + throw PrivatePaykitError.privateUnavailable + } + }, + receive: { _ in receives += 1 } + ) + await service.drainPendingPrivateMessages(reason: "test", advancing: [publicKey], operations: operations) + XCTAssertEqual(advances, 0) + XCTAssertEqual(receives, 0) + await service.drainPendingPrivateMessages(reason: "test", advancing: [publicKey], operations: operations) + XCTAssertEqual(advances, 1) + XCTAssertEqual(sends, 2) + XCTAssertEqual(receives, 1) + } + func testPrivateMessageDrainDoesNotVisitUnrelatedPeers() async { let service = PrivatePaykitService() let selected = "selected" @@ -266,14 +318,14 @@ final class PrivatePaykitServiceTests: XCTestCase { receive: { received.append($0) } ) ) - XCTAssertEqual(advanced, [publicKey]) + XCTAssertTrue(advanced.isEmpty) XCTAssertEqual(sent, [publicKey]) XCTAssertEqual(received, [publicKey]) } } func testPrivateMessageDrainStopsAfterInvalidationAtEachSuspension() async throws { - for stage in ["link", "outbound", "send", "peers", "receive"] { + for stage in ["initialPeers", "link", "outbound", "send", "peers", "receive"] { let service = PrivatePaykitService() var events: [String] = [] let record: (String) async -> Void = { event in @@ -283,11 +335,15 @@ final class PrivatePaykitServiceTests: XCTestCase { await service.drainPendingPrivateMessages(reason: "test", advancing: ["first", "second"], operations: .init( ensureLink: { _ in await record("link") }, pendingOutbound: { await record("outbound"); return ["first", "second"] }, - linkedPeers: { await record("peers"); return [self.drainPeer("first"), self.drainPeer("second")] }, + linkedPeers: { + let isInitialRead = events.isEmpty + await record(isInitialRead ? "initialPeers" : "peers") + return isInitialRead ? [] : [self.drainPeer("first"), self.drainPeer("second")] + }, processPending: { _ in await record("send") }, receive: { _ in await record("receive") } )) - let order = ["link", "link", "outbound", "send", "send", "peers", "receive", "receive"] + let order = ["initialPeers", "link", "link", "outbound", "send", "send", "peers", "receive", "receive"] XCTAssertEqual(events, try Array(order.prefix(through: XCTUnwrap(order.firstIndex(of: stage)))), stage) } } @@ -319,7 +375,7 @@ final class PrivatePaykitServiceTests: XCTestCase { await service.drainPendingPrivateMessages(reason: "test", advancing: ["first", "second"], operations: .init( ensureLink: { _ in withUnsafeCurrentTask { $0?.cancel() } }, pendingOutbound: { XCTFail("Cancelled retry must not inspect outbound work"); return [] }, - linkedPeers: { XCTFail("Cancelled retry must not inspect peers"); return [] }, + linkedPeers: { XCTAssertFalse(Task.isCancelled); return [] }, processPending: { _ in XCTFail("Cancelled retry must not send") }, receive: { _ in XCTFail("Cancelled retry must not receive") } )) @@ -327,9 +383,9 @@ final class PrivatePaykitServiceTests: XCTestCase { await task.value } - private func drainPeer(_ publicKey: String) -> LinkedPeerRecord { + private func drainPeer(_ publicKey: String, state: LinkedPeerState = .linked) -> LinkedPeerRecord { LinkedPeerRecord( - counterparty: publicKey, state: .linked, + counterparty: publicKey, state: state, lastSyncAt: nil, lastPrivateReceiveAt: nil, failureCount: 0, localRecoveryAttemptId: nil, localRecoveryMarkerCreatedAt: nil, localRecoveryMarkerLastError: nil, remoteRecoveryAttemptId: nil, remoteRecoveryMarkerObservedAt: nil From 89b1c244218d8c771f4ed4f34d12fd1f7d008b4a Mon Sep 17 00:00:00 2001 From: benk10 Date: Mon, 5 Oct 2026 02:54:16 -0500 Subject: [PATCH 45/68] Document foreground Paykit inbox polling --- journeys/payment-requests/README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/journeys/payment-requests/README.md b/journeys/payment-requests/README.md index c9140f193..f30540d07 100644 --- a/journeys/payment-requests/README.md +++ b/journeys/payment-requests/README.md @@ -31,11 +31,11 @@ That run established the issuer shapes captured by the fixture: lowercase `btc`, ## Foreground synchronization -Bitkit checks shared request state every 10 seconds while foregrounded and online. -Private-message intake, outbound retries, endpoint publication, and target discovery run on startup, +Bitkit checks the private inbox and shared request state every 10 seconds while foregrounded and online. +Outbound retries, endpoint publication, and target discovery also run on startup, explicit refreshes, and maintenance rounds after 30 seconds, then every 60 seconds. Maintenance uses elapsed time, including slow requests; polling remains serialized and does not -start catch-up rounds. New peer messages may wait for maintenance plus synchronization time. +start catch-up rounds. Incoming messages wait for the next poll plus synchronization time. ## Accepting install From f23151b3910a6dd4806d8a6a745f49810d2b89ca Mon Sep 17 00:00:00 2001 From: benk10 Date: Mon, 5 Oct 2026 05:06:32 -0500 Subject: [PATCH 46/68] fix: refresh Paykit requests after contact changes --- Bitkit/AppScene.swift | 2 +- .../PaykitPaymentRequestServiceTests.swift | 24 +++++++++++++++++++ .../definite-pre-broadcast-retry.xml | 2 +- .../delete-and-readd-contact.xml | 2 +- 4 files changed, 27 insertions(+), 3 deletions(-) diff --git a/Bitkit/AppScene.swift b/Bitkit/AppScene.swift index acc05ff66..e80dda265 100644 --- a/Bitkit/AppScene.swift +++ b/Bitkit/AppScene.swift @@ -468,7 +468,7 @@ struct AppScene: View { paykitPaymentRequestManager.updateSavedPublicKeys(publicKeys) Task { await PrivatePaykitService.shared.prepareSavedContacts(publicKeys, wallet: wallet) - await refreshIncomingPaykitPaymentRequests() + await refreshIncomingPaykitPaymentRequests(forceFresh: true) } } .onReceive(PaykitPaymentProofService.proofStateChangedPublisher) { diff --git a/BitkitTests/PaykitPaymentRequestServiceTests.swift b/BitkitTests/PaykitPaymentRequestServiceTests.swift index 4c86e76f9..032044619 100644 --- a/BitkitTests/PaykitPaymentRequestServiceTests.swift +++ b/BitkitTests/PaykitPaymentRequestServiceTests.swift @@ -63,6 +63,30 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { } } + func testFreshRefreshRereadsRequestsChangedAfterSnapshot() async throws { + let record = try paymentRequestRecord() + let sdk = PaymentRequestSdkMock(records: [record]) + let manager = paymentRequestManager(sdk: sdk) + await sdk.pauseNextPaymentRequestList() + let first = Task { await manager.refresh() } + try await waitUntil { await sdk.paymentRequestListIsPaused() } + + await sdk.setRecords([]) + let refreshStarted = expectation(description: "Request state refresh started") + let fresh = Task { + refreshStarted.fulfill() + await manager.refresh(forceFresh: true) + } + await fulfillment(of: [refreshStarted], timeout: 1) + await sdk.resumePaymentRequestList() + await first.value + await fresh.value + + XCTAssertTrue(manager.pendingRequests.isEmpty) + let snapshot = await sdk.snapshot() + XCTAssertEqual(snapshot.paymentRequestListCallCount, 2) + } + func testFreshRefreshRereadsProofStateChangedDuringNotificationSynchronization() async throws { actor InFlightPayments { var ids: Set diff --git a/journeys/payment-requests/definite-pre-broadcast-retry.xml b/journeys/payment-requests/definite-pre-broadcast-retry.xml index 3e5157553..2a5c23b1d 100644 --- a/journeys/payment-requests/definite-pre-broadcast-retry.xml +++ b/journeys/payment-requests/definite-pre-broadcast-retry.xml @@ -12,7 +12,7 @@ Swipe the send control (id "GRAB") Verify the send failure screen (id "SendFailure") appears Configure the same LNURL-pay invoice callback to succeed without publishing a new Paykit payment list - Tap Try Again (id "Retry") + Tap Try Again (id "Retry") without waiting for the next foreground request poll Verify the LNURL payment review appears again with 21,000 sats (id "ReviewAmount-primary" or "ReviewAmount-secondary", depending on the primary display setting) Swipe the send control (id "GRAB") Verify the payment success screen (id "SendSuccess") appears diff --git a/journeys/payment-requests/delete-and-readd-contact.xml b/journeys/payment-requests/delete-and-readd-contact.xml index debc8ab0c..ce519ff23 100644 --- a/journeys/payment-requests/delete-and-readd-contact.xml +++ b/journeys/payment-requests/delete-and-readd-contact.xml @@ -8,7 +8,7 @@ On the payer, open Contacts, open the requester's contact, choose Delete Contact, and confirm deletion Verify the requester is absent from Contacts when the deletion confirmation appears Leave Contacts and immediately reopen it, wait for the list refresh to finish, and verify the deleted contact does not reappear - Open Payment Requests and wait for the request list to refresh, and verify "Before deletion" is no longer listed + Open Payment Requests immediately after deletion, wait for the request list to refresh, and verify "Before deletion" is no longer listed without waiting for another foreground polling interval On the requester, send another Payment Request to the payer with the note "After deletion" On the payer, return Home and wait for two foreground request polling intervals Verify no Payment Request confirmation appears for "After deletion" and no payable request from the deleted contact appears in Payment Requests From 407c282e4f0ba0b742d41a82bd81376102af576d Mon Sep 17 00:00:00 2001 From: benk10 Date: Mon, 5 Oct 2026 06:24:42 -0500 Subject: [PATCH 47/68] fix: preserve Paykit cleanup and request presentation state --- Bitkit/MainNavView.swift | 12 ++++ Bitkit/Services/ContactPaymentsService.swift | 66 +++++++++++++++++++ .../PaykitReceivedPaymentContacts.swift | 8 ++- .../PrivatePaykitService+Contacts.swift | 7 +- Bitkit/Services/PubkyService.swift | 17 ++++- Bitkit/Views/Settings/DevSettingsView.swift | 54 +++------------ BitkitTests/ContactPaymentsServiceTests.swift | 38 +++++++++-- BitkitTests/PaykitContactLifecycleTests.swift | 43 +++++++++++- .../PaykitReceivedPaymentContactsTests.swift | 28 +++++++- BitkitTests/PrivatePaykitServiceTests.swift | 19 ++++-- .../issuer-interoperability.xml | 2 + .../requested-resolution-failure.xml | 2 + 12 files changed, 229 insertions(+), 67 deletions(-) diff --git a/Bitkit/MainNavView.swift b/Bitkit/MainNavView.swift index 4a4d264e2..c4298fd7e 100644 --- a/Bitkit/MainNavView.swift +++ b/Bitkit/MainNavView.swift @@ -351,6 +351,18 @@ struct MainNavView: View { TabBar() .ignoresSafeArea(.keyboard) DrawerView() + + if paykitPaymentRequestManager.requestedPresentationId != nil, + sheets.activeSheetConfiguration == nil, + !sheets.isReplacingSheet + { + ActivityIndicator() + .padding(24) + .background(Color.gray6, in: RoundedRectangle(cornerRadius: 8)) + .accessibilityLabel(t("wallet__payment_request")) + .accessibilityIdentifier("PaymentRequestPreparing") + .allowsHitTesting(false) + } } .onChange(of: scenePhase) { _, newPhase in if newPhase == .active { diff --git a/Bitkit/Services/ContactPaymentsService.swift b/Bitkit/Services/ContactPaymentsService.swift index 15c9fb5b2..10f61db4a 100644 --- a/Bitkit/Services/ContactPaymentsService.swift +++ b/Bitkit/Services/ContactPaymentsService.swift @@ -159,6 +159,72 @@ enum ContactPaymentsService { } } + @MainActor + static func disablePaykitUI( + pubkyProfile: PubkyProfileManager, + operations: Operations, + defaults: UserDefaults = .standard + ) async throws -> Bool { + latestChange += 1 + let change = latestChange + let session = pubkyProfile.currentSession + let isChangeCurrent: ChangeCheck = { + Self.latestChange == change && pubkyProfile.currentSession == session && !defaults.bool(forKey: PaykitFeatureFlags.uiEnabledKey) + } + let hadPublicState = PaykitFeatureFlags.hasPublicPublishedState(defaults: defaults) || + defaults.bool(forKey: PublicPaykitService.cleanupPendingKey) + let hadPrivateState = PaykitFeatureFlags.hasPrivatePublishedState(defaults: defaults) || + defaults.bool(forKey: PrivatePaykitService.cleanupPendingKey) + + defaults.set(false, forKey: PaykitFeatureFlags.uiEnabledKey) + defaults.set(false, forKey: confirmedPreferenceKey) + defaults.set(false, forKey: PrivatePaykitService.publishingEnabledKey) + defaults.set(false, forKey: PublicPaykitService.publishingEnabledKey) + defaults.removeObject(forKey: "publicPaykitBolt11") + defaults.removeObject(forKey: "publicPaykitBolt11PaymentHash") + defaults.removeObject(forKey: "publicPaykitBolt11ExpiresAt") + if hadPublicState { operations.setPublicCleanupPending(true) } + if hadPrivateState { operations.setPrivateCleanupPending(true) } + + await acquireOperation() + defer { releaseOperation() } + try Task.checkCancellation() + guard isChangeCurrent() else { return false } + + var cleanupError: Error? + if hadPublicState || defaults.bool(forKey: PublicPaykitService.cleanupPendingKey) { + operations.setPublicCleanupPending(true) + do { + try await operations.syncPublicEndpoints(false, isChangeCurrent) + try Task.checkCancellation() + guard isChangeCurrent() else { return false } + operations.setPublicCleanupPending(false) + } catch { + try Task.checkCancellation() + if error is CancellationError { throw error } + guard isChangeCurrent() else { return false } + cleanupError = error + } + } + + if hadPrivateState || defaults.bool(forKey: PrivatePaykitService.cleanupPendingKey) { + operations.setPrivateCleanupPending(true) + do { + try await operations.removePrivateEndpoints(isChangeCurrent) + try Task.checkCancellation() + guard isChangeCurrent() else { return false } + operations.setPrivateCleanupPending(false) + } catch { + try Task.checkCancellation() + if error is CancellationError { throw error } + guard isChangeCurrent() else { return false } + cleanupError = cleanupError ?? error + } + } + if let cleanupError { throw cleanupError } + return isChangeCurrent() + } + @MainActor static func reconcilePendingEndpoints(_ reconcile: () async -> Void) async { guard !isOperationActive, !Task.isCancelled else { return } diff --git a/Bitkit/Services/PaykitReceivedPaymentContacts.swift b/Bitkit/Services/PaykitReceivedPaymentContacts.swift index c5ff3ca73..059738f0c 100644 --- a/Bitkit/Services/PaykitReceivedPaymentContacts.swift +++ b/Bitkit/Services/PaykitReceivedPaymentContacts.swift @@ -28,11 +28,15 @@ struct PaykitReceivedPaymentContacts: Equatable { else { continue } if method.onchainNetwork == network { - guard let address = try? validateBitcoinAddress(address: payload.value) else { continue } + var value = payload.value + if value == value.uppercased(), value.hasPrefix("BC1") || value.hasPrefix("TB1") || value.hasPrefix("BCRT1") { + value = value.lowercased() + } + guard let address = try? validateBitcoinAddress(address: value) else { continue } let addressNetwork = NetworkValidationHelper.convertNetworkType(address.network) // Test networks share legacy address encodings; the endpoint identifies the intended network. guard addressNetwork == network || (addressNetwork == .testnet && (network == .signet || network == .regtest)) else { continue } - onchain[payload.value, default: []].insert(counterparty) + onchain[address.address, default: []].insert(counterparty) } else if method == .bitcoinLightningBolt11, let invoice = try? Bolt11Invoice.fromStr(invoiceStr: payload.value), invoice.currency() == Self.currency(for: network) diff --git a/Bitkit/Services/PrivatePaykitService+Contacts.swift b/Bitkit/Services/PrivatePaykitService+Contacts.swift index 9f8c60556..42880add0 100644 --- a/Bitkit/Services/PrivatePaykitService+Contacts.swift +++ b/Bitkit/Services/PrivatePaykitService+Contacts.swift @@ -259,16 +259,13 @@ extension PrivatePaykitService { private func removePublishedEndpointsLocked(for publicKeys: [String]?, operations: EndpointCleanupOperations) async throws { let peers = try await operations.linkedPeers() let linkedPublicKeys = Set(peers - .filter { $0.state != .notLinked } + .filter { $0.state == .linked || $0.state == .linking || $0.state == .recoveryRequired } .compactMap { PubkyPublicKeyFormat.normalized($0.counterparty) }) - let discoveredPublicKeys = peers - .filter { $0.state == .linked } - .compactMap { PubkyPublicKeyFormat.normalized($0.counterparty) } let publicKeys = publicKeys ?? normalizedSavedContactKeys(Array( Set(knownSavedContactKeys) .union(state.contacts.keys) .union(Self.pendingDeletedContactCleanupKeys()) - .union(discoveredPublicKeys) + .union(linkedPublicKeys) )) let cleanupKeys = privatePaymentListCleanupKeys(publicKeys, linkedPublicKeys: linkedPublicKeys) let publicKeySet = Set(publicKeys) diff --git a/Bitkit/Services/PubkyService.swift b/Bitkit/Services/PubkyService.swift index 603c536f0..d1919fcb3 100644 --- a/Bitkit/Services/PubkyService.swift +++ b/Bitkit/Services/PubkyService.swift @@ -855,7 +855,8 @@ actor PaykitSdkService { ) async throws -> PrivatePaymentListDeliveryReport? { guard !counterparties.isEmpty else { return nil } return try await withStateRevisionTracking { sdk in - let blockedPeers = try await sdk.linkedPeers().filter { $0.state == .blocked } + let peers = try await sdk.linkedPeers() + let blockedPeers = peers.filter { $0.state == .blocked } let updates = counterparties.filter { counterparty in !blockedPeers.contains { PubkyPublicKeyFormat.matches($0.counterparty, counterparty) } }.map { PrivatePaymentListReservationUpdateInput(counterparty: $0, reservations: []) } @@ -866,6 +867,20 @@ actor PaykitSdkService { { return nil } + for update in updates where peers.contains(where: { + $0.state == .recoveryRequired && PubkyPublicKeyFormat.matches($0.counterparty, update.counterparty) + }) { + do { + _ = try await sdk.ensureLinkWithPeer(counterparty: update.counterparty, maxAdvanceSteps: 1) + } catch { + try Task.checkCancellation() + if error is CancellationError { throw error } + Logger.warn( + "Failed to recover private Paykit link before withdrawal: \(PaykitResolutionFailureDiagnostics.reason(for: error))", + context: "PaykitSdkService" + ) + } + } return try await sdk.syncPrivatePaymentListsWithReservationsAndProcessOutbound( updates: updates, clearUnlistedLinkedPeers: false diff --git a/Bitkit/Views/Settings/DevSettingsView.swift b/Bitkit/Views/Settings/DevSettingsView.swift index 8ec54e4c4..e9b6c5789 100644 --- a/Bitkit/Views/Settings/DevSettingsView.swift +++ b/Bitkit/Views/Settings/DevSettingsView.swift @@ -3,9 +3,6 @@ import UIKit struct DevSettingsView: View { @AppStorage(PaykitFeatureFlags.uiEnabledKey) private var isPaykitUIEnabled = PaykitFeatureFlags.uiEnabledByDefault - @AppStorage(ContactPaymentsService.confirmedPreferenceKey) private var hasConfirmedPublicPaykitEndpoints = false - @AppStorage(PrivatePaykitService.publishingEnabledKey) private var sharesPrivatePaykitEndpoints = false - @AppStorage(PublicPaykitService.publishingEnabledKey) private var sharesPublicPaykitEndpoints = false @AppStorage(BoltzService.savingsSwapEnabledKey) private var isSavingsSwapEnabled = false @AppStorage(SubscriptionClock.offsetDaysKey) private var subscriptionClockOffsetDays = 0 @@ -13,6 +10,7 @@ struct DevSettingsView: View { @EnvironmentObject var activity: ActivityListViewModel @EnvironmentObject var feeEstimatesManager: FeeEstimatesManager @EnvironmentObject var notificationManager: PushNotificationManager + @EnvironmentObject var pubkyProfile: PubkyProfileManager @EnvironmentObject var session: SessionManager @EnvironmentObject var wallet: WalletViewModel @Environment(PaykitPaymentRequestManager.self) private var paymentRequests @@ -249,56 +247,21 @@ struct DevSettingsView: View { @MainActor private func disablePaykitUI() async { - let hadPublicPaykitState = PaykitFeatureFlags.hasPublicPublishedState() || - UserDefaults.standard.bool(forKey: PublicPaykitService.cleanupPendingKey) - let hadPrivatePaykitState = PaykitFeatureFlags.hasPrivatePublishedState() || - UserDefaults.standard.bool(forKey: PrivatePaykitService.cleanupPendingKey) - - isPaykitUIEnabled = false - hasConfirmedPublicPaykitEndpoints = false - sharesPrivatePaykitEndpoints = false - sharesPublicPaykitEndpoints = false - UserDefaults.standard.removeObject(forKey: "publicPaykitBolt11") - UserDefaults.standard.removeObject(forKey: "publicPaykitBolt11PaymentHash") - UserDefaults.standard.removeObject(forKey: "publicPaykitBolt11ExpiresAt") - - var cleanupError: Error? - if hadPublicPaykitState { - do { - try await PublicPaykitService.syncPublishedEndpoints(wallet: wallet, publish: false) - PublicPaykitService.setCleanupPending(false) - } catch { - cleanupError = error - PublicPaykitService.setCleanupPending(true) - Logger.warn("Failed to remove public Paykit endpoints after disabling Paykit UI: \(error)", context: "DevSettingsView") - } - } - - if hadPrivatePaykitState { - do { - try await PrivatePaykitService.shared.removePublishedEndpoints() - PrivatePaykitService.setContactSharingCleanupPending(false) - } catch { - if cleanupError == nil { - cleanupError = error - } - PrivatePaykitService.setContactSharingCleanupPending(true) - Logger.warn("Failed to remove private Paykit endpoints after disabling Paykit UI: \(error)", context: "DevSettingsView") - } - } - - if let cleanupError { + do { + guard try await ContactPaymentsService.disablePaykitUI(pubkyProfile: pubkyProfile, operations: .live(wallet: wallet)) else { return } + } catch is CancellationError { + return + } catch { + Logger.warn("Failed to remove Paykit endpoints after disabling Paykit UI: \(error)", context: "DevSettingsView") app.toast( type: .error, title: "Paykit UI disabled", - description: cleanupError.localizedDescription, + description: error.localizedDescription, accessibilityIdentifier: "PaykitUiDisabledToast" ) return } - PublicPaykitService.setCleanupPending(false) - PrivatePaykitService.setContactSharingCleanupPending(false) app.toast(type: .success, title: "Paykit UI disabled", accessibilityIdentifier: "PaykitUiDisabledToast") } } @@ -306,6 +269,7 @@ struct DevSettingsView: View { #Preview { DevSettingsView() .environmentObject(AppViewModel()) + .environmentObject(PubkyProfileManager()) .environmentObject(ActivityListViewModel()) .environmentObject(FeeEstimatesManager()) .environmentObject(NavigationViewModel()) diff --git a/BitkitTests/ContactPaymentsServiceTests.swift b/BitkitTests/ContactPaymentsServiceTests.swift index f1a81388e..6dd3aa913 100644 --- a/BitkitTests/ContactPaymentsServiceTests.swift +++ b/BitkitTests/ContactPaymentsServiceTests.swift @@ -321,8 +321,14 @@ final class ContactPaymentsServiceTests: XCTestCase { } func testEnablingWaitsForSuccessfulOrFailedDisable() async throws { - for removalFails in [false, true] { + let ownerKey = try useLocalPubkySecretKey() + for (disableUI, removalFails) in [(false, false), (false, true), (true, false), (true, true)] { try await withIsolatedDefaultsAsync { defaults in + defaults.set(true, forKey: PaykitFeatureFlags.uiEnabledKey) + defaults.set(true, forKey: PublicPaykitService.publishingEnabledKey) + defaults.set(true, forKey: PrivatePaykitService.publishingEnabledKey) + let pubkyProfile = signedInProfile(ownerKey: ownerKey) + let contactsManager = ContactsManager(contactRecords: { [] }) let operations = OperationsSpy() operations.privateRemovalFailures = removalFails ? [1] : [] let removalStarted = expectation(description: "Withdrawal started") @@ -335,14 +341,33 @@ final class ContactPaymentsServiceTests: XCTestCase { } let disable = Task { + if disableUI { + return try await ContactPaymentsService.disablePaykitUI( + pubkyProfile: pubkyProfile, operations: operations.makeOperations(defaults: defaults), defaults: defaults + ) + } try await ContactPaymentsService.setEnabled( false, contactPublicKeys: [], canUsePrivatePayments: true, operations: operations.makeOperations(defaults: defaults), defaults: defaults ) + return true } await fulfillment(of: [removalStarted], timeout: 2) + if disableUI { + XCTAssertFalse(defaults.bool(forKey: PaykitFeatureFlags.uiEnabledKey)) + XCTAssertFalse(defaults.bool(forKey: ContactPaymentsService.confirmedPreferenceKey)) + } let enable = Task { enableRequested.fulfill() + if disableUI { + defaults.set(true, forKey: PaykitFeatureFlags.uiEnabledKey) + let applied = try await ContactPaymentsService.setEnabled( + true, pubkyProfile: pubkyProfile, contactsManager: contactsManager, + operations: operations.makeOperations(defaults: defaults), defaults: defaults + ) + XCTAssertTrue(applied) + return + } try await ContactPaymentsService.setEnabled( true, contactPublicKeys: ["contact-a"], canUsePrivatePayments: true, operations: operations.makeOperations(defaults: defaults), defaults: defaults @@ -350,18 +375,21 @@ final class ContactPaymentsServiceTests: XCTestCase { } await fulfillment(of: [enableRequested], timeout: 2) XCTAssertFalse(ContactPaymentsService.isEnabled(defaults: defaults)) - XCTAssertEqual(operations.calls, ["private:remove"]) + XCTAssertEqual(operations.calls, disableUI ? ["public:false", "private:remove"] : ["private:remove"]) finishRemoval.finish() switch await disable.result { - case .success: - XCTAssertFalse(removalFails) + case let .success(applied): + XCTAssertEqual(applied, !disableUI) + if !disableUI { XCTAssertFalse(removalFails) } case let .failure(error): XCTAssertTrue(removalFails) XCTAssertEqual(error as? TestError, .operationFailed) } try await enable.value - XCTAssertEqual(operations.calls, ["private:remove", "public:false", "public:true", "private:publish"]) + let cleanupCalls = disableUI ? ["public:false", "private:remove"] : ["private:remove", "public:false"] + XCTAssertEqual(operations.calls, cleanupCalls + ["public:true", "private:publish"]) + XCTAssertTrue(defaults.bool(forKey: PaykitFeatureFlags.uiEnabledKey)) XCTAssertTrue(ContactPaymentsService.isEnabled(defaults: defaults)) XCTAssertFalse(defaults.bool(forKey: PrivatePaykitService.cleanupPendingKey)) XCTAssertFalse(defaults.bool(forKey: PublicPaykitService.cleanupPendingKey)) diff --git a/BitkitTests/PaykitContactLifecycleTests.swift b/BitkitTests/PaykitContactLifecycleTests.swift index e1b2437ac..1a3bbebba 100644 --- a/BitkitTests/PaykitContactLifecycleTests.swift +++ b/BitkitTests/PaykitContactLifecycleTests.swift @@ -248,6 +248,29 @@ final class PaykitContactLifecycleTests: XCTestCase { updates.map(\.counterparty) == [sdk.publicKey, other] && updates.allSatisfy(\.reservations.isEmpty) }) } + + func testWithdrawalRecoversPeerBeforeQueueingAndRetainsRecoveryFailure() async throws { + let sdk = ContactLifecycleSdk(noPointer: .init()) + var healthy = sdk.peers[0] + healthy.counterparty = "pubky5rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" + sdk.peers.append(healthy) + sdk.peers[0].state = .recoveryRequired + sdk.failRecovery = true + let service = PaykitSdkService(sdkFactory: { sdk }) + let keys = [sdk.publicKey, healthy.counterparty] + + let failed = try await service.clearPrivatePaymentLists(to: keys) + XCTAssertEqual(failed?.failedToQueue.map(\.counterparty), [sdk.publicKey]) + XCTAssertEqual(failed?.cleared.map(\.counterparty), [healthy.counterparty]) + XCTAssertEqual(sdk.peers[0].state, .recoveryRequired) + + sdk.failRecovery = false + let report = try await service.clearPrivatePaymentLists(to: keys) + XCTAssertEqual(sdk.peers[0].state, .linking) + XCTAssertEqual(report?.cleared.map(\.counterparty), keys) + XCTAssertTrue(report?.failedToQueue.isEmpty == true) + XCTAssertTrue(try XCTUnwrap(sdk.withdrawals.first?.first).reservations.isEmpty) + } } private final class ContactLifecycleSdk: PaykitSdk, @unchecked Sendable { @@ -259,6 +282,7 @@ private final class ContactLifecycleSdk: PaykitSdk, @unchecked Sendable { var failLinkedPeers = false var failUnblock = false var failSaveContact = false + var failRecovery = false var peerReads = 0 var identityReads = 0 var registryReads = 0 @@ -320,15 +344,30 @@ private final class ContactLifecycleSdk: PaykitSdk, @unchecked Sendable { return peers } + override func ensureLinkWithPeer(counterparty: String, maxAdvanceSteps: UInt32) async throws -> LinkedPeerHandshakeReport { + XCTAssertEqual(maxAdvanceSteps, 1) + if failRecovery { throw PubkyServiceError.sessionNotActive } + let index = try XCTUnwrap(peers.firstIndex { $0.counterparty == counterparty }) + XCTAssertEqual(peers[index].state, .recoveryRequired) + peers[index].state = .linking + return LinkedPeerHandshakeReport(counterparty: counterparty, state: .linking, generation: 1, handshakeRole: nil) + } + override func syncPrivatePaymentListsWithReservationsAndProcessOutbound( updates: [PrivatePaymentListReservationUpdateInput], clearUnlistedLinkedPeers: Bool ) async throws -> PrivatePaymentListDeliveryReport { XCTAssertFalse(clearUnlistedLinkedPeers) + let failed = updates.filter { update in + peers.contains { $0.counterparty == update.counterparty && $0.state == .recoveryRequired } + } withdrawals.append(updates) return .init( - queued: [], cleared: updates.map { .init(counterparty: $0.counterparty, outboundMessageId: 1, error: nil) }, - failedToQueue: [], failedToDeliver: [] + queued: [], + cleared: updates.filter { update in !failed.contains { $0.counterparty == update.counterparty } } + .map { .init(counterparty: $0.counterparty, outboundMessageId: 1, error: nil) }, + failedToQueue: failed.map { .init(counterparty: $0.counterparty, outboundMessageId: nil, error: nil) }, + failedToDeliver: [] ) } diff --git a/BitkitTests/PaykitReceivedPaymentContactsTests.swift b/BitkitTests/PaykitReceivedPaymentContactsTests.swift index 10fb882a9..44d6e104a 100644 --- a/BitkitTests/PaykitReceivedPaymentContactsTests.swift +++ b/BitkitTests/PaykitReceivedPaymentContactsTests.swift @@ -17,6 +17,28 @@ final class PaykitReceivedPaymentContactsTests: XCTestCase { ) } + func testRequestAddressNormalizationPreservesValidationAndAmbiguity() async throws { + let uppercase = receivingAddress.uppercased() + try await assertAttribution(contacts: contacts(address: uppercase, counterparty: alice), reservations: [:], expected: alice) + + for mixedCase in ["B" + receivingAddress.dropFirst(), "b" + uppercase.dropFirst()] { + XCTAssertTrue(try contacts(address: mixedCase, counterparty: alice).isEmpty) + } + + let legacy = "n31ZLqqyfoYu4fjd16u7ZQaSqgGfrmw8wC" + let legacyContacts = try PaykitReceivedPaymentContacts( + records: [record(address: legacy, counterparty: alice, identifier: "btc-regtest-p2pkh")], network: .regtest + ) + XCTAssertEqual(legacyContacts.contact(onchainAddresses: [legacy]), alice) + XCTAssertNil(legacyContacts.contact(onchainAddresses: [legacy.lowercased()])) + + let ambiguous = try PaykitReceivedPaymentContacts( + records: [record(address: uppercase, counterparty: alice), record(address: receivingAddress, counterparty: bob)], + network: .regtest + ) + try await assertAttribution(contacts: ambiguous, reservations: [receivingAddress: alice], expected: nil) + } + func testConflictingRequestsAndReservationsRejectLiveAndHistoryAttribution() async throws { let receivingRequest = try contacts(address: receivingAddress, counterparty: alice) let otherRequest = try contacts(address: otherAddress, counterparty: bob) @@ -253,7 +275,7 @@ final class PaykitReceivedPaymentContactsTests: XCTestCase { try PaykitReceivedPaymentContacts(records: [record(address: address, counterparty: counterparty)], network: .regtest) } - private func record(address: String, counterparty: String) throws -> PaymentRequestRecord { + private func record(address: String, counterparty: String, identifier: String = "btc-regtest-p2wpkh") throws -> PaymentRequestRecord { try PaymentRequestRecord( counterparty: counterparty, paymentRequestId: "550e8400-e29b-41d4-a716-446655440000", localRole: .payee, state: .proposed, proposalStreamItemId: 1, proposalOutboundMessageId: nil, proposalOutboundStatus: nil, @@ -261,8 +283,8 @@ final class PaykitReceivedPaymentContactsTests: XCTestCase { executionClaimAppId: nil, terms: PaymentRequestTerms( amount: PaymentRequestAmount(value: "0.001", asset: "btc"), paymentReference: PaymentReference(text: "invoice-123"), - proposalExpiresAt: nil, recurrence: nil, acceptedPaymentEndpointIdentifiers: ["btc-regtest-p2wpkh"], - paymentEndpoints: ["btc-regtest-p2wpkh": PublicPaykitService.serializePayload(value: address)], + proposalExpiresAt: nil, recurrence: nil, acceptedPaymentEndpointIdentifiers: [identifier], + paymentEndpoints: [identifier: PublicPaykitService.serializePayload(value: address)], requiredAppId: nil, conversion: nil, paymentDeadline: nil, metadata: PrivateJsonObject(text: "{}") ), acceptedEventId: nil, acceptedOutboundStatus: nil, rejectedEventId: nil, rejectedOutboundStatus: nil, diff --git a/BitkitTests/PrivatePaykitServiceTests.swift b/BitkitTests/PrivatePaykitServiceTests.swift index 969ea9774..3d514b0d2 100644 --- a/BitkitTests/PrivatePaykitServiceTests.swift +++ b/BitkitTests/PrivatePaykitServiceTests.swift @@ -1034,8 +1034,10 @@ final class PrivatePaykitServiceTests: XCTestCase { var recoveringPeer = peer recoveringPeer.counterparty = "pubky6rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" recoveringPeer.state = .recoveryRequired + let expectedKeys = [publicKey, linkingPeer.counterparty, recoveringPeer.counterparty] let service = PrivatePaykitService() var failLookup = true + var delivered = false var cleared = [String]() var registryUpdates = 0 let operations = PrivatePaykitService.EndpointCleanupOperations( @@ -1044,16 +1046,17 @@ final class PrivatePaykitServiceTests: XCTestCase { return [peer, linkingPeer, recoveringPeer] }, clearPaymentLists: { + XCTAssertEqual(Set($0), Set(expectedKeys)) cleared.append(contentsOf: $0) return PrivatePaymentListDeliveryReport( queued: [], cleared: $0.map { .init(counterparty: $0, outboundMessageId: 1, error: nil) }, failedToQueue: [], failedToDeliver: [] ) }, - drainMessages: { _ in XCTFail("Delivered withdrawal needs no drain") }, - pendingDrainKeys: { _ in [] }, + drainMessages: { XCTAssertEqual(Set($0), Set(expectedKeys.dropFirst())) }, + pendingDrainKeys: { _ in delivered ? [] : Set(expectedKeys.dropFirst()) }, syncApp: { - XCTAssertEqual(cleared, [publicKey]) + XCTAssertEqual(Set(cleared), Set(expectedKeys)) registryUpdates += 1 } ) @@ -1067,8 +1070,16 @@ final class PrivatePaykitServiceTests: XCTestCase { XCTAssertTrue(cleared.isEmpty) XCTAssertEqual(registryUpdates, 0) failLookup = false + do { + try await service.removePublishedEndpoints(operations: operations) + XCTFail("Pending recovery must keep cleanup pending") + } catch { + XCTAssertTrue(PublicPaykitService.isCleanupPending) + } + XCTAssertEqual(registryUpdates, 0) + delivered = true try await service.removePublishedEndpoints(operations: operations) - XCTAssertEqual(cleared, [publicKey]) + XCTAssertEqual(cleared.count, expectedKeys.count * 2) XCTAssertEqual(registryUpdates, 1) } diff --git a/journeys/payment-requests/issuer-interoperability.xml b/journeys/payment-requests/issuer-interoperability.xml index 78bb440a5..32a24274d 100644 --- a/journeys/payment-requests/issuer-interoperability.xml +++ b/journeys/payment-requests/issuer-interoperability.xml @@ -17,7 +17,9 @@ Tap the pending Payment Requests bell (id "PaymentRequestsBell") Verify the incoming Payment Requests sheet (id "PaymentRequestsSheet") appears Tap Pay (id "PaymentRequestPay-71300000-0000-4000-8000-000000000001") + If confirmation is not already open, verify progress (id "PaymentRequestPreparing") is visible while the request is being prepared Verify the Payment Request confirmation screen (id "PaymentRequestConfirm") shows 100,000 sats + Verify progress (id "PaymentRequestPreparing") is no longer visible Tap Show details (id "SendConfirmToggleDetails") Verify the recipient (id "ReviewContactRecipient") is the saved fixture issuer contact diff --git a/journeys/payment-requests/requested-resolution-failure.xml b/journeys/payment-requests/requested-resolution-failure.xml index 9c829c68b..6ce65f289 100644 --- a/journeys/payment-requests/requested-resolution-failure.xml +++ b/journeys/payment-requests/requested-resolution-failure.xml @@ -13,7 +13,9 @@ Verify the incoming request row (id "PaymentRequestRow-<payment-request-id>-one-time") is visible Tap Pay (id "PaymentRequestPay-<payment-request-id>") + Verify progress (id "PaymentRequestPreparing") is visible while the request is being prepared Wait up to 35 seconds for the terminal error toast (id "PaymentRequestUnavailableToast") + Verify progress (id "PaymentRequestPreparing") is no longer visible Verify the toast title is "Payment Request" and its description is "The payment request is no longer available." Verify Payment Requests (id "PaymentRequestsScreen") remains visible Verify the incoming request row (id "PaymentRequestRow-<payment-request-id>-one-time") remains visible for a later retry From 897740b18da41fe87ca8afc1268a1069dd8d3128 Mon Sep 17 00:00:00 2001 From: benk10 Date: Mon, 5 Oct 2026 09:53:02 -0500 Subject: [PATCH 48/68] fix: preserve Paykit presentation and queue ordering --- Bitkit/AppScene.swift | 2 +- Bitkit/MainNavView.swift | 4 +- Bitkit/Models/BackupPayloads.swift | 7 +- Bitkit/Services/CoreService.swift | 17 ++++ .../PaykitPaymentRequestService.swift | 7 +- Bitkit/Services/PubkyService.swift | 84 +++++++++++----- Bitkit/ViewModels/SettingsViewModel.swift | 4 +- .../PaykitPaymentRequestServiceTests.swift | 43 +++++++- .../PaykitReceivedPaymentContactsTests.swift | 28 +++++- BitkitTests/PaykitSdkOperationLockTests.swift | 97 +++++++++++++++++-- BitkitTests/PrivatePaykitServiceTests.swift | 10 +- changelog.d/next/856.changed.md | 2 +- .../automatic-presentation.xml | 4 + .../requested-resolution-failure.xml | 5 +- .../subscriptions/review-and-subscribe.xml | 1 + 15 files changed, 270 insertions(+), 45 deletions(-) diff --git a/Bitkit/AppScene.swift b/Bitkit/AppScene.swift index e80dda265..8e4af8f59 100644 --- a/Bitkit/AppScene.swift +++ b/Bitkit/AppScene.swift @@ -472,7 +472,7 @@ struct AppScene: View { } } .onReceive(PaykitPaymentProofService.proofStateChangedPublisher) { - Task { await refreshIncomingPaykitPaymentRequests(forceFresh: true) } + Task { await refreshIncomingPaykitPaymentRequests(mode: .stored, forceFresh: true) } } .onReceive(PaykitPaymentProofService.onchainPaymentResolutionPublisher) { resolution in Task { await associateResolvedPaykitOnchainPayment(resolution) } diff --git a/Bitkit/MainNavView.swift b/Bitkit/MainNavView.swift index c4298fd7e..96e0c7890 100644 --- a/Bitkit/MainNavView.swift +++ b/Bitkit/MainNavView.swift @@ -352,7 +352,9 @@ struct MainNavView: View { .ignoresSafeArea(.keyboard) DrawerView() - if paykitPaymentRequestManager.requestedPresentationId != nil, + if isPaykitUIActive, + pubkyProfile.authState == .authenticated, + paykitPaymentRequestManager.requestedPresentationId != nil, sheets.activeSheetConfiguration == nil, !sheets.isReplacingSheet { diff --git a/Bitkit/Models/BackupPayloads.swift b/Bitkit/Models/BackupPayloads.swift index b269f5557..2c9e26d83 100644 --- a/Bitkit/Models/BackupPayloads.swift +++ b/Bitkit/Models/BackupPayloads.swift @@ -85,6 +85,7 @@ struct AppCacheData: Codable { let lastUsedTags: [String] let quickPayLedger: QuickPayLedger? let blocktankRefundAddress: BlocktankRefundAddress? + let detachedActivityContacts: Set init( hasSeenContactsIntro: Bool, @@ -104,7 +105,8 @@ struct AppCacheData: Codable { dismissedSuggestions: [String], lastUsedTags: [String], quickPayLedger: QuickPayLedger? = nil, - blocktankRefundAddress: BlocktankRefundAddress? = nil + blocktankRefundAddress: BlocktankRefundAddress? = nil, + detachedActivityContacts: Set = [] ) { self.hasSeenContactsIntro = hasSeenContactsIntro self.hasSeenProfileIntro = hasSeenProfileIntro @@ -124,6 +126,7 @@ struct AppCacheData: Codable { self.lastUsedTags = lastUsedTags self.quickPayLedger = quickPayLedger self.blocktankRefundAddress = blocktankRefundAddress + self.detachedActivityContacts = detachedActivityContacts } init(from decoder: Decoder) throws { @@ -146,6 +149,7 @@ struct AppCacheData: Codable { lastUsedTags = try c.decodeIfPresent([String].self, forKey: .lastUsedTags) ?? [] quickPayLedger = try c.decodeIfPresent(QuickPayLedger.self, forKey: .quickPayLedger) blocktankRefundAddress = try c.decodeIfPresent(BlocktankRefundAddress.self, forKey: .blocktankRefundAddress) + detachedActivityContacts = try c.decodeIfPresent(Set.self, forKey: .detachedActivityContacts) ?? [] } private enum CodingKeys: String, CodingKey { @@ -156,6 +160,7 @@ struct AppCacheData: Codable { case dismissedSuggestions, lastUsedTags case quickPayLedger case blocktankRefundAddress + case detachedActivityContacts } } diff --git a/Bitkit/Services/CoreService.swift b/Bitkit/Services/CoreService.swift index 99b831b01..6a8d62e6c 100644 --- a/Bitkit/Services/CoreService.swift +++ b/Bitkit/Services/CoreService.swift @@ -46,9 +46,26 @@ class ActivityService { func setContactDetached(_ detached: Bool, activityId: String, walletId: String) { var entries = UserDefaults.standard.dictionary(forKey: Self.detachedContactsKey) as? [String: [String]] ?? [:] var ids = Set(entries[walletId] ?? []) + guard ids.contains(activityId) != detached else { return } if detached { ids.insert(activityId) } else { ids.remove(activityId) } entries[walletId] = ids.isEmpty ? nil : Array(ids) UserDefaults.standard.set(entries, forKey: Self.detachedContactsKey) + metadataChangedSubject.send() + } + + func detachedContactsBackupSnapshot() -> Set { + let entries = UserDefaults.standard.dictionary(forKey: Self.detachedContactsKey) as? [String: [String]] ?? [:] + return Set(entries.flatMap { walletId, ids in ids.map { "\(walletId):\($0)" } }) + } + + func restoreDetachedContacts(_ scopedIds: Set) { + var entries: [String: [String]] = [:] + for scopedId in scopedIds { + let parts = scopedId.split(separator: ":", maxSplits: 1, omittingEmptySubsequences: false) + guard parts.count == 2, !parts[0].isEmpty, !parts[1].isEmpty else { continue } + entries[String(parts[0]), default: []].append(String(parts[1])) + } + UserDefaults.standard.set(entries, forKey: Self.detachedContactsKey) } private let activitiesChangedSubject = PassthroughSubject() diff --git a/Bitkit/Services/PaykitPaymentRequestService.swift b/Bitkit/Services/PaykitPaymentRequestService.swift index 2c6d76104..f883ec1d8 100644 --- a/Bitkit/Services/PaykitPaymentRequestService.swift +++ b/Bitkit/Services/PaykitPaymentRequestService.swift @@ -887,7 +887,12 @@ struct PaykitPaymentRequestService { } func claimForPayment(_ request: PaykitPaymentRequest) async throws { - _ = try await sdk.claimPaymentRequestForExecution(counterparty: request.counterparty, paymentRequestId: request.paymentRequestId) + let record = try await sdk.claimPaymentRequestForExecution(counterparty: request.counterparty, paymentRequestId: request.paymentRequestId) + if let period = request.billingPeriod { + guard let subscription = PaykitSubscription(record: record), !subscription.paidPeriods.contains(period) else { + throw PaykitPaymentRequestError.requestUnavailable + } + } } func reject(_ request: PaykitPaymentRequest) async throws { diff --git a/Bitkit/Services/PubkyService.swift b/Bitkit/Services/PubkyService.swift index d1919fcb3..f07bfd332 100644 --- a/Bitkit/Services/PubkyService.swift +++ b/Bitkit/Services/PubkyService.swift @@ -824,7 +824,7 @@ actor PaykitSdkService { } func syncPublicEndpoints(_ endpoints: [PublicPaykitService.Endpoint]) async throws -> EndpointSyncReport { - try await withStateRevisionTracking { sdk in + try await withStateRevisionTracking(priority: endpoints.isEmpty ? .ordered : .background) { sdk in try await sdk.syncPublicEndpointsWithReceivingDetails(receivingDetails: endpoints.map(\.paykitPublicReceivingDetail)) } } @@ -833,7 +833,8 @@ actor PaykitSdkService { _ updates: [PrivatePaymentListReservationUpdateInput], clearUnlistedLinkedPeers: Bool ) async throws -> PrivatePaymentListDeliveryReport { - return try await withStateRevisionTracking { sdk in + let withdraws = clearUnlistedLinkedPeers || updates.contains { $0.reservations.isEmpty } + return try await withStateRevisionTracking(priority: withdraws ? .ordered : .background) { sdk in try await sdk.syncPrivatePaymentListsWithReservationsAndProcessOutbound( updates: updates, clearUnlistedLinkedPeers: clearUnlistedLinkedPeers @@ -963,7 +964,7 @@ actor PaykitSdkService { terms: Paykit.PaymentRequestTerms, expectedIdentity: String ) async throws -> Paykit.PaymentRequestRecord { - try await withStateRevisionTracking { sdk in + try await withStateRevisionTracking(priority: .interactive) { sdk in guard let identityStatus = try await sdk.identityStatus(), identityStatus.capability == .privateLinkCapable, PubkyPublicKeyFormat.matches(identityStatus.publicKey, expectedIdentity) @@ -1041,7 +1042,7 @@ actor PaykitSdkService { amount: PaymentAmountContext? = nil, afterPrivatePaymentListVersion: UInt64? ) async throws -> PreparedPrivateContactPayment { - try await withStateRevisionTracking { sdk in + try await withStateRevisionTracking(priority: .interactive) { sdk in try await sdk.prepareAndResolvePrivateContactPayment( counterparty: counterparty, amount: amount, @@ -1056,7 +1057,7 @@ actor PaykitSdkService { paymentRequestId: String, afterPrivatePaymentListVersion: UInt64? ) async throws -> PreparedPrivateContactPayment { - try await withStateRevisionTracking { sdk in + try await withStateRevisionTracking(priority: .interactive) { sdk in try await sdk.prepareAndResolvePrivatePaymentRequest( counterparty: counterparty, paymentRequestId: paymentRequestId, @@ -1067,7 +1068,7 @@ actor PaykitSdkService { } func resolvePublicContactPayment(counterparty: String) async throws -> PublicContactPaymentResolution { - try await operationLock.withLock { + try await operationLock.withLock(priority: .interactive) { try await handle().resolvePublicContactPayment(counterparty: counterparty, amount: nil) } } @@ -1164,8 +1165,11 @@ actor PaykitSdkService { } } - private func withSdk(_ operation: (PaykitSdk) async throws -> T) async throws -> T { - try await operationLock.withLock { + private func withSdk( + priority: PaykitSdkOperationLock.Priority = .ordered, + _ operation: (PaykitSdk) async throws -> T + ) async throws -> T { + try await operationLock.withLock(priority: priority) { try await withSdkErrorHandling { try await refreshPaykitKey() return try await operation(handle()) @@ -1189,8 +1193,11 @@ actor PaykitSdkService { cachedBackupState = nil } - private func withStateRevisionTracking(_ operation: (PaykitSdk) async throws -> T) async throws -> T { - try await withSdk { sdk in + private func withStateRevisionTracking( + priority: PaykitSdkOperationLock.Priority = .ordered, + _ operation: (PaykitSdk) async throws -> T + ) async throws -> T { + try await withSdk(priority: priority) { sdk in return try await Self.withBackupStateRevisionTracking( readRevision: { try await self.withSdkErrorHandling { try await sdk.backupStateRevision() } }, readStateRevision: { try sdk.stateRevision() }, @@ -1449,23 +1456,41 @@ actor PaykitSdkService { } final class PaykitSdkOperationLock: @unchecked Sendable { + enum Priority { + case ordered + case interactive + case background + } + private enum Waiter { - case uncancellable(CheckedContinuation) - case cancellable(UUID, CheckedContinuation) + case uncancellable(Priority, CheckedContinuation) + case cancellable(UUID, Priority, CheckedContinuation) + + var priority: Priority { + switch self { + case let .uncancellable(priority, _), let .cancellable(_, priority, _): priority + } + } } + private let maxInteractiveBypasses = 3 private let lock = NSLock() private var isLocked = false private var waiters: [Waiter] = [] + private var interactiveBypasses = 0 private var generation = 0 private var isWiping = false private var activeWipeID: UUID? @TaskLocal private static var walletWipeOwner: UUID? - func withLock(generation expectedGeneration: Int? = nil, _ operation: () async throws -> T) async throws -> T { + func withLock( + priority: Priority = .ordered, + generation expectedGeneration: Int? = nil, + _ operation: () async throws -> T + ) async throws -> T { if ownsWipe() { return try await operation() } let admittedGeneration = try admit() - await acquire() + await acquire(priority: priority) defer { release() } try validate(expectedGeneration ?? admittedGeneration) try Task.checkCancellation() @@ -1492,7 +1517,7 @@ final class PaykitSdkOperationLock: @unchecked Sendable { func withWalletWipe(_ operation: () async throws -> T) async throws -> T { let wipeID = try beginWipe() defer { endWipe() } - await acquire() + await acquire(priority: .ordered) defer { release() } try Task.checkCancellation() return try await Self.$walletWipeOwner.withValue(wipeID) { @@ -1553,11 +1578,11 @@ final class PaykitSdkOperationLock: @unchecked Sendable { return try await operation() } - private func acquire() async { + private func acquire(priority: Priority) async { await withCheckedContinuation { continuation in lock.lock() if isLocked { - waiters.append(.uncancellable(continuation)) + waiters.append(.uncancellable(priority, continuation)) lock.unlock() } else { isLocked = true @@ -1576,7 +1601,7 @@ final class PaykitSdkOperationLock: @unchecked Sendable { lock.unlock() continuation.resume(throwing: CancellationError()) } else if isLocked { - waiters.append(.cancellable(id, continuation)) + waiters.append(.cancellable(id, .ordered, continuation)) lock.unlock() } else { isLocked = true @@ -1593,10 +1618,10 @@ final class PaykitSdkOperationLock: @unchecked Sendable { lock.lock() defer { lock.unlock() } guard let index = waiters.firstIndex(where: { - if case let .cancellable(waiterID, _) = $0 { return waiterID == id } + if case let .cancellable(waiterID, _, _) = $0 { return waiterID == id } return false }), - case let .cancellable(_, continuation) = waiters.remove(at: index) + case let .cancellable(_, _, continuation) = waiters.remove(at: index) else { return nil } return continuation } @@ -1606,20 +1631,33 @@ final class PaykitSdkOperationLock: @unchecked Sendable { lock.lock() if waiters.isEmpty { isLocked = false + interactiveBypasses = 0 nextWaiter = nil } else { - nextWaiter = waiters.removeFirst() + // Ordered work is a barrier: priority must not cross identity changes, cleanup, or other mutations. + let interactiveIndex = waiters.prefix { $0.priority != .ordered }.firstIndex { $0.priority == .interactive } + let index = interactiveBypasses < maxInteractiveBypasses ? (interactiveIndex ?? waiters.startIndex) : waiters.startIndex + interactiveBypasses = index == waiters.startIndex ? 0 : interactiveBypasses + 1 + nextWaiter = waiters.remove(at: index) } lock.unlock() switch nextWaiter { - case let .uncancellable(continuation): + case let .uncancellable(_, continuation): continuation.resume() - case let .cancellable(_, continuation): + case let .cancellable(_, _, continuation): continuation.resume() case nil: break } } + + #if DEBUG + var waiterCountForTesting: Int { + lock.lock() + defer { lock.unlock() } + return waiters.count + } + #endif } /// Caps concurrent operations. A freed slot goes to the oldest interactive waiter, or to the oldest bulk waiter when no diff --git a/Bitkit/ViewModels/SettingsViewModel.swift b/Bitkit/ViewModels/SettingsViewModel.swift index 9bb48628f..214bda019 100644 --- a/Bitkit/ViewModels/SettingsViewModel.swift +++ b/Bitkit/ViewModels/SettingsViewModel.swift @@ -907,7 +907,8 @@ class SettingsViewModel: NSObject, ObservableObject { dismissedSuggestions: defaults.stringArray(forKey: "dismissedSuggestions") ?? [], lastUsedTags: defaults.stringArray(forKey: "lastUsedTags") ?? [], quickPayLedger: spend, - blocktankRefundAddress: refundAddress + blocktankRefundAddress: refundAddress, + detachedActivityContacts: CoreService.shared.activity.detachedContactsBackupSnapshot() ) } @@ -936,5 +937,6 @@ class SettingsViewModel: NSObject, ObservableObject { } else { refundAddressStore.clear() } + CoreService.shared.activity.restoreDetachedContacts(cache.detachedActivityContacts) } } diff --git a/BitkitTests/PaykitPaymentRequestServiceTests.swift b/BitkitTests/PaykitPaymentRequestServiceTests.swift index 032044619..af43f0dac 100644 --- a/BitkitTests/PaykitPaymentRequestServiceTests.swift +++ b/BitkitTests/PaykitPaymentRequestServiceTests.swift @@ -126,7 +126,7 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { let refreshStarted = expectation(description: "Proof state refresh started") let afterFailure = Task { refreshStarted.fulfill() - await manager.refresh(forceFresh: true) + await manager.refresh(mode: .stored, forceFresh: true) } await fulfillment(of: [refreshStarted], timeout: 1) await center.resumePendingRequests() @@ -136,6 +136,8 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { XCTAssertEqual(manager.pendingRequests.map(\.id), [requestId]) let snapshot = await sdk.snapshot() XCTAssertEqual(snapshot.paymentRequestListCallCount, 2) + XCTAssertEqual(snapshot.processCallCount, 1) + XCTAssertEqual(snapshot.receiveCallCount, 1) } func testClearInvalidatesStrongerRefreshWaitingForWeakerMode() async throws { @@ -1462,6 +1464,10 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { func insert(_ id: PaykitPaymentRequest.ID) { ids.insert(id) } + + func clear() { + ids = [] + } } let clock = try PaymentRequestTestClock(XCTUnwrap(PaykitPaymentRequest.parseDate("2027-01-15T08:00:00Z"))) @@ -1496,10 +1502,24 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { await manager.refresh() XCTAssertTrue(manager.pendingRequests.isEmpty) try await manager.ensurePaymentAllowed(firstPeriod) + await manager.finishPayment(firstPeriod) + await inFlightPayments.clear() + await manager.refresh(mode: .stored) + XCTAssertEqual(manager.pendingRequests.map(\.id), [firstPeriod.id]) record.paymentProofs = try [paymentProofRecord( endpoint: method.rawValue, kind: .onchain, billingPeriod: XCTUnwrap(firstPeriod.billingPeriod).sdkValue )] + record.paymentProofs[0].outboundStatus = .pending await sdk.setRecords([record]) + XCTAssertTrue(try XCTUnwrap(manager.subscriptions.first).paidPeriods.isEmpty) + do { + try await manager.prepareForPayment(firstPeriod) { + XCTFail("A paid billing period must not consume another payment destination") + } + XCTFail("A queued proof must prevent another payment before refreshing") + } catch { + XCTAssertEqual(error as? PaykitPaymentRequestError, .requestUnavailable) + } clock.advance(by: 31 * 24 * 60 * 60) await manager.refresh() @@ -2346,22 +2366,27 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { endsAt: nil ) let record = try paymentRequestRecord( - state: .activeRecurring, paymentDeadline: .periodStart(seconds: 3600), recurrence: recurrence + state: .activeRecurring, recurrence: recurrence ) let subscription = try XCTUnwrap(PaykitSubscription(record: record)) let request = try XCTUnwrap(subscription.requests(through: now, acceptedAt: PaykitPreciseInstant(date: now)).first) + let sdk = PaymentRequestSdkMock(records: [record]) let manager = paymentRequestManager( - sdk: PaymentRequestSdkMock(records: [record]), + sdk: sdk, clock: PaymentRequestTestClock(now), completedPaymentProofKinds: [request.id: .lightning] ) - await manager.refresh() + await manager.refresh(mode: .stored, forceFresh: true) XCTAssertTrue(manager.pendingRequests.isEmpty) + XCTAssertTrue(manager.requestsForPresentation().isEmpty) XCTAssertEqual(manager.historyRequests.first?.id, request.id) XCTAssertEqual(manager.historyRequests.first?.lifecycleState, .proofSubmitted) XCTAssertEqual(manager.historyRequests.first?.paymentProofKind, .lightning) + let snapshot = await sdk.snapshot() + XCTAssertEqual(snapshot.processCallCount, 0) + XCTAssertEqual(snapshot.receiveCallCount, 0) } func testCompletedOneTimePaymentAwaitingProofSubmissionKeepsPaymentProofKind() async throws { @@ -3326,6 +3351,10 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { let context = ContactPaymentContext(publicKey: request.counterparty, incomingPaymentRequest: request) XCTAssertTrue(app.claimContactPaymentContext(context)) + await sdk.pauseNextPaymentRequestList() + let refresh = Task { await manager.refresh() } + try await waitUntil { await sdk.paymentRequestListIsPaused() } + try await app.handleScannedData( "bitcoin:bcrt1q6rhpng9evdsfnn833a4f4vej0asu6dk5srld6x", claimedContactPaymentContext: context @@ -3348,6 +3377,12 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { XCTAssertNil(app.contactPaymentContext) XCTAssertEqual(manager.pendingRequests, [request]) XCTAssertTrue(manager.requestsForPresentation().isEmpty) + + await sdk.resumePaymentRequestList() + await refresh.value + XCTAssertTrue(manager.requestsForPresentation().isEmpty) + XCTAssertTrue(manager.requestPresentation(request)) + XCTAssertEqual(manager.requestsForPresentation(), [request]) } func testAmountMismatchIsVisibleAndStopsAutomaticPresentationUntilExplicitRetry() async throws { diff --git a/BitkitTests/PaykitReceivedPaymentContactsTests.swift b/BitkitTests/PaykitReceivedPaymentContactsTests.swift index 44d6e104a..156586cb4 100644 --- a/BitkitTests/PaykitReceivedPaymentContactsTests.swift +++ b/BitkitTests/PaykitReceivedPaymentContactsTests.swift @@ -193,14 +193,38 @@ final class PaykitReceivedPaymentContactsTests: XCTestCase { let afterSkippedScan = try await service.getActivity(id: activity.activityId) XCTAssertEqual(afterSkippedScan, persisted) - defer { service.setContactDetached(false, activityId: activity.activityId, walletId: WalletScope.default) } + let hardwareWalletId = "hardware-\(UUID().uuidString)" + defer { + service.setContactDetached(false, activityId: activity.activityId, walletId: WalletScope.default) + service.setContactDetached(false, activityId: activity.activityId, walletId: hardwareWalletId) + } + let backupRequired = expectation(description: "Detachment marks metadata backup required") + let metadataChanges = service.metadataChangedPublisher.sink { backupRequired.fulfill() } try await service.setContact(nil, forActivity: activity.activityId) - try await service.backfillReceivedPaykitContacts(contacts, identity: alice, cache: cache, reservations: reservations) { true } + await fulfillment(of: [backupRequired], timeout: 1) + metadataChanges.cancel() + service.setContactDetached(true, activityId: activity.activityId, walletId: hardwareWalletId) + let backup = try await MainActor.run { try JSONEncoder().encode(SettingsViewModel.shared.getAppCacheData()) } + let restored = try JSONDecoder().decode(AppCacheData.self, from: backup) + let defaultMarker = "\(WalletScope.default):\(activity.activityId)" + let hardwareMarker = "\(hardwareWalletId):\(activity.activityId)" + XCTAssertTrue(restored.detachedActivityContacts.isSuperset(of: [defaultMarker, hardwareMarker])) + XCTAssertTrue(try JSONDecoder().decode(AppCacheData.self, from: Data("{}".utf8)).detachedActivityContacts.isEmpty) + + service.setContactDetached(false, activityId: activity.activityId, walletId: WalletScope.default) + service.setContactDetached(false, activityId: activity.activityId, walletId: hardwareWalletId) + XCTAssertFalse(service.isContactDetached(activityId: activity.activityId, walletId: WalletScope.default)) + try await MainActor.run { try SettingsViewModel.shared.restoreAppCacheData(restored) } + XCTAssertTrue(service.isContactDetached(activityId: activity.activityId, walletId: WalletScope.default)) + XCTAssertTrue(service.isContactDetached(activityId: activity.activityId, walletId: hardwareWalletId)) + let restoredCache = PaykitReceivedPaymentBackfillCache(activityChanges: service.activitiesChangedPublisher) + try await service.backfillReceivedPaykitContacts(contacts, identity: alice, cache: restoredCache, reservations: reservations) { true } let detached = try await service.getActivity(id: activity.activityId) guard case let .onchain(detachedPayment) = detached else { return XCTFail("Expected the detached payment") } XCTAssertNil(detachedPayment.contact) try await service.setContact(bob, forActivity: activity.activityId) XCTAssertFalse(service.isContactDetached(activityId: activity.activityId, walletId: WalletScope.default)) + XCTAssertTrue(service.isContactDetached(activityId: activity.activityId, walletId: hardwareWalletId)) } func testUnavailableReservationsCannotCacheNegativeMatch() async { diff --git a/BitkitTests/PaykitSdkOperationLockTests.swift b/BitkitTests/PaykitSdkOperationLockTests.swift index e068b5471..290034730 100644 --- a/BitkitTests/PaykitSdkOperationLockTests.swift +++ b/BitkitTests/PaykitSdkOperationLockTests.swift @@ -11,6 +11,82 @@ final class PaykitSdkOperationLockTests: XCTestCase { } } + func testInteractiveWorkOvertakesOnlyQueuedPublicationWithBoundedFairness() async throws { + try await assertQueuedOrder( + priorities: [.background, .background, .interactive, .interactive, .interactive, .interactive], + expected: [2, 3, 4, 0, 5, 1] + ) + } + + func testInteractiveWorkCannotCrossAnOrderedOperation() async throws { + try await assertQueuedOrder( + priorities: [.background, .interactive, .ordered, .background, .interactive], + expected: [1, 0, 2, 4, 3] + ) + } + + func testCancelledInteractiveWorkDoesNotExecuteOrBlockPublication() async throws { + try await assertQueuedOrder( + priorities: [.background, .interactive], + expected: [0], + cancelIndex: 1 + ) + } + + private func assertQueuedOrder( + priorities: [PaykitSdkOperationLock.Priority], + expected: [Int], + cancelIndex: Int? = nil + ) async throws { + let lock = PaykitSdkOperationLock() + let recorder = Recorder() + let (gate, release) = AsyncStream.makeStream() + defer { release.finish() } + let started = expectation(description: "Active publication started") + let active = Task { + try await lock.withLock(priority: .background) { + await recorder.record("active") + started.fulfill() + for await _ in gate {} + } + } + await fulfillment(of: [started], timeout: 2) + + var queued: [Task] = [] + for (index, priority) in priorities.enumerated() { + queued.append(Task { + try await lock.withLock(priority: priority) { await recorder.record(String(index)) } + }) + await waitForWaiters(lock, count: index + 1) + } + let heldEvents = await recorder.events + XCTAssertEqual(heldEvents, ["active"], "Active publication must not be preempted") + if let cancelIndex { queued[cancelIndex].cancel() } + release.finish() + try await active.value + for (index, task) in queued.enumerated() { + if index == cancelIndex { + do { + try await task.value + XCTFail("Expected cancellation") + } catch is CancellationError {} + } else { + try await task.value + } + } + let events = await recorder.events + XCTAssertEqual(events, ["active"] + expected.map(String.init)) + try await lock.withLock {} + } + + private func waitForWaiters(_ lock: PaykitSdkOperationLock, count: Int) async { + let deadline = ContinuousClock.now.advanced(by: .seconds(2)) + while lock.waiterCountForTesting < count, ContinuousClock.now < deadline { + await Task.yield() + } + XCTAssertEqual(lock.waiterCountForTesting, count) + } + func testPublicCapabilityReadDoesNotWaitForSerializedSdkWork() async throws { let (gate, release) = AsyncStream.makeStream() defer { release.finish() } @@ -82,10 +158,13 @@ final class PaykitSdkOperationLockTests: XCTestCase { for await _ in activeStarted { break } - let queued = Task { - try await lock.withLock { await recorder.record("stale") } + var queued: [Task] = [] + for priority in [PaykitSdkOperationLock.Priority.background, .interactive] { + queued.append(Task { + try await lock.withLock(priority: priority) { await recorder.record("stale") } + }) + await waitForWaiters(lock, count: queued.count) } - try await Task.sleep(for: .milliseconds(50)) let wipe = Task { try await lock.withWalletWipe { try await lock.withLock { await recorder.record("cleanup") } @@ -106,11 +185,13 @@ final class PaykitSdkOperationLockTests: XCTestCase { for await _ in wipeStarted { break } - do { - try await queued.value - XCTFail("Expected queued work from the old wallet to be rejected") - } catch let PaykitError.Storage(code, _) { - XCTAssertEqual(code, "wallet_wipe_in_progress") + for task in queued { + do { + try await task.value + XCTFail("Expected queued work from the old wallet to be rejected") + } catch let PaykitError.Storage(code, _) { + XCTAssertEqual(code, "wallet_wipe_in_progress") + } } releaseWipe.yield() try await active.value diff --git a/BitkitTests/PrivatePaykitServiceTests.swift b/BitkitTests/PrivatePaykitServiceTests.swift index 3d514b0d2..aca8625f7 100644 --- a/BitkitTests/PrivatePaykitServiceTests.swift +++ b/BitkitTests/PrivatePaykitServiceTests.swift @@ -1054,7 +1054,15 @@ final class PrivatePaykitServiceTests: XCTestCase { ) }, drainMessages: { XCTAssertEqual(Set($0), Set(expectedKeys.dropFirst())) }, - pendingDrainKeys: { _ in delivered ? [] : Set(expectedKeys.dropFirst()) }, + pendingDrainKeys: { + PrivatePaykitService.pendingPrivateMessageDrainKeys( + Set($0), + linkedPeers: Dictionary(uniqueKeysWithValues: [peer, linkingPeer, recoveringPeer].map { + ($0.counterparty, delivered ? .linked : $0.state) + }), + pendingOutbound: [] + ) + }, syncApp: { XCTAssertEqual(Set(cleared), Set(expectedKeys)) registryUpdates += 1 diff --git a/changelog.d/next/856.changed.md b/changelog.d/next/856.changed.md index dcc96142d..b3b8590c2 100644 --- a/changelog.d/next/856.changed.md +++ b/changelog.d/next/856.changed.md @@ -1 +1 @@ -Share Paykit contacts and payment state with authorized apps while keeping wallet keys private, preserve disabled contact sharing during cleanup failures, reduce repeated shared-state reads, and label received payments with their Paykit payer contact. +Share Paykit contacts and payment state with authorized apps while keeping wallet keys private, preserve disabled contact sharing during cleanup failures, prevent paid subscription periods from being offered again, reduce repeated shared-state reads, and label received payments with their Paykit payer contact while preserving manual contact removal through backup and restore. diff --git a/journeys/payment-requests/automatic-presentation.xml b/journeys/payment-requests/automatic-presentation.xml index 526914be9..8d198c125 100644 --- a/journeys/payment-requests/automatic-presentation.xml +++ b/journeys/payment-requests/automatic-presentation.xml @@ -16,5 +16,9 @@ Close the confirmation without paying Open Receive and close it again Verify Home remains visible and neither reviewed request reopens automatically + On the requester, send a Payment Request above the payer's available balance with the note "Unaffordable request" + On the payer, verify an insufficient-balance toast appears once and no payment confirmation opens + Allow another pending-request sync, then open Receive and close it; verify the unaffordable request does not automatically prepare again or repeat its toast + Open Payment Requests and tap Pay on "Unaffordable request"; verify this explicit retry shows the insufficient-balance toast again diff --git a/journeys/payment-requests/requested-resolution-failure.xml b/journeys/payment-requests/requested-resolution-failure.xml index 6ce65f289..557461ca7 100644 --- a/journeys/payment-requests/requested-resolution-failure.xml +++ b/journeys/payment-requests/requested-resolution-failure.xml @@ -2,7 +2,7 @@ Verifies a user explicitly opening an incoming Payment Request receives localized terminal feedback after resolution retries exhaust, while the request remains available for another - attempt. + attempt. Dev settings must be available for the final availability check. Precondition: onboarded dev wallet with Paykit UI enabled, a profile, and one linked saved contact. Seed exactly one proposed incoming Payment Request from that contact with a known @@ -19,5 +19,8 @@ Verify the toast title is "Payment Request" and its description is "The payment request is no longer available." Verify Payment Requests (id "PaymentRequestsScreen") remains visible Verify the incoming request row (id "PaymentRequestRow-<payment-request-id>-one-time") remains visible for a later retry + Start another Pay attempt and verify progress (id "PaymentRequestPreparing") appears + While preparation is pending, navigate to Dev settings and turn off Enable Paykit UI (id "PaykitUiToggle") + Return to the wallet home screen and verify progress (id "PaymentRequestPreparing") is absent and no payment sheet opens diff --git a/journeys/subscriptions/review-and-subscribe.xml b/journeys/subscriptions/review-and-subscribe.xml index a3cd80e22..05caab154 100644 --- a/journeys/subscriptions/review-and-subscribe.xml +++ b/journeys/subscriptions/review-and-subscribe.xml @@ -27,6 +27,7 @@ If the first period was due on acceptance: complete the send flow that opens, then verify the Subscribed confirmation appears If no payment was due: verify the Subscribed confirmation appears directly, with no send flow Tap Close on the Subscribed confirmation + If a payment was made: verify its billing period is no longer offered with Pay or Dismiss, no Payment Requests sheet opens for it, and the home payment-request bell is absent when no other requests are pending Verify the sheet dismisses and "Journey Sub" moves from PROPOSALS into the ACTIVE section Tap the "Journey Sub" row and verify the detail shows STATUS "Active" and a RENEWS date one month ahead, with no year Only when a payment was made: verify the PAYMENTS section lists it titled "Journey Sub" with the payment date as its subtitle. With no payment yet, the section is absent. From cd921acf13c303a90300451ca7e9085a6807614b Mon Sep 17 00:00:00 2001 From: benk10 Date: Mon, 5 Oct 2026 11:54:47 -0500 Subject: [PATCH 49/68] fix: preserve hardware wallet contact detachment on restore --- Bitkit/Services/CoreService.swift | 8 +++++--- BitkitTests/PaykitReceivedPaymentContactsTests.swift | 2 +- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/Bitkit/Services/CoreService.swift b/Bitkit/Services/CoreService.swift index 6a8d62e6c..a3cf80b79 100644 --- a/Bitkit/Services/CoreService.swift +++ b/Bitkit/Services/CoreService.swift @@ -61,9 +61,11 @@ class ActivityService { func restoreDetachedContacts(_ scopedIds: Set) { var entries: [String: [String]] = [:] for scopedId in scopedIds { - let parts = scopedId.split(separator: ":", maxSplits: 1, omittingEmptySubsequences: false) - guard parts.count == 2, !parts[0].isEmpty, !parts[1].isEmpty else { continue } - entries[String(parts[0]), default: []].append(String(parts[1])) + guard let separator = scopedId.lastIndex(of: ":") else { continue } + let walletId = String(scopedId[.. Date: Mon, 5 Oct 2026 12:22:38 -0500 Subject: [PATCH 50/68] fix: skip invoice refresh on Paykit request screens --- .../Views/Wallets/Receive/ReceiveSheet.swift | 10 +++ BitkitTests/ReceiveSheetSessionTests.swift | 74 +++++++++++++++++++ 2 files changed, 84 insertions(+) diff --git a/Bitkit/Views/Wallets/Receive/ReceiveSheet.swift b/Bitkit/Views/Wallets/Receive/ReceiveSheet.swift index d8be39ebd..a2cdce5e1 100644 --- a/Bitkit/Views/Wallets/Receive/ReceiveSheet.swift +++ b/Bitkit/Views/Wallets/Receive/ReceiveSheet.swift @@ -14,6 +14,15 @@ enum ReceiveRoute: Hashable { case paymentRequestAmount(PaykitPaymentRequestDraft, PaykitPaymentRequestTarget) case paymentRequestDetails(PaykitPaymentRequestDraft, PaykitPaymentRequestTarget) case paymentRequestSent(PaykitPaymentRequest) + + var requiresInvoiceRefresh: Bool { + switch self { + case .qr, .edit, .tag, .cjitAmount, .cjitConfirm, .cjitLearnMore, .cjitGeoBlocked: + return true + case .requestOrPay, .paymentRequestRecipient, .paymentRequestAmount, .paymentRequestDetails, .paymentRequestSent: + return false + } + } } struct ReceiveConfig { @@ -67,6 +76,7 @@ struct ReceiveSheet: View { wallet.invoiceAmountSats = 0 wallet.invoiceNote = "" tagManager.clearSelectedTags() + guard config.initialRoute.requiresInvoiceRefresh else { return } Task { // Reset tags for current payment ID before refreshing if let paymentId = await wallet.paymentId(), !paymentId.isEmpty { diff --git a/BitkitTests/ReceiveSheetSessionTests.swift b/BitkitTests/ReceiveSheetSessionTests.swift index b97b52a30..591e3ab18 100644 --- a/BitkitTests/ReceiveSheetSessionTests.swift +++ b/BitkitTests/ReceiveSheetSessionTests.swift @@ -1,4 +1,5 @@ @testable import Bitkit +import Paykit import XCTest @MainActor @@ -17,4 +18,77 @@ final class ReceiveSheetSessionTests: XCTestCase { XCTAssertNotNil(secondID) XCTAssertNotEqual(firstID, secondID) } + + func testPaykitRoutesDoNotRequireInvoiceRefresh() { + let draft = PaykitPaymentRequestDraft(amountSats: 1000, note: "Lunch", expiresAt: Date(timeIntervalSince1970: 86400)) + let target = PaykitPaymentRequestTarget(publicKey: "pubkycontact") + let routes: [ReceiveRoute] = [ + .requestOrPay(publicKey: target.publicKey), + .paymentRequestRecipient(draft), + .paymentRequestAmount(draft, target), + .paymentRequestDetails(draft, target), + .paymentRequestSent(sentRequest(draft: draft, target: target)), + ] + + for route in routes { + XCTAssertFalse(route.requiresInvoiceRefresh, "\(route)") + } + } + + func testReceiveRoutesRequireInvoiceRefresh() { + let routes: [ReceiveRoute] = [ + .qr(cjitInvoice: nil, tab: nil), + .qr(cjitInvoice: "cjit-invoice", tab: .spending), + .qr(cjitInvoice: nil, tab: .trezor), + .edit(tab: .spending, onchainOnly: false), + .edit(tab: .trezor, onchainOnly: true), + .tag, + .cjitAmount, + .cjitConfirm(entry: .mock(), receiveAmountSats: 1000, isAdditional: false), + .cjitLearnMore(entry: .mock(), receiveAmountSats: 1000, isAdditional: false), + .cjitGeoBlocked, + ] + + for route in routes { + XCTAssertTrue(route.requiresInvoiceRefresh, "\(route)") + } + } + + private func sentRequest(draft: PaykitPaymentRequestDraft, target: PaykitPaymentRequestTarget) -> PaykitPaymentRequest { + let record = PaymentRequestRecord( + counterparty: target.publicKey, + paymentRequestId: "550e8400-e29b-41d4-a716-446655440000", + localRole: .payee, + state: .proposed, + proposalStreamItemId: nil, + proposalOutboundMessageId: nil, + proposalOutboundStatus: nil, + proposalEventId: nil, + proposalAppId: "bitkit", + payerAppId: nil, + executionClaimAppId: nil, + terms: nil, + acceptedEventId: nil, + acceptedOutboundStatus: nil, + rejectedEventId: nil, + rejectedOutboundStatus: nil, + canceledEventId: nil, + canceledOutboundStatus: nil, + conversionQuotes: [], + paymentProofs: [], + lastStreamItemId: nil, + lastOutboundMessageId: nil, + lastOutboundStatus: nil, + lastEventAt: nil, + invalidReason: nil + ) + return PaykitPaymentRequest( + createdRecord: record, + draft: draft, + target: target, + acceptedPaymentEndpointIdentifiers: ["btc-regtest-p2wpkh"], + deliveryStatus: .sent, + createdAt: Date(timeIntervalSince1970: 0) + ) + } } From 2de4fd5b0d9dceafc5ee2dc58efcd5fe444e2c70 Mon Sep 17 00:00:00 2001 From: benk10 Date: Mon, 5 Oct 2026 15:30:37 -0500 Subject: [PATCH 51/68] fix: reconcile shared proposal delivery status --- .../PaykitPaymentRequestService.swift | 40 +++- Bitkit/Services/PubkyService.swift | 11 +- .../PaykitPaymentRequestServiceTests.swift | 197 ++++++++++++++++-- changelog.d/next/856.changed.md | 2 +- journeys/subscriptions/create-and-propose.xml | 2 +- 5 files changed, 218 insertions(+), 34 deletions(-) diff --git a/Bitkit/Services/PaykitPaymentRequestService.swift b/Bitkit/Services/PaykitPaymentRequestService.swift index f883ec1d8..c66804ffd 100644 --- a/Bitkit/Services/PaykitPaymentRequestService.swift +++ b/Bitkit/Services/PaykitPaymentRequestService.swift @@ -496,6 +496,7 @@ protocol PaykitPaymentRequestSdkHandling: Sendable { func receivePrivateMessagesFromLinkedPeers() async throws -> [Paykit.PrivateStreamCounterpartyIntakeReport] func paymentRequests() async throws -> [Paykit.PaymentRequestRecord] func sharedPaymentRequests() async throws -> [Paykit.PaymentRequestRecord] + func sharedPaymentRequests(expectedIdentity: String) async throws -> [Paykit.PaymentRequestRecord] func identityStatus() async throws -> Paykit.IdentityStatus? func linkedPeers() async throws -> [Paykit.LinkedPeerRecord] func canReceivePaymentRequests(publicKey: String, priority: PaykitPublicReadPriority) async throws -> Bool @@ -738,8 +739,8 @@ struct PaykitPaymentRequestService { terms: terms, expectedIdentity: expectedIdentity ) - let reports = await (try? processPendingMessages(to: target.publicKey)) ?? [] - let deliveryStatus = proposalWasSent(record, reports: reports) ? PaykitPaymentRequest.DeliveryStatus.sent : .queued + let wasSent = await proposalWasSent(record, to: target.publicKey, expectedIdentity: expectedIdentity) + let deliveryStatus = wasSent ? PaykitPaymentRequest.DeliveryStatus.sent : .queued return PaykitPaymentRequest( createdRecord: record, draft: draft, @@ -814,8 +815,7 @@ struct PaykitPaymentRequestService { terms: terms, expectedIdentity: expectedIdentity ) - let reports = await (try? processPendingMessages(to: target.publicKey)) ?? [] - let deliveryStatus = proposalWasSent(record, reports: reports) + let deliveryStatus = await proposalWasSent(record, to: target.publicKey, expectedIdentity: expectedIdentity) ? PaykitPaymentRequest.DeliveryStatus.sent : .queued guard let subscription = PaykitSubscription(record: record, deliveryStatusOverride: deliveryStatus) else { @@ -1022,15 +1022,41 @@ struct PaykitPaymentRequestService { private func proposalWasSent( _ record: Paykit.PaymentRequestRecord, - reports: [Paykit.OutboundPrivateCounterpartySendReport] - ) -> Bool { + to counterparty: String, + expectedIdentity: String + ) async -> Bool { + let reports: [Paykit.OutboundPrivateCounterpartySendReport] + do { + reports = try await processPendingMessages(to: counterparty) + } catch { + return record.proposalOutboundStatus == .sent + } if case .sent? = record.proposalOutboundStatus { return true } guard let messageId = record.proposalOutboundMessageId else { return false } - return reports.contains { report in + if reports.contains(where: { report in PubkyPublicKeyFormat.matches(report.counterparty, record.counterparty) && report.report?.sent.contains(messageId) == true + }) { + return true + } + guard !Task.isCancelled else { return false } + do { + let records = try await sdk.sharedPaymentRequests(expectedIdentity: expectedIdentity) + guard !Task.isCancelled else { return false } + return records.contains { + PubkyPublicKeyFormat.matches($0.counterparty, record.counterparty) && + $0.paymentRequestId == record.paymentRequestId && + $0.localRole == .payee && + $0.proposalOutboundMessageId == messageId && + $0.proposalOutboundStatus == .sent + } + } catch is CancellationError { + return false + } catch { + logWarning("Failed to read Paykit proposal delivery status: \(error)") + return false } } diff --git a/Bitkit/Services/PubkyService.swift b/Bitkit/Services/PubkyService.swift index f07bfd332..29f4a1717 100644 --- a/Bitkit/Services/PubkyService.swift +++ b/Bitkit/Services/PubkyService.swift @@ -927,6 +927,13 @@ actor PaykitSdkService { } } + func sharedPaymentRequests(expectedIdentity: String) async throws -> [Paykit.PaymentRequestRecord] { + try await withSdk { sdk in + try await Self.requireSignedInIdentity(expectedIdentity, in: sdk) + return try await sdk.paymentRequests() + } + } + nonisolated static func isBitkitPaymentRequest(_ record: Paykit.PaymentRequestRecord) -> Bool { switch record.localRole { case .payee: @@ -1396,8 +1403,8 @@ actor PaykitSdkService { } /// Throws `identityChanged` unless `expectedIdentity` is the identity `sdk` is signed in as. Run it inside the locked - /// operation of the write it guards: sign-in and sign-out also take `operationLock`, so neither can land between this - /// check and the write. + /// operation it guards: sign-in and sign-out also take `operationLock`, so neither can land between this + /// check and the guarded read or write. private nonisolated static func requireSignedInIdentity(_ expectedIdentity: String, in sdk: PaykitSdk) async throws { let signedInIdentity = try await sdk.identityStatus()?.publicKey guard PubkyPublicKeyFormat.matches(signedInIdentity, expectedIdentity) else { diff --git a/BitkitTests/PaykitPaymentRequestServiceTests.swift b/BitkitTests/PaykitPaymentRequestServiceTests.swift index d7d681bea..0f85f47df 100644 --- a/BitkitTests/PaykitPaymentRequestServiceTests.swift +++ b/BitkitTests/PaykitPaymentRequestServiceTests.swift @@ -5918,9 +5918,138 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { XCTAssertEqual(snapshot.proposedRequests.map(\.counterparty), [publicKey]) XCTAssertEqual(snapshot.processedCounterparties, [publicKey]) XCTAssertEqual(snapshot.processCallCount, 0) + XCTAssertEqual(snapshot.paymentRequestListCallCount, 0) } } + func testProposalsUseFreshDeliveryStatusOnlyForTheCommittedProposal() async throws { + let publicKey = "pubky\(String(repeating: "y", count: 52))" + let expectedIdentity = "pubky\(String(repeating: "z", count: 52))" + let now = Date(timeIntervalSince1970: 1_800_000_000) + let expiresAt = now.addingTimeInterval(60) + let record = try paymentRequestRecord(counterparty: publicKey, role: .payee, proposalOutboundMessageId: 7) + var sent = record + sent.proposalOutboundStatus = .sent + var otherMessage = sent + otherMessage.proposalOutboundMessageId = 8 + var otherRequest = sent + otherRequest.paymentRequestId = "another-request" + var otherPeer = sent + otherPeer.counterparty = expectedIdentity + var otherRole = sent + otherRole.localRole = .payer + let cases: [(PaymentRequestRecord?, PaykitPaymentRequest.DeliveryStatus)] = [ + (sent, .sent), (record, .queued), (nil, .queued), (otherMessage, .queued), + (otherRequest, .queued), (otherPeer, .queued), (otherRole, .queued), + ] + + for isSubscription in [false, true] { + for (freshRecord, expectedStatus) in cases { + let sdk = PaymentRequestSdkMock(records: []) + await sdk.configureRecipients( + peers: [linkedPeer(counterparty: publicKey, state: .linked)], + requestCapabilitiesByPublicKey: [publicKey: true] + ) + await sdk.setProposalResult(record) + await sdk.pauseNextProcess() + let service = PaykitPaymentRequestService( + sdk: sdk, now: { now }, isPrivatePaymentPublishingEnabled: { true }, logWarning: { _ in } + ) + let target = PaykitPaymentRequestTarget(publicKey: publicKey) + let proposal = Task { + if isSubscription { + return try await service.proposeSubscription( + PaykitSubscriptionDraft( + amountSats: 1000, name: "Support", description: "", frequency: .month, + expiresAt: expiresAt, iconData: nil + ), + to: target, savedPublicKeys: [publicKey], expectedIdentity: expectedIdentity, + validateBeforeProposing: {} + ).deliveryStatus + } + return try await service.propose( + PaykitPaymentRequestDraft(amountSats: 1000, note: "Support", expiresAt: expiresAt), + to: target, savedPublicKeys: [publicKey], expectedIdentity: expectedIdentity + ).deliveryStatus + } + try await waitUntil { await sdk.processIsPaused() } + await sdk.setRecords(freshRecord.map { [$0] } ?? []) + await sdk.resumeProcess() + + let status = try await proposal.value + XCTAssertEqual(status, expectedStatus) + let snapshot = await sdk.snapshot() + XCTAssertEqual(snapshot.proposedRequests.count, 1) + XCTAssertEqual(snapshot.paymentRequestListCallCount, 1) + } + } + } + + func testProposeRemainsCreatedWhenDeliveryStatusReadFails() async throws { + let publicKey = "pubky\(String(repeating: "y", count: 52))" + let expiresAt = Date(timeIntervalSince1970: 1_900_000_000) + for error in [PaymentRequestSdkMockError.process, CancellationError()] as [Error] { + let sdk = PaymentRequestSdkMock(records: []) + await sdk.configureRecipients( + peers: [linkedPeer(counterparty: publicKey, state: .linked)], + requestCapabilitiesByPublicKey: [publicKey: true] + ) + try await sdk.setProposalResult(paymentRequestRecord( + id: "outgoing", counterparty: publicKey, role: .payee, proposalOutboundMessageId: 7 + )) + await sdk.setPaymentRequestListError(error) + let manager = paymentRequestManager(sdk: sdk) + await manager.refreshEligibleTargets(savedPublicKeys: [publicKey]) + + let request = try await manager.propose( + PaykitPaymentRequestDraft(amountSats: 1, note: "", expiresAt: expiresAt), + to: XCTUnwrap(manager.eligibleTargets.first) + ) + + XCTAssertEqual(request.deliveryStatus, .queued) + XCTAssertEqual(manager.outgoingRequests.map(\.paymentRequestId), ["outgoing"]) + let snapshot = await sdk.snapshot() + XCTAssertEqual(snapshot.proposedRequests.count, 1) + } + } + + func testProposalDoesNotUseDeliveryStatusFromAReplacementIdentity() async throws { + let publicKey = "pubky\(String(repeating: "y", count: 52))" + let expiresAt = Date(timeIntervalSince1970: 1_900_000_000) + let sdk = PaymentRequestSdkMock(records: []) + await sdk.configureRecipients( + peers: [linkedPeer(counterparty: publicKey, state: .linked)], + requestCapabilitiesByPublicKey: [publicKey: true] + ) + var record = try paymentRequestRecord( + id: "outgoing", counterparty: publicKey, role: .payee, proposalOutboundMessageId: 7 + ) + await sdk.setProposalResult(record) + await sdk.pauseNextProcess() + let manager = paymentRequestManager(sdk: sdk) + await manager.refreshEligibleTargets(savedPublicKeys: [publicKey]) + let target = try XCTUnwrap(manager.eligibleTargets.first) + let proposal = Task { + try await manager.propose( + PaykitPaymentRequestDraft(amountSats: 1, note: "", expiresAt: expiresAt), to: target + ) + } + try await waitUntil { await sdk.processIsPaused() } + let replacementIdentity = "pubky\(String(repeating: "a", count: 52))" + manager.activate(identity: replacementIdentity) + await sdk.setActiveIdentity(replacementIdentity) + record.proposalOutboundStatus = .sent + await sdk.setRecords([record]) + await sdk.resumeProcess() + + let request = try await proposal.value + XCTAssertEqual(request.deliveryStatus, .queued) + XCTAssertTrue(manager.outgoingRequests.isEmpty) + let snapshot = await sdk.snapshot() + XCTAssertEqual(snapshot.proposedRequests.count, 1) + XCTAssertEqual(snapshot.paymentRequestListCallCount, 0) + } + func testProposalsRejectUnsavedSelectedTarget() async throws { let publicKey = "pubky\(String(repeating: "y", count: 52))" let unrelatedKey = "pubky\(String(repeating: "a", count: 52))" @@ -5975,31 +6104,40 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { func testProposeRemainsCreatedWhenImmediateDeliveryFails() async throws { let publicKey = "pubky\(String(repeating: "y", count: 52))" let expiresAt = Date(timeIntervalSince1970: 1_900_000_000) - let sdk = PaymentRequestSdkMock(records: []) - await sdk.configureRecipients( - peers: [linkedPeer(counterparty: publicKey, state: .linked)], - requestCapabilitiesByPublicKey: [publicKey: true] - ) - try await sdk.setProposalResult(paymentRequestRecord( - id: "outgoing", - counterparty: publicKey, - role: .payee, - expiresAt: timestamp(expiresAt) - )) - await sdk.failNextProcess() - let manager = paymentRequestManager(sdk: sdk) - await manager.refreshEligibleTargets(savedPublicKeys: [publicKey]) + for cancelled in [false, true] { + let sdk = PaymentRequestSdkMock(records: []) + await sdk.configureRecipients( + peers: [linkedPeer(counterparty: publicKey, state: .linked)], + requestCapabilitiesByPublicKey: [publicKey: true] + ) + try await sdk.setProposalResult(paymentRequestRecord( + id: "outgoing", + counterparty: publicKey, + role: .payee, + expiresAt: timestamp(expiresAt), + proposalOutboundMessageId: 7 + )) + if cancelled { + await sdk.cancelNextProcess() + } else { + await sdk.failNextProcess() + } + let manager = paymentRequestManager(sdk: sdk) + await manager.refreshEligibleTargets(savedPublicKeys: [publicKey]) - _ = try await manager.propose( - PaykitPaymentRequestDraft(amountSats: 1, note: "", expiresAt: expiresAt), - to: XCTUnwrap(manager.eligibleTargets.first) - ) + let request = try await manager.propose( + PaykitPaymentRequestDraft(amountSats: 1, note: "", expiresAt: expiresAt), + to: XCTUnwrap(manager.eligibleTargets.first) + ) - XCTAssertEqual(manager.outgoingRequests.map(\.paymentRequestId), ["outgoing"]) - let snapshot = await sdk.snapshot() - XCTAssertEqual(snapshot.proposedRequests.count, 1) - XCTAssertEqual(snapshot.processCallCount, 0) - XCTAssertEqual(snapshot.processedCounterparties, [publicKey]) + XCTAssertEqual(request.deliveryStatus, .queued) + XCTAssertEqual(manager.outgoingRequests.map(\.paymentRequestId), ["outgoing"]) + let snapshot = await sdk.snapshot() + XCTAssertEqual(snapshot.proposedRequests.count, 1) + XCTAssertEqual(snapshot.processCallCount, 0) + XCTAssertEqual(snapshot.processedCounterparties, [publicKey]) + XCTAssertEqual(snapshot.paymentRequestListCallCount, cancelled ? 0 : 1) + } } func testProposeRevalidatesTargetBeforeEnqueueing() async throws { @@ -6443,6 +6581,7 @@ private actor PaymentRequestSdkMock: PaykitPaymentRequestSdkHandling, PaykitPaym private var records: [PaymentRequestRecord] private var incomingRecords: [PaymentRequestRecord] = [] private var paymentRequestListCallCount = 0 + private var paymentRequestListError: Error? private var peerRecords: [LinkedPeerRecord] = [] private var requestCapabilitiesByPublicKey: [String: Bool] = [:] private var liveSessionAvailable = true @@ -6553,6 +6692,18 @@ private actor PaymentRequestSdkMock: PaykitPaymentRequestSdkHandling, PaykitPaym return snapshot } + func sharedPaymentRequests(expectedIdentity: String) async throws -> [PaymentRequestRecord] { + guard PubkyPublicKeyFormat.matches(activeIdentity, expectedIdentity) else { + throw PaykitPaymentRequestError.requestUnavailable + } + if let paymentRequestListError { throw paymentRequestListError } + return await sharedPaymentRequests() + } + + func setPaymentRequestListError(_ error: Error) { + paymentRequestListError = error + } + func identityStatus() -> IdentityStatus? { IdentityStatus(publicKey: activeIdentity, capability: liveSessionAvailable ? .privateLinkCapable : .signedOut) } diff --git a/changelog.d/next/856.changed.md b/changelog.d/next/856.changed.md index b3b8590c2..b49ee46bd 100644 --- a/changelog.d/next/856.changed.md +++ b/changelog.d/next/856.changed.md @@ -1 +1 @@ -Share Paykit contacts and payment state with authorized apps while keeping wallet keys private, preserve disabled contact sharing during cleanup failures, prevent paid subscription periods from being offered again, reduce repeated shared-state reads, and label received payments with their Paykit payer contact while preserving manual contact removal through backup and restore. +Share Paykit contacts and payment state with authorized apps while keeping wallet keys private, preserve disabled contact sharing during cleanup failures, prevent paid subscription periods from being offered again, show confirmed proposal delivery accurately, reduce repeated shared-state reads, and label received payments with their Paykit payer contact while preserving manual contact removal through backup and restore. diff --git a/journeys/subscriptions/create-and-propose.xml b/journeys/subscriptions/create-and-propose.xml index 47bf370a7..0d5b6961c 100644 --- a/journeys/subscriptions/create-and-propose.xml +++ b/journeys/subscriptions/create-and-propose.xml @@ -31,6 +31,6 @@ Tap Choose Recipient, select the linked contact if needed, and tap Propose Subscription again Verify the sent confirmation (id "SubscriptionProposalSent") shows headline "Sent Proposal", the recipient contact, and the subscription name with 5,000 sats Tap OK - Verify a row for "Journey Sub" appears in the CREATED section with subtitle "Proposal sent" + Verify a row for "Journey Sub" appears in the CREATED section with subtitle "Proposal sent", not "Proposal queued" From 486d79522b3a3e2305c113594ffa6c38dc59e369 Mon Sep 17 00:00:00 2001 From: benk10 Date: Mon, 5 Oct 2026 16:16:30 -0500 Subject: [PATCH 52/68] Run public payment resolution outside the mutation queue --- Bitkit/Services/PubkyService.swift | 6 ++- BitkitTests/PaykitSdkOperationLockTests.swift | 39 +++++++++++++++++++ 2 files changed, 43 insertions(+), 2 deletions(-) diff --git a/Bitkit/Services/PubkyService.swift b/Bitkit/Services/PubkyService.swift index 29f4a1717..5b5de0a13 100644 --- a/Bitkit/Services/PubkyService.swift +++ b/Bitkit/Services/PubkyService.swift @@ -1075,8 +1075,10 @@ actor PaykitSdkService { } func resolvePublicContactPayment(counterparty: String) async throws -> PublicContactPaymentResolution { - try await operationLock.withLock(priority: .interactive) { - try await handle().resolvePublicContactPayment(counterparty: counterparty, amount: nil) + try await withPublicRead { sdk in + let result = try await sdk.resolvePublicContactPayment(counterparty: counterparty, amount: nil) + guard self.sdk === sdk else { throw PubkyServiceError.identityChanged } + return result } } diff --git a/BitkitTests/PaykitSdkOperationLockTests.swift b/BitkitTests/PaykitSdkOperationLockTests.swift index 290034730..247aae1c9 100644 --- a/BitkitTests/PaykitSdkOperationLockTests.swift +++ b/BitkitTests/PaykitSdkOperationLockTests.swift @@ -104,6 +104,8 @@ final class PaykitSdkOperationLockTests: XCTestCase { let lookup = Task { let result = try await service.canReceivePaymentRequests(publicKey: "peer") XCTAssertFalse(result) + let resolution = try await service.resolvePublicContactPayment(counterparty: "peer") + XCTAssertEqual(resolution.status, .noEndpoint) read.fulfill() } await fulfillment(of: [read], timeout: 0.5) @@ -112,6 +114,35 @@ final class PaykitSdkOperationLockTests: XCTestCase { try await lookup.value } + func testPublicPaymentResolutionDiscardsReplacedRuntimeAndAllowsFreshRead() async throws { + for wipe in [false, true] { + let (gate, release) = AsyncStream.makeStream() + defer { release.finish() } + let started = expectation(description: "Public payment resolution started") + let sdk = PublicReadSdk(noPointer: .init()) + sdk.publicRead = { + started.fulfill() + for await _ in gate {} + } + let service = PaykitSdkService(sdkFactory: { sdk }) + let lookup = Task { try await service.resolvePublicContactPayment(counterparty: "peer") } + await fulfillment(of: [started], timeout: 2) + if wipe { + try await service.withWalletWipe {} + } else { + await service.clearState() + } + release.finish() + do { + _ = try await lookup.value + XCTFail("Expected replaced runtime result to be rejected") + } catch PubkyServiceError.identityChanged {} + sdk.publicRead = {} + let fresh = try await service.resolvePublicContactPayment(counterparty: "peer") + XCTAssertEqual(fresh.status, .noEndpoint) + } + } + func testPublicReadDiscardsCancelledResult() async throws { let lock = PaykitSdkOperationLock() let lookup = Task { @@ -318,6 +349,14 @@ final class PaykitSdkOperationLockTests: XCTestCase { private final class PublicReadSdk: PaykitSdk, @unchecked Sendable { var lockedRead: () async -> Void = {} + var publicRead: () async -> Void = {} + + override func resolvePublicContactPayment(counterparty _: String, + amount _: PaymentAmountContext?) async throws -> PublicContactPaymentResolution + { + await publicRead() + return PublicContactPaymentResolution(status: .noEndpoint, payableEndpoints: [], failures: []) + } override func paykitAppRegistry(publicKey _: String) async throws -> PaykitAppRegistry? { return nil From 976538f2c298ed27a384e7510f58aadef6053f1b Mon Sep 17 00:00:00 2001 From: benk10 Date: Mon, 5 Oct 2026 16:38:22 -0500 Subject: [PATCH 53/68] Prioritize request delivery status checks --- .../PaykitPaymentRequestService.swift | 6 +++++ Bitkit/Services/PubkyService.swift | 2 +- .../PaykitPaymentRequestServiceTests.swift | 26 +++++++++++++++---- 3 files changed, 28 insertions(+), 6 deletions(-) diff --git a/Bitkit/Services/PaykitPaymentRequestService.swift b/Bitkit/Services/PaykitPaymentRequestService.swift index c66804ffd..bb7a03602 100644 --- a/Bitkit/Services/PaykitPaymentRequestService.swift +++ b/Bitkit/Services/PaykitPaymentRequestService.swift @@ -1041,6 +1041,12 @@ struct PaykitPaymentRequestService { }) { return true } + if reports.contains(where: { report in + PubkyPublicKeyFormat.matches(report.counterparty, record.counterparty) && + report.report?.failed.contains(where: { $0.outboundMessageId == messageId }) == true + }) { + return false + } guard !Task.isCancelled else { return false } do { let records = try await sdk.sharedPaymentRequests(expectedIdentity: expectedIdentity) diff --git a/Bitkit/Services/PubkyService.swift b/Bitkit/Services/PubkyService.swift index 5b5de0a13..1cdf8ad06 100644 --- a/Bitkit/Services/PubkyService.swift +++ b/Bitkit/Services/PubkyService.swift @@ -928,7 +928,7 @@ actor PaykitSdkService { } func sharedPaymentRequests(expectedIdentity: String) async throws -> [Paykit.PaymentRequestRecord] { - try await withSdk { sdk in + try await withSdk(priority: .interactive) { sdk in try await Self.requireSignedInIdentity(expectedIdentity, in: sdk) return try await sdk.paymentRequests() } diff --git a/BitkitTests/PaykitPaymentRequestServiceTests.swift b/BitkitTests/PaykitPaymentRequestServiceTests.swift index 0f85f47df..111a513dc 100644 --- a/BitkitTests/PaykitPaymentRequestServiceTests.swift +++ b/BitkitTests/PaykitPaymentRequestServiceTests.swift @@ -5938,19 +5938,35 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { otherPeer.counterparty = expectedIdentity var otherRole = sent otherRole.localRole = .payer - let cases: [(PaymentRequestRecord?, PaykitPaymentRequest.DeliveryStatus)] = [ - (sent, .sent), (record, .queued), (nil, .queued), (otherMessage, .queued), - (otherRequest, .queued), (otherPeer, .queued), (otherRole, .queued), + let cases: [(PaymentRequestRecord?, UInt64?, Bool, PaykitPaymentRequest.DeliveryStatus)] = [ + (sent, nil, false, .sent), (record, nil, false, .queued), (nil, nil, false, .queued), + (otherMessage, nil, false, .queued), (otherRequest, nil, false, .queued), + (otherPeer, nil, false, .queued), (otherRole, nil, false, .queued), + (record, 7, false, .queued), (sent, 8, false, .sent), (sent, nil, true, .sent), ] for isSubscription in [false, true] { - for (freshRecord, expectedStatus) in cases { + for (freshRecord, failedMessageId, processFails, expectedStatus) in cases { let sdk = PaymentRequestSdkMock(records: []) await sdk.configureRecipients( peers: [linkedPeer(counterparty: publicKey, state: .linked)], requestCapabilitiesByPublicKey: [publicKey: true] ) await sdk.setProposalResult(record) + if let failedMessageId { + await sdk.setProcessReports([OutboundPrivateCounterpartySendReport( + counterparty: publicKey, + report: OutboundPrivateSendReport( + attempted: [failedMessageId], sent: [], + failed: [OutboundPrivateSendFailure( + outboundMessageId: failedMessageId, error: PaymentRequestIntakeError(noPointer: .init()) + )], + reservationCleanupFailures: [], recoveryMarkerFailures: [] + ), + error: nil + )]) + } + if processFails { await sdk.failNextProcess() } await sdk.pauseNextProcess() let service = PaykitPaymentRequestService( sdk: sdk, now: { now }, isPrivatePaymentPublishingEnabled: { true }, logWarning: { _ in } @@ -5980,7 +5996,7 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { XCTAssertEqual(status, expectedStatus) let snapshot = await sdk.snapshot() XCTAssertEqual(snapshot.proposedRequests.count, 1) - XCTAssertEqual(snapshot.paymentRequestListCallCount, 1) + XCTAssertEqual(snapshot.paymentRequestListCallCount, failedMessageId == 7 ? 0 : 1) } } } From f361e348af0c0a1ca42f79e4bcbdd6a751aa7760 Mon Sep 17 00:00:00 2001 From: benk10 Date: Mon, 5 Oct 2026 18:38:46 -0500 Subject: [PATCH 54/68] fix: retry pending sharing cleanup on reconnect --- Bitkit/AppScene.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Bitkit/AppScene.swift b/Bitkit/AppScene.swift index f2ab7e347..e70895c30 100644 --- a/Bitkit/AppScene.swift +++ b/Bitkit/AppScene.swift @@ -1210,6 +1210,7 @@ struct AppScene: View { } if mode == .full { await PubkyService.republishIdentityIfNeeded(publicKey: pubkyProfile.publicKey) + await retryPendingPaykitEndpointRemoval() await PrivatePaykitService.shared.refreshKnownSavedContactEndpoints( wallet: wallet, reason: "payment request polling" @@ -1566,6 +1567,7 @@ struct AppScene: View { if scenePhase == .active { await PubkyService.republishIdentityIfNeeded(publicKey: pubkyProfile.publicKey) } + await retryPendingPaykitEndpointRemoval() if PaykitFeatureFlags.isUIEnabled { let contactPublicKeys = contactsManager.contacts.map(\.publicKey) await PrivatePaykitService.shared.prepareSavedContacts( From 060d7a046f68c549d412950a846c3f309dee6999 Mon Sep 17 00:00:00 2001 From: benk10 Date: Mon, 5 Oct 2026 23:30:42 -0500 Subject: [PATCH 55/68] fix: prepare incoming payments in the send sheet --- Bitkit/AppScene.swift | 200 ++++++++++--- Bitkit/MainNavView.swift | 14 - .../PaykitPaymentRequestService.swift | 42 ++- .../PrivatePaykitService+Contacts.swift | 48 ++-- Bitkit/Services/PubkyService.swift | 31 +- Bitkit/ViewModels/SheetViewModel.swift | 11 +- Bitkit/Views/Offline/OfflineSheetScreen.swift | 7 +- .../PaymentRequests/PaymentRequestsView.swift | 9 +- .../Wallets/Send/SendConfirmationView.swift | 53 ++-- .../Send/SendContactHeaderAvatar.swift | 4 +- Bitkit/Views/Wallets/Send/SendSheet.swift | 159 +++++----- .../PaykitPaymentRequestServiceTests.swift | 271 +++++++++++++++++- BitkitTests/SendConfirmationSwipeTests.swift | 18 ++ changelog.d/next/856.changed.md | 2 +- journeys/payment-requests/README.md | 11 + .../automatic-presentation.xml | 13 +- .../issuer-interoperability.xml | 4 +- .../requested-resolution-failure.xml | 12 +- 18 files changed, 720 insertions(+), 189 deletions(-) diff --git a/Bitkit/AppScene.swift b/Bitkit/AppScene.swift index e70895c30..fcb10e146 100644 --- a/Bitkit/AppScene.swift +++ b/Bitkit/AppScene.swift @@ -114,6 +114,72 @@ enum IncomingPaykitPaymentRequestPresentationDispatch: Equatable { case presentNext } +@Observable @MainActor +final class IncomingPaykitPaymentRequestPreparation { + private(set) var request: PaykitPaymentRequest? + private(set) var resolvedRoute: SendRoute? + private let session: PubkyProfileManager.SignedInSession? + var paymentContext: ContactPaymentContext? + + init(request: PaykitPaymentRequest, session: PubkyProfileManager.SignedInSession?) { + self.request = request + self.session = session + } + + func visibleRequest( + manager: PaykitPaymentRequestManager, + session: PubkyProfileManager.SignedInSession?, + paymentContext: ContactPaymentContext?, + now: Date = Date() + ) -> PaykitPaymentRequest? { + guard let request, matchesSession(session), + paymentContext == self.paymentContext, + manager.isCurrentPresentation(request), + !request.isExpired(at: now) + else { return nil } + return request + } + + func matchesSession(_ session: PubkyProfileManager.SignedInSession?) -> Bool { + session != nil && session == self.session + } + + func clear() { + request = nil + resolvedRoute = nil + } + + func ownsSheet(_ sheets: SheetViewModel) -> Bool { + sheets.activeSheetConfiguration?.id == .send && + (sheets.activeSheetConfiguration?.data as? SendConfig)?.preparation === self && !sheets.isReplacingSheet + } + + func complete( + route: SendRoute, + manager: PaykitPaymentRequestManager, + session: PubkyProfileManager.SignedInSession?, + app: AppViewModel, + sheets: SheetViewModel + ) -> Bool { + guard !Task.isCancelled, ownsSheet(sheets), paymentContext != nil, + visibleRequest(manager: manager, session: session, paymentContext: app.contactPaymentContext) != nil + else { return false } + resolvedRoute = route + return true + } + + func whilePreparing(_ operation: () async throws -> T) async throws -> T { + try await withTaskCancellationHandler { + try Task.checkCancellation() + let result = try await operation() + try Task.checkCancellation() + return result + } onCancel: { + Task { @MainActor [weak self] in self?.clear() } + } + } +} + @MainActor enum IncomingPaykitPaymentRequestPresentationDispatcher { static func feedback( @@ -241,6 +307,7 @@ struct AppScene: View { @State private var hwWalletManager: HwWalletManager @State private var calculatorInputManager = CalculatorInputManager() @State private var paykitPaymentRequestManager = PaykitPaymentRequestManager() + @State private var incomingPaymentRequestPreparation: IncomingPaykitPaymentRequestPreparation? @State private var receivedPaymentBackfillCache = PaykitReceivedPaymentBackfillCache( activityChanges: CoreService.shared.activity.activitiesChangedPublisher ) @@ -442,7 +509,7 @@ struct AppScene: View { .environment(paykitPaymentRequestManager) } - private var appEventContent: some View { + private var paykitEventContent: some View { configuredContent .onChange(of: pubkyProfile.authState, initial: true) { _, authState in receivedPaymentBackfillCache.invalidate() @@ -486,15 +553,21 @@ struct AppScene: View { .onReceive(NotificationCenter.default.publisher(for: .paykitSubscriptionPaymentDue)) { _ in Task { await handlePendingPaykitSubscriptionNotification() } } + } + + private var appEventContent: some View { + paykitEventContent .onChange(of: sheets.activeSheetConfiguration?.id) { _, activeSheetId in guard activeSheetId == nil, !sheets.isReplacingSheet else { return } - Task { - try? await Task.sleep(for: .milliseconds(700)) - guard !Task.isCancelled, - sheets.activeSheetConfiguration == nil, - !sheets.isReplacingSheet - else { return } - await presentNextIncomingPaykitItem() + scheduleNextIncomingPaykitItemPresentation() + } + .onChange(of: isIncomingPaymentRequestPreparationInvalid) { _, invalid in + guard invalid, let preparation = incomingPaymentRequestPreparation else { return } + endIncomingPaymentRequestPreparation(preparation) + } + .onChange(of: app.showDrawer) { _, isShowing in + if !isShowing { + Task { await presentNextIncomingPaykitItem() } } } .onChange(of: incomingPaykitPaymentRequestPresentationState) { previous, current in @@ -1223,6 +1296,9 @@ struct AppScene: View { private func presentNextIncomingPaykitPaymentRequest() async { guard scenePhase == .active, isPinVerified || !settings.pinEnabled, + PaykitFeatureFlags.isUIEnabled, + pubkyProfile.currentSession != nil, + !app.showDrawer, sheets.activeSheetConfiguration == nil, !sheets.isReplacingSheet, app.contactPaymentContext == nil @@ -1233,12 +1309,26 @@ struct AppScene: View { guard sheets.activeSheetConfiguration == nil, !sheets.isReplacingSheet, app.contactPaymentContext == nil else { return } for request in requests { guard paykitPaymentRequestManager.isCurrentPresentation(request) else { return } + let preparation = IncomingPaykitPaymentRequestPreparation(request: request, session: pubkyProfile.currentSession) + incomingPaymentRequestPreparation = preparation + sheets.showSheet(.send, data: SendConfig(view: .confirm, preparation: preparation, onDismiss: { + if preparation.resolvedRoute == nil, scenePhase == .active, + preparation.matchesSession(pubkyProfile.currentSession), let request = preparation.request + { + paykitPaymentRequestManager.dismissPreparingRequest(request) + } + preparation.clear() + })) + defer { + if preparation.resolvedRoute == nil { + endIncomingPaymentRequestPreparation(preparation) + } + } do { - let result = try await PrivatePaykitService.shared.beginPaymentRequest(request) - guard paykitPaymentRequestManager.isCurrentPresentation(request), - sheets.activeSheetConfiguration == nil, - !sheets.isReplacingSheet, - app.contactPaymentContext == nil + let result = try await preparation.whilePreparing { + try await PrivatePaykitService.shared.beginPaymentRequest(request) + } + guard isCurrentIncomingPaymentRequestPreparation(preparation), app.contactPaymentContext == nil else { return } if case .privateLinkPending = result { if let feedback = IncomingPaykitPaymentRequestPresentationDispatcher.finishPendingPrivateLink( @@ -1247,14 +1337,14 @@ struct AppScene: View { ) { presentIncomingPaykitPaymentRequestFeedback(feedback, for: request) } - continue + return } guard case let .opened(paymentTarget, privatePaymentContext) = result else { deferIncomingPaykitPaymentRequestPresentation( request, reason: result.incomingPaymentRequestFailureReason ?? .resolutionFailed ) - continue + return } let contactPaymentContext = ContactPaymentContext( @@ -1263,17 +1353,18 @@ struct AppScene: View { incomingPaymentRequest: request ) guard app.claimContactPaymentContext(contactPaymentContext) else { return } + preparation.paymentContext = contactPaymentContext do { - try await app.handleScannedData( - paymentTarget, - claimedContactPaymentContext: contactPaymentContext, - alternativeOnchainBalanceSats: hwWalletManager.maximumFundingBalanceSats - ) - guard paykitPaymentRequestManager.isCurrentPresentation(request), - app.ownsContactPaymentContext(contactPaymentContext), - sheets.activeSheetConfiguration == nil, - !sheets.isReplacingSheet + try await preparation.whilePreparing { + try await app.handleScannedData( + paymentTarget, + claimedContactPaymentContext: contactPaymentContext, + alternativeOnchainBalanceSats: hwWalletManager.maximumFundingBalanceSats + ) + } + guard isCurrentIncomingPaymentRequestPreparation(preparation), + app.ownsContactPaymentContext(contactPaymentContext) else { if app.ownsContactPaymentContext(contactPaymentContext) { app.resetSendState() @@ -1300,15 +1391,15 @@ struct AppScene: View { return } - guard paykitPaymentRequestManager.isCurrentPresentation(request) else { + guard isCurrentIncomingPaymentRequestPreparation(preparation) else { app.resetSendState() wallet.resetSendState(speed: settings.defaultTransactionSpeed) return } } catch ScanHandlingError.pubkyAuthRequest { - guard paykitPaymentRequestManager.isCurrentPresentation(request) else { return } + guard isCurrentIncomingPaymentRequestPreparation(preparation) else { return } _ = paykitPaymentRequestManager.markPresentedIfPending(request) - continue + return } catch is CancellationError { if app.ownsContactPaymentContext(contactPaymentContext) { app.resetSendState() @@ -1317,7 +1408,7 @@ struct AppScene: View { return } catch { guard app.ownsContactPaymentContext(contactPaymentContext) else { return } - guard paykitPaymentRequestManager.isCurrentPresentation(request) else { + guard isCurrentIncomingPaymentRequestPreparation(preparation) else { app.resetSendState() wallet.resetSendState(speed: settings.defaultTransactionSpeed) return @@ -1334,7 +1425,7 @@ struct AppScene: View { return } deferIncomingPaykitPaymentRequestPresentation(request, reason: .invalidPaymentTarget) - continue + return } guard PaykitPaymentRequestPresentationCoordinator.canPresentPreparedRequest( @@ -1357,13 +1448,20 @@ struct AppScene: View { deferIncomingPaykitPaymentRequestPresentation(request, reason: .paymentTargetNotRoutable) return } - sheets.showSheet(.send, data: SendConfig(view: route)) + _ = preparation.complete( + route: route, + manager: paykitPaymentRequestManager, + session: pubkyProfile.currentSession, + app: app, + sheets: sheets + ) return } catch is CancellationError { return } catch { - guard paykitPaymentRequestManager.isCurrentPresentation(request) else { return } + guard isCurrentIncomingPaymentRequestPreparation(preparation) else { return } deferIncomingPaykitPaymentRequestPresentation(request, reason: .resolutionFailed) + return } } } @@ -1376,7 +1474,45 @@ struct AppScene: View { !sheets.isReplacingSheet, app.contactPaymentContext == nil else { return } - await presentNextIncomingPaykitPaymentRequest() + scheduleNextIncomingPaykitItemPresentation() + } + + private func isCurrentIncomingPaymentRequestPreparation(_ preparation: IncomingPaykitPaymentRequestPreparation) -> Bool { + scenePhase == .active && (isPinVerified || !settings.pinEnabled) && PaykitFeatureFlags.isUIEnabled && + !app.showDrawer && preparation.ownsSheet(sheets) && preparation.visibleRequest( + manager: paykitPaymentRequestManager, + session: pubkyProfile.currentSession, + paymentContext: app.contactPaymentContext + ) != nil + } + + private var isIncomingPaymentRequestPreparationInvalid: Bool { + guard let preparation = incomingPaymentRequestPreparation else { return false } + if preparation.resolvedRoute != nil { return !preparation.ownsSheet(sheets) } + return !isCurrentIncomingPaymentRequestPreparation(preparation) + } + + private func endIncomingPaymentRequestPreparation(_ preparation: IncomingPaykitPaymentRequestPreparation) { + let context = preparation.paymentContext + preparation.clear() + if let context, app.ownsContactPaymentContext(context) { + app.resetSendState() + wallet.resetSendState(speed: settings.defaultTransactionSpeed) + } + if preparation.ownsSheet(sheets) { + sheets.hideSheet(reason: "Incoming payment request preparation ended") + } + if incomingPaymentRequestPreparation === preparation { + incomingPaymentRequestPreparation = nil + } + } + + private func scheduleNextIncomingPaykitItemPresentation() { + Task { + try? await Task.sleep(for: .milliseconds(700)) + guard !Task.isCancelled, sheets.activeSheetConfiguration == nil, !sheets.isReplacingSheet else { return } + await presentNextIncomingPaykitItem() + } } private func deferIncomingPaykitPaymentRequestPresentation( diff --git a/Bitkit/MainNavView.swift b/Bitkit/MainNavView.swift index 96e0c7890..4a4d264e2 100644 --- a/Bitkit/MainNavView.swift +++ b/Bitkit/MainNavView.swift @@ -351,20 +351,6 @@ struct MainNavView: View { TabBar() .ignoresSafeArea(.keyboard) DrawerView() - - if isPaykitUIActive, - pubkyProfile.authState == .authenticated, - paykitPaymentRequestManager.requestedPresentationId != nil, - sheets.activeSheetConfiguration == nil, - !sheets.isReplacingSheet - { - ActivityIndicator() - .padding(24) - .background(Color.gray6, in: RoundedRectangle(cornerRadius: 8)) - .accessibilityLabel(t("wallet__payment_request")) - .accessibilityIdentifier("PaymentRequestPreparing") - .allowsHitTesting(false) - } } .onChange(of: scenePhase) { _, newPhase in if newPhase == .active { diff --git a/Bitkit/Services/PaykitPaymentRequestService.swift b/Bitkit/Services/PaykitPaymentRequestService.swift index bb7a03602..f0acbffc2 100644 --- a/Bitkit/Services/PaykitPaymentRequestService.swift +++ b/Bitkit/Services/PaykitPaymentRequestService.swift @@ -492,13 +492,18 @@ enum PaykitPaymentRequestError: LocalizedError, Equatable { protocol PaykitPaymentRequestSdkHandling: Sendable { func processOutboundPrivateMessages(counterparty: String) async throws -> Paykit.OutboundPrivateSendReport + func processOutboundPrivateMessages(counterparty: String, priority: PaykitSdkOperationLock.Priority) async throws -> Paykit + .OutboundPrivateSendReport func processPendingPrivateMessages() async throws -> [Paykit.OutboundPrivateCounterpartySendReport] func receivePrivateMessagesFromLinkedPeers() async throws -> [Paykit.PrivateStreamCounterpartyIntakeReport] func paymentRequests() async throws -> [Paykit.PaymentRequestRecord] func sharedPaymentRequests() async throws -> [Paykit.PaymentRequestRecord] + func sharedPaymentRequests(priority: PaykitSdkOperationLock.Priority) async throws -> [Paykit.PaymentRequestRecord] func sharedPaymentRequests(expectedIdentity: String) async throws -> [Paykit.PaymentRequestRecord] func identityStatus() async throws -> Paykit.IdentityStatus? + func identityStatus(priority: PaykitSdkOperationLock.Priority) async throws -> Paykit.IdentityStatus? func linkedPeers() async throws -> [Paykit.LinkedPeerRecord] + func linkedPeers(priority: PaykitSdkOperationLock.Priority) async throws -> [Paykit.LinkedPeerRecord] func canReceivePaymentRequests(publicKey: String, priority: PaykitPublicReadPriority) async throws -> Bool func proposePaymentRequest( counterparty: String, @@ -557,7 +562,10 @@ struct PaykitPaymentRequestService { self.logWarning = logWarning } - func synchronize(mode: PaykitPaymentRequestRefreshMode = .full) async throws -> PaykitPaymentRequestSnapshot { + func synchronize( + mode: PaykitPaymentRequestRefreshMode = .full, + priority: PaykitSdkOperationLock.Priority = .ordered + ) async throws -> PaykitPaymentRequestSnapshot { if mode == .full { try await processPendingMessages() } @@ -566,9 +574,9 @@ struct PaykitPaymentRequestService { logIntakeFailures(intakeReports) } let synchronizationDate = now() - let sharedRecords = try await sdk.sharedPaymentRequests() + let sharedRecords = try await sdk.sharedPaymentRequests(priority: priority) let records = sharedRecords.filter(PaykitSdkService.isBitkitPaymentRequest) - let blockedPeers = try await sdk.linkedPeers().filter { $0.state == .blocked } + let blockedPeers = try await sdk.linkedPeers(priority: priority).filter { $0.state == .blocked } let availableRecords = records.filter { record in !blockedPeers.contains { PubkyPublicKeyFormat.matches($0.counterparty, record.counterparty) @@ -632,7 +640,8 @@ struct PaykitPaymentRequestService { ) async throws -> PaykitPaymentRequestTargetDiscovery { let unavailable = PaykitPaymentRequestTargetDiscovery(targets: [], isComplete: true, capabilityCheckedPublicKeys: []) guard isPrivatePaymentPublishingEnabled(), !Self.acceptedPaymentEndpointIdentifiers().isEmpty else { return unavailable } - guard let identityStatus = try await sdk.identityStatus(), + let sdkPriority: PaykitSdkOperationLock.Priority = priority == .bulk ? .background : .interactive + guard let identityStatus = try await sdk.identityStatus(priority: sdkPriority), identityStatus.capability == .privateLinkCapable, PubkyPublicKeyFormat.matches(identityStatus.publicKey, expectedIdentity) else { return unavailable } @@ -640,7 +649,7 @@ struct PaykitPaymentRequestService { let savedKeys = savedPublicKeys.compactMap(PubkyPublicKeyFormat.normalized).filter { seenSavedKeys.insert($0).inserted } - let linkedPeers = try await sdk.linkedPeers().filter { $0.state == .linked } + let linkedPeers = try await sdk.linkedPeers(priority: sdkPriority).filter { $0.state == .linked } let linkedKeys = Set(linkedPeers.compactMap { PubkyPublicKeyFormat.normalized($0.counterparty) }) let candidates = savedKeys.filter { linkedKeys.contains($0) } let lookup: @Sendable (String) async throws -> (String, Result) = { [sdk] publicKey in @@ -989,7 +998,7 @@ struct PaykitPaymentRequestService { do { let reports: [Paykit.OutboundPrivateCounterpartySendReport] if let counterparty { - let report = try await sdk.processOutboundPrivateMessages(counterparty: counterparty) + let report = try await sdk.processOutboundPrivateMessages(counterparty: counterparty, priority: .interactive) reports = [Paykit.OutboundPrivateCounterpartySendReport( counterparty: counterparty, report: report, @@ -1219,6 +1228,7 @@ final class PaykitPaymentRequestManager { private var approvedPaymentRequestIds: Set = [] private var acceptedRequestIds: Set = [] private var presentedRequestIds: Set = [] + private var dismissedPreparingRequestIds: Set = [] private var presentationRetryAttempts: [PaykitPaymentRequest.ID: Int] = [:] private var presentationRetryDeadlines: [PaykitPaymentRequest.ID: ContinuousClock.Instant] = [:] private var automaticPresentationDiagnosticReasons: @@ -1897,6 +1907,7 @@ final class PaykitPaymentRequestManager { requestedPresentationId == nil else { return false } presentationGeneration += 1 + dismissedPreparingRequestIds.remove(request.id) presentationRetryAttempts.removeValue(forKey: request.id) presentationRetryDeadlines.removeValue(forKey: request.id) requestedPresentationId = request.id @@ -1904,6 +1915,21 @@ final class PaykitPaymentRequestManager { return true } + func dismissPreparingRequest(_ request: PaykitPaymentRequest) { + guard pendingRequests.contains(where: { $0.id == request.id }), + requestedPresentationId == nil || requestedPresentationId == request.id, + !presentedRequestIds.contains(request.id) || requestedPresentationId == request.id + else { return } + dismissedPreparingRequestIds.insert(request.id) + presentationGeneration += 1 + if requestedPresentationId == request.id { + requestedPresentationId = nil + } + presentationRetryAttempts.removeValue(forKey: request.id) + presentationRetryDeadlines.removeValue(forKey: request.id) + schedulePresentationRetry() + } + func clear() { stateGeneration += 1 let clearedStateGeneration = stateGeneration @@ -1928,6 +1954,7 @@ final class PaykitPaymentRequestManager { savedPublicKeys = [] presentedRequestIds = [] persistedPresentedRequestIds = [] + dismissedPreparingRequestIds = [] presentationRetryAttempts = [:] presentationRetryDeadlines = [:] automaticPresentationDiagnosticReasons = [:] @@ -1963,6 +1990,7 @@ final class PaykitPaymentRequestManager { return pendingRequests.filter { !presentedRequestIds.contains($0.id) && + !dismissedPreparingRequestIds.contains($0.id) && !processingRequestIds.contains($0.id) && (presentationRetryDeadlines[$0.id].map { $0 <= date } ?? true) } @@ -2173,7 +2201,7 @@ final class PaykitPaymentRequestManager { mode: PaykitPaymentRequestRefreshMode ) async { do { - let snapshot = try await service.synchronize(mode: mode) + let snapshot = try await service.synchronize(mode: mode, priority: .background) guard generation == refreshGeneration, let activeIdentity else { return } async let completedProofKinds = completedPaymentProofKinds(activeIdentity) async let inFlightRequestIds = inFlightPaymentRequestIds(activeIdentity) diff --git a/Bitkit/Services/PrivatePaykitService+Contacts.swift b/Bitkit/Services/PrivatePaykitService+Contacts.swift index 42880add0..334368501 100644 --- a/Bitkit/Services/PrivatePaykitService+Contacts.swift +++ b/Bitkit/Services/PrivatePaykitService+Contacts.swift @@ -29,13 +29,15 @@ extension PrivatePaykitService { let processPending: (String) async throws -> Void let receive: (String) async throws -> Void - static let live = PrivateMessageDrainOperations( - ensureLink: { _ = try await PaykitSdkService.shared.ensureLinkWithPeer($0) }, - pendingOutbound: { try await PaykitSdkService.shared.pendingOutboundPrivateCounterparties() }, - linkedPeers: { try await PaykitSdkService.shared.linkedPeers() }, - processPending: { _ = try await PaykitSdkService.shared.processOutboundPrivateMessages(counterparty: $0) }, - receive: { _ = try await PaykitSdkService.shared.receivePrivateMessages(counterparty: $0) } - ) + static func live(readPriority: PaykitSdkOperationLock.Priority = .ordered) -> PrivateMessageDrainOperations { + PrivateMessageDrainOperations( + ensureLink: { _ = try await PaykitSdkService.shared.ensureLinkWithPeer($0) }, + pendingOutbound: { try await PaykitSdkService.shared.pendingOutboundPrivateCounterparties(priority: readPriority) }, + linkedPeers: { try await PaykitSdkService.shared.linkedPeers(priority: readPriority) }, + processPending: { _ = try await PaykitSdkService.shared.processOutboundPrivateMessages(counterparty: $0) }, + receive: { _ = try await PaykitSdkService.shared.receivePrivateMessages(counterparty: $0) } + ) + } } struct EndpointCleanupOperations { @@ -207,7 +209,11 @@ extension PrivatePaykitService { ) async -> Error? { if let isSessionCurrent, await !isSessionCurrent() { return nil } let generation = preparationGeneration - let operations = endpointPublicationOperations(wallet: wallet, forceRefreshLightning: forceRefreshLightning) + let operations = endpointPublicationOperations( + wallet: wallet, + forceRefreshLightning: forceRefreshLightning, + readPriority: requireImmediatePublication ? .ordered : .background + ) guard await operations.canPublish() else { await prepareRelevantPrivateLinksIfAvailable(publicKeys, reason: reason) return requireImmediatePublication && !publicKeys.isEmpty ? PrivatePaykitError.privateUnavailable : nil @@ -468,7 +474,8 @@ extension PrivatePaykitService { ) async -> Error? { let operations = endpointPublicationOperations( wallet: wallet, - forceRefreshLightning: forceRefreshLightning + forceRefreshLightning: forceRefreshLightning, + readPriority: requireImmediatePublication ? .ordered : .background ) return await syncLocalEndpointPublication( for: publicKeys, @@ -481,7 +488,8 @@ extension PrivatePaykitService { private func endpointPublicationOperations( wallet: WalletViewModel, - forceRefreshLightning: Bool + forceRefreshLightning: Bool, + readPriority: PaykitSdkOperationLock.Priority ) -> EndpointPublicationOperations { if let publicationOperations { return publicationOperations } return EndpointPublicationOperations( @@ -504,7 +512,7 @@ extension PrivatePaykitService { clearUnlistedLinkedPeers: false ) }, - linkedPeers: { try await PaykitSdkService.shared.linkedPeers() }, + linkedPeers: { try await PaykitSdkService.shared.linkedPeers(priority: readPriority) }, canPublish: { await self.canPublishPrivateEndpoints(wallet: wallet) } ) } @@ -648,7 +656,7 @@ extension PrivatePaykitService { func drainPendingPrivateMessages( reason: String, advancing retryKeys: [String], - operations: PrivateMessageDrainOperations = .live + operations: PrivateMessageDrainOperations = .live() ) async { let retryKeys = Set(retryKeys.map { PubkyPublicKeyFormat.normalized($0) ?? $0 }) guard !retryKeys.isEmpty, !Task.isCancelled else { return } @@ -743,9 +751,9 @@ extension PrivatePaykitService { let retryKeys = Array(pendingMessageDrainRetryKeys) guard !retryKeys.isEmpty else { return } - let drainKeys = await pendingPrivateMessageDrainKeys(retryKeys) + let drainKeys = await pendingPrivateMessageDrainKeys(retryKeys, priority: .background) if !drainKeys.isEmpty { - await drainPendingPrivateMessages(reason: reason, advancing: Array(drainKeys)) + await drainPendingPrivateMessages(reason: reason, advancing: Array(drainKeys), operations: .live(readPriority: .background)) } await updatePendingMessageDrainRetryKeys(retryKeys) } @@ -761,19 +769,23 @@ extension PrivatePaykitService { } private func updatePendingMessageDrainRetryKeys(_ retryKeys: [String]) async { - let remainingKeys = await pendingPrivateMessageDrainKeys(retryKeys) + let remainingKeys = await pendingPrivateMessageDrainKeys(retryKeys, priority: .background) pendingMessageDrainRetryKeys.subtract(retryKeys) pendingMessageDrainRetryKeys.formUnion(remainingKeys) } - private func pendingPrivateMessageDrainKeys(_ retryKeys: [String], retryMissingPeers: Bool = false) async -> Set { + private func pendingPrivateMessageDrainKeys( + _ retryKeys: [String], + retryMissingPeers: Bool = false, + priority: PaykitSdkOperationLock.Priority = .ordered + ) async -> Set { let retryKeys = Set(retryKeys) guard !retryKeys.isEmpty else { return [] } let linkedPeers: [String: LinkedPeerState] do { var peersByKey: [String: LinkedPeerState] = [:] - for peer in try await PaykitSdkService.shared.linkedPeers() { + for peer in try await PaykitSdkService.shared.linkedPeers(priority: priority) { guard let publicKey = PubkyPublicKeyFormat.normalized(peer.counterparty) else { continue } peersByKey[publicKey] = peer.state } @@ -785,7 +797,7 @@ extension PrivatePaykitService { let pendingOutbound: Set do { - let pending = try await PaykitSdkService.shared.pendingOutboundPrivateCounterparties() + let pending = try await PaykitSdkService.shared.pendingOutboundPrivateCounterparties(priority: priority) pendingOutbound = Set(pending.compactMap(PubkyPublicKeyFormat.normalized)) } catch { Logger.warn("Failed to inspect pending private Paykit messages: \(error)", context: "PrivatePaykit") diff --git a/Bitkit/Services/PubkyService.swift b/Bitkit/Services/PubkyService.swift index 1cdf8ad06..cc64804c2 100644 --- a/Bitkit/Services/PubkyService.swift +++ b/Bitkit/Services/PubkyService.swift @@ -535,7 +535,11 @@ actor PaykitSdkService { } func identityStatus() async throws -> IdentityStatus? { - try await withSdk { sdk in + try await identityStatus(priority: .ordered) + } + + func identityStatus(priority: PaykitSdkOperationLock.Priority) async throws -> IdentityStatus? { + try await withSdk(priority: priority) { sdk in try await sdk.identityStatus() } } @@ -905,7 +909,14 @@ actor PaykitSdkService { @discardableResult func processOutboundPrivateMessages(counterparty: String) async throws -> OutboundPrivateSendReport { - try await withStateRevisionTracking { sdk in + try await processOutboundPrivateMessages(counterparty: counterparty, priority: .ordered) + } + + func processOutboundPrivateMessages( + counterparty: String, + priority: PaykitSdkOperationLock.Priority + ) async throws -> OutboundPrivateSendReport { + try await withStateRevisionTracking(priority: priority) { sdk in try await sdk.processOutboundPrivateMessages(counterparty: counterparty) } } @@ -922,7 +933,11 @@ actor PaykitSdkService { } func sharedPaymentRequests() async throws -> [Paykit.PaymentRequestRecord] { - try await withSdk { sdk in + try await sharedPaymentRequests(priority: .ordered) + } + + func sharedPaymentRequests(priority: PaykitSdkOperationLock.Priority) async throws -> [Paykit.PaymentRequestRecord] { + try await withSdk(priority: priority) { sdk in try await sdk.paymentRequests() } } @@ -1033,13 +1048,17 @@ actor PaykitSdkService { } func linkedPeers() async throws -> [LinkedPeerRecord] { - try await withSdk { sdk in + try await linkedPeers(priority: .ordered) + } + + func linkedPeers(priority: PaykitSdkOperationLock.Priority) async throws -> [LinkedPeerRecord] { + try await withSdk(priority: priority) { sdk in try await sdk.linkedPeers() } } - func pendingOutboundPrivateCounterparties() async throws -> [String] { - try await withSdk { sdk in + func pendingOutboundPrivateCounterparties(priority: PaykitSdkOperationLock.Priority = .ordered) async throws -> [String] { + try await withSdk(priority: priority) { sdk in try await sdk.pendingOutboundPrivateCounterparties() } } diff --git a/Bitkit/ViewModels/SheetViewModel.swift b/Bitkit/ViewModels/SheetViewModel.swift index 1081b4e70..710e186cc 100644 --- a/Bitkit/ViewModels/SheetViewModel.swift +++ b/Bitkit/ViewModels/SheetViewModel.swift @@ -81,6 +81,9 @@ class SheetViewModel: ObservableObject { func hideSheet(reason: String? = nil, file: String = #file, function: String = #function, line: Int = #line) { if let config = activeSheetConfiguration { + if !isReplacingSheet { + (config.data as? SendConfig)?.onDismiss?() + } let fallback = "\(URL(fileURLWithPath: file).lastPathComponent):\(line) \(function)" let reasonText = " reason: \(reason ?? fallback)" Logger.debug("Hiding sheet \(config.id.rawValue)\(reasonText)", context: "SheetViewModel") @@ -437,11 +440,15 @@ class SheetViewModel: ObservableObject { guard let config = activeSheetConfiguration, config.id == .send else { return nil } let sendConfig = config.data as? SendConfig let initialRoute = sendConfig?.initialRoute ?? .options - return SendSheetItem(initialRoute: initialRoute, hardwareWalletId: sendConfig?.hardwareWalletId) + return SendSheetItem( + initialRoute: initialRoute, + hardwareWalletId: sendConfig?.hardwareWalletId, + preparation: sendConfig?.preparation + ) } set { if newValue == nil { - activeSheetConfiguration = nil + hideSheet(reason: "Send sheet dismissed") } } } diff --git a/Bitkit/Views/Offline/OfflineSheetScreen.swift b/Bitkit/Views/Offline/OfflineSheetScreen.swift index 6531f52e6..093145d8f 100644 --- a/Bitkit/Views/Offline/OfflineSheetScreen.swift +++ b/Bitkit/Views/Offline/OfflineSheetScreen.swift @@ -26,9 +26,10 @@ private struct OfflineSheetOverlayModifier: ViewModifier { let title: String var forceShow = false + var isEnabled = true private var isShowing: Bool { - !network.isConnected || forceShow + isEnabled && (!network.isConnected || forceShow) } func body(content: Content) -> some View { @@ -54,7 +55,7 @@ extension View { /// Overlays a `OfflineSheetScreen` when the device is offline, or whenever `forceShow` is true /// (e.g. connection issues beyond device connectivity, like an unreachable Lightning peer). /// The underlying content remains mounted so navigation state and inputs are preserved. - func offlineSheetOverlay(title: String, forceShow: Bool = false) -> some View { - modifier(OfflineSheetOverlayModifier(title: title, forceShow: forceShow)) + func offlineSheetOverlay(title: String, forceShow: Bool = false, isEnabled: Bool = true) -> some View { + modifier(OfflineSheetOverlayModifier(title: title, forceShow: forceShow, isEnabled: isEnabled)) } } diff --git a/Bitkit/Views/PaymentRequests/PaymentRequestsView.swift b/Bitkit/Views/PaymentRequests/PaymentRequestsView.swift index fe27739ba..110080ba2 100644 --- a/Bitkit/Views/PaymentRequests/PaymentRequestsView.swift +++ b/Bitkit/Views/PaymentRequests/PaymentRequestsView.swift @@ -528,6 +528,7 @@ struct PaymentRequestDetailView: View { counterparty(request) if isActionable(request) { tags + .disabled(paymentRequests.requestedPresentationId == request.id) } if let note = request.note, !note.isEmpty { @@ -670,7 +671,8 @@ struct PaymentRequestDetailView: View { CustomButton( title: t("wallet__payment_request_dismiss"), variant: .secondary, - icon: Image("x-mark").resizable().frame(width: 16, height: 16) + icon: Image("x-mark").resizable().frame(width: 16, height: 16), + isDisabled: paymentRequests.requestedPresentationId == request.id ) { do { try await paymentRequests.dismiss(request) @@ -682,12 +684,13 @@ struct PaymentRequestDetailView: View { CustomButton( title: t("common__pay"), - icon: Image("coins").resizable().frame(width: 16, height: 16) + icon: Image("coins").resizable().frame(width: 16, height: 16), + isLoading: paymentRequests.requestedPresentationId == request.id ) { guard paymentRequests.requestPresentation(request) else { return } tagManager.preserveSelectedTags(for: request.id) - navigation.navigateBack() } + .accessibilityIdentifier("PaymentRequestDetailsPay") } .padding(.bottom, 16) } diff --git a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift index 60eeb492d..626dc27c8 100644 --- a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift +++ b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift @@ -20,6 +20,7 @@ struct SendConfirmationView: View { let requestPinCheck: () async -> Bool let prepareIncomingPaymentRequest: () async throws -> Void let routingCacheResetAttempted: Bool + var preparingRequest: PaykitPaymentRequest? @State private var showDetails = false @State private var showingBiometricError = false @@ -140,6 +141,7 @@ struct SendConfirmationView: View { } private var canEditAmount: Bool { + guard preparingRequest == nil else { return false } guard app.contactPaymentContext?.incomingPaymentRequest == nil else { return false } guard app.selectedWalletToPayFrom == .lightning else { return true } guard let invoice = app.scannedLightningInvoice else { return true } @@ -148,7 +150,7 @@ struct SendConfirmationView: View { } private var contactPaymentContact: PubkyContact? { - guard let publicKey = app.contactPaymentContext?.publicKey else { + guard let publicKey = preparingRequest?.counterparty ?? app.contactPaymentContext?.publicKey else { return nil } @@ -156,7 +158,7 @@ struct SendConfirmationView: View { } private var shouldAutomaticallyPay: Bool { - app.contactPaymentContext?.isInitialSubscriptionPayment == true && app.selectedWalletToPayFrom == .lightning && + preparingRequest == nil && app.contactPaymentContext?.isInitialSubscriptionPayment == true && app.selectedWalletToPayFrom == .lightning && !hwSend.isActive && !requiresPaymentConfirmation } @@ -174,11 +176,13 @@ struct SendConfirmationView: View { SheetHeader( title: reviewTitle, showBackButton: !navigationPath.isEmpty, - action: AnyView(SendContactHeaderAvatar()) + action: AnyView(SendContactHeaderAvatar(publicKey: preparingRequest?.counterparty)) ) VStack(alignment: .leading, spacing: 0) { - if app.selectedWalletToPayFrom == .lightning, let invoice = app.scannedLightningInvoice { + if let preparingRequest { + MoneyStack(sats: Int(preparingRequest.amountSats), showSymbol: true, testIdPrefix: "ReviewAmount") + } else if app.selectedWalletToPayFrom == .lightning, let invoice = app.scannedLightningInvoice { MoneyStack( sats: Int(wallet.sendAmountSats ?? invoice.amountSatoshis), showSymbol: true, @@ -235,10 +239,11 @@ struct SendConfirmationView: View { } .frame(maxWidth: .infinity, alignment: .center) .padding(.bottom, 62) + .disabled(preparingRequest != nil) .accessibilityIdentifier("SendConfirmToggleDetails") } - if isFeeRateMissing, wallet.feeRateLoadFailed { + if preparingRequest == nil, isFeeRateMissing, wallet.feeRateLoadFailed { CustomButton(title: t("common__try_again"), size: .small) { await retryFeeRate() } @@ -256,7 +261,8 @@ struct SendConfirmationView: View { isLoading: Self.isSwipeLoading( hasStartedAutomaticPayment: hasStartedAutomaticPayment, isFeeRateMissing: isFeeRateMissing, - feeRateLoadFailed: wallet.feeRateLoadFailed + feeRateLoadFailed: wallet.feeRateLoadFailed, + isPreparingRequest: preparingRequest != nil ), swipeProgress: $swipeProgress ) { @@ -268,8 +274,10 @@ struct SendConfirmationView: View { .sheetBackground() .frame(maxWidth: .infinity, maxHeight: .infinity) .accessibilityElement(children: .contain) - .accessibilityIdentifier(app.contactPaymentContext?.incomingPaymentRequest == nil ? "SendConfirm" : "PaymentRequestConfirm") - .task { + .accessibilityIdentifier(preparingRequest == nil && app.contactPaymentContext? + .incomingPaymentRequest == nil ? "SendConfirm" : "PaymentRequestConfirm") + .task(id: preparingRequest?.id) { + guard preparingRequest == nil else { return } ensureSendAmountFromScannedInvoicesIfNeeded() if app.contactPaymentContext?.isInitialSubscriptionPayment == true, !shouldAutomaticallyPay { requiresPaymentConfirmation = true @@ -282,16 +290,18 @@ struct SendConfirmationView: View { Task { @MainActor in await startAutomaticPaymentIfNeeded() } } .onChange(of: wallet.selectedFeeRateSatsPerVByte) { + guard preparingRequest == nil else { return } Task { await calculateTransactionFee() } } .onChange(of: wallet.feeRateLoadFailed, initial: true) { _, failed in - if failed, isFeeRateMissing { + if preparingRequest == nil, failed, isFeeRateMissing { showFeeRateUnavailableToast() } } .onChange(of: app.selectedWalletToPayFrom) { + guard preparingRequest == nil else { return } Task { if app.selectedWalletToPayFrom == .lightning { await calculateTransactionFee() @@ -301,6 +311,7 @@ struct SendConfirmationView: View { } } .onChange(of: hwSend.walletId) { + guard preparingRequest == nil else { return } Task { await calculateTransactionFee() } } .alert( @@ -335,7 +346,9 @@ struct SendConfirmationView: View { } private var reviewTitle: String { - paykitPaymentReviewTitle(context: app.contactPaymentContext, fallback: t("wallet__send_review")) + preparingRequest == nil + ? paykitPaymentReviewTitle(context: app.contactPaymentContext, fallback: t("wallet__send_review")) + : t("wallet__payment_request") } @MainActor @@ -636,6 +649,7 @@ struct SendConfirmationView: View { } private func submitPayment(isAutomatic: Bool = false) async throws { + guard preparingRequest == nil else { throw CancellationError() } if isFeeRateMissing { try await wallet.setFeeRate(speed: settings.defaultTransactionSpeed) } @@ -764,9 +778,13 @@ struct SendConfirmationView: View { walletType == .onchain && !isHardwarePayment && feeRate == nil } - /// The swipe shows its spinner while a payment starts or the fee rate is still loading; once the load gave up it stops so the user can retry. - static func isSwipeLoading(hasStartedAutomaticPayment: Bool, isFeeRateMissing: Bool, feeRateLoadFailed: Bool) -> Bool { - hasStartedAutomaticPayment || (isFeeRateMissing && !feeRateLoadFailed) + static func isSwipeLoading( + hasStartedAutomaticPayment: Bool, + isFeeRateMissing: Bool, + feeRateLoadFailed: Bool, + isPreparingRequest: Bool = false + ) -> Bool { + isPreparingRequest || hasStartedAutomaticPayment || (isFeeRateMissing && !feeRateLoadFailed) } private func showFeeRateUnavailableToast() { @@ -791,9 +809,10 @@ struct SendConfirmationView: View { walletType: WalletType, isHardwarePayment: Bool, isHardwareConfirmationUnavailable: Bool, - feeRate: UInt32? + feeRate: UInt32?, + isPreparingRequest: Bool = false ) -> Bool { - isHardwareConfirmationUnavailable + isPreparingRequest || isHardwareConfirmationUnavailable || isFeeRateMissing(walletType: walletType, isHardwarePayment: isHardwarePayment, feeRate: feeRate) } @@ -810,7 +829,8 @@ struct SendConfirmationView: View { walletType: app.selectedWalletToPayFrom, isHardwarePayment: hwSend.isActive, isHardwareConfirmationUnavailable: isHardwareConfirmationUnavailable, - feeRate: wallet.selectedFeeRateSatsPerVByte + feeRate: wallet.selectedFeeRateSatsPerVByte, + isPreparingRequest: preparingRequest != nil ) } @@ -840,6 +860,7 @@ struct SendConfirmationView: View { } private var oneOffPaymentRequest: PaykitPaymentRequest? { + if let preparingRequest { return preparingRequest } guard let request = app.contactPaymentContext?.incomingPaymentRequest, request.billingPeriod == nil else { return nil } diff --git a/Bitkit/Views/Wallets/Send/SendContactHeaderAvatar.swift b/Bitkit/Views/Wallets/Send/SendContactHeaderAvatar.swift index 301d2d6d0..497ab09b2 100644 --- a/Bitkit/Views/Wallets/Send/SendContactHeaderAvatar.swift +++ b/Bitkit/Views/Wallets/Send/SendContactHeaderAvatar.swift @@ -4,6 +4,8 @@ struct SendContactHeaderAvatar: View { @EnvironmentObject private var app: AppViewModel @EnvironmentObject private var contactsManager: ContactsManager + var publicKey: String? + var body: some View { if let contact { PubkyContactAvatar(contact: contact, size: 32) @@ -14,7 +16,7 @@ struct SendContactHeaderAvatar: View { } private var contact: PubkyContact? { - guard let publicKey = app.contactPaymentContext?.publicKey else { + guard let publicKey = publicKey ?? app.contactPaymentContext?.publicKey else { return nil } diff --git a/Bitkit/Views/Wallets/Send/SendSheet.swift b/Bitkit/Views/Wallets/Send/SendSheet.swift index c181fe82d..f43be60b0 100644 --- a/Bitkit/Views/Wallets/Send/SendSheet.swift +++ b/Bitkit/Views/Wallets/Send/SendSheet.swift @@ -78,10 +78,19 @@ enum SendRoute: Hashable { struct SendConfig { let initialRoute: SendRoute let hardwareWalletId: String? + let preparation: IncomingPaykitPaymentRequestPreparation? + let onDismiss: (() -> Void)? - init(view: SendRoute = .options, hardwareWalletId: String? = nil) { + init( + view: SendRoute = .options, + hardwareWalletId: String? = nil, + preparation: IncomingPaykitPaymentRequestPreparation? = nil, + onDismiss: (() -> Void)? = nil + ) { initialRoute = view self.hardwareWalletId = hardwareWalletId + self.preparation = preparation + self.onDismiss = onDismiss } } @@ -90,10 +99,12 @@ struct SendSheetItem: SheetItem { let size: SheetSize = .large let initialRoute: SendRoute let hardwareWalletId: String? + let preparation: IncomingPaykitPaymentRequestPreparation? - init(initialRoute: SendRoute = .options, hardwareWalletId: String? = nil) { + init(initialRoute: SendRoute = .options, hardwareWalletId: String? = nil, preparation: IncomingPaykitPaymentRequestPreparation? = nil) { self.initialRoute = initialRoute self.hardwareWalletId = hardwareWalletId + self.preparation = preparation } } @@ -136,6 +147,10 @@ struct SendSheet: View { rootOverride ?? rootRoute } + private var isPreparingRequest: Bool { + config.preparation != nil && incomingPaymentRequest == nil + } + /// How long the sync overlay may wait for channels to become usable before falling back private static let syncTimeoutSeconds: TimeInterval = 20 @@ -144,7 +159,7 @@ struct SendSheet: View { /// If there are no channels at all, we should NOT wait behind the sync UI – that's a capacity issue, not a sync issue. /// For onchain: only need node running. private var shouldShowSyncOverlay: Bool { - if hwSend.isActive { + if isPreparingRequest || hwSend.isActive { return false } @@ -201,76 +216,23 @@ struct SendSheet: View { .sheet(isPresented: reconnectPairingBinding) { HardwarePairingSheet(config: HardwarePairingSheetItem()) } - .offlineSheetOverlay(title: t("wallet__send_bitcoin"), forceShow: syncTimedOut) + .offlineSheetOverlay(title: t("wallet__send_bitcoin"), forceShow: syncTimedOut, isEnabled: !isPreparingRequest) .onChange(of: shouldShowSyncOverlay, initial: true) { _, isShowing in Logger.debug("shouldShowSyncOverlay: \(isShowing) (node: \(wallet.nodeLifecycleState))", context: "SendSheet") } - .onAppear { - wallet.resetSendState(speed: settings.defaultTransactionSpeed) - if let walletId = config.hardwareWalletId { - let balance = hwWalletManager.fundingBalance(walletId: walletId) - let reserve = HwFundingSigner.feeReserve( - balanceSats: balance, - satsPerVByte: wallet.selectedFeeRateSatsPerVByte.map(UInt64.init) - ) - hwSend.seedAvailable( - walletId: walletId, - availableSats: balance > reserve ? balance - reserve : 0 - ) - } - if let request = app.contactPaymentContext?.incomingPaymentRequest { - incomingPaymentRequest = request - if !tagManager.consumePreservedTags(for: request.id) { - tagManager.clearSelectedTags() - } - guard paykitPaymentRequestManager.markPresentedIfPending(request) else { - app.resetSendState() - sheets.hideSheetIfActive( - isEmbedded ? .subscription : .send, - reason: "Incoming payment request is no longer available" - ) - return - } - if PaykitSubscriptionNotificationTargetStore.load()?.matches(request) == true { - PaykitSubscriptionNotificationTargetStore.clear() - } - wallet.sendAmountSats = request.amountSats - } else { - tagManager.clearSelectedTags() - } - hasValidatedAfterSync = false - syncTimedOut = false - - // A plain Send open (TabBar) must not inherit invoice state from an earlier scan, - // e.g. one abandoned behind the sync overlay. Invoice-carrying opens use other routes. - if currentRoot == .options { - app.resetSendState() - } - - setupTask?.cancel() - setupTask = Task { - do { - try await wallet.loadFeeRateWithRetry(speed: settings.defaultTransactionSpeed) - } catch is CancellationError { - return - } catch { - Logger.error("Failed to set default fee rate: \(error)") - } - - if let request = incomingPaymentRequest { - guard isCurrentIncomingRequest(request.id) else { return } - guard await selectHardwareFundingSourceIfNeeded( - amountSats: request.amountSats, - requestId: request.id - ) else { return } - guard isCurrentIncomingRequest(request.id) else { return } - if !shouldShowSyncOverlay { - validatePaymentAfterSync() - } - } + .task(id: config.preparation?.resolvedRoute) { + if let preparation = config.preparation { + guard let route = preparation.resolvedRoute, preparation.ownsSheet(sheets), + let request = preparation.request, + app.contactPaymentContext == preparation.paymentContext, + app.contactPaymentContext?.incomingPaymentRequest?.id == request.id + else { return } + rootRoute = route } + setUpSend() } .onChange(of: wallet.nodeLifecycleState) { _, state in + guard !isPreparingRequest else { return } // When the node becomes running and we have a scanned invoice, run deferred validation. // This covers: // - Pure onchain invoices (node was not running at scan time) @@ -286,6 +248,7 @@ struct SendSheet: View { } } .onChange(of: wallet.hasUsableChannels) { _, hasUsable in + guard !isPreparingRequest else { return } // Only validate if channels just became usable and we have a scanned invoice // (Validation already happened in AppViewModel if channels were already usable) guard app.hasSendPaymentTarget else { return } @@ -320,7 +283,9 @@ struct SendSheet: View { @ViewBuilder private var content: some View { - if shouldShowSyncOverlay { + if isPreparingRequest, config.preparation?.request == nil { + Color.clear + } else if shouldShowSyncOverlay { SendSyncScreen() .transition(.opacity) } else { @@ -339,6 +304,9 @@ struct SendSheet: View { setupTask?.cancel() setupTask = nil hwSend.cancel() + if isPreparingRequest { + return + } if let request = incomingPaymentRequest ?? app.contactPaymentContext?.incomingPaymentRequest { Task { await paykitPaymentRequestManager.finishPayment(request) } } @@ -349,6 +317,58 @@ struct SendSheet: View { QuickPayPaymentCoordinator.shared.detach() } + private func setUpSend() { + wallet.resetSendState(speed: settings.defaultTransactionSpeed) + if let walletId = config.hardwareWalletId { + let balance = hwWalletManager.fundingBalance(walletId: walletId) + let reserve = HwFundingSigner.feeReserve( + balanceSats: balance, + satsPerVByte: wallet.selectedFeeRateSatsPerVByte.map(UInt64.init) + ) + hwSend.seedAvailable(walletId: walletId, availableSats: balance > reserve ? balance - reserve : 0) + } + if let request = app.contactPaymentContext?.incomingPaymentRequest { + if !tagManager.consumePreservedTags(for: request.id) { + tagManager.clearSelectedTags() + } + guard paykitPaymentRequestManager.markPresentedIfPending(request) else { + app.resetSendState() + sheets.hideSheetIfActive(isEmbedded ? .subscription : .send, reason: "Incoming payment request is no longer available") + return + } + if PaykitSubscriptionNotificationTargetStore.load()?.matches(request) == true { + PaykitSubscriptionNotificationTargetStore.clear() + } + wallet.sendAmountSats = request.amountSats + incomingPaymentRequest = request + } else { + tagManager.clearSelectedTags() + } + hasValidatedAfterSync = false + syncTimedOut = false + if currentRoot == .options { + app.resetSendState() + } + setupTask?.cancel() + setupTask = Task { + do { + try await wallet.loadFeeRateWithRetry(speed: settings.defaultTransactionSpeed) + } catch is CancellationError { + return + } catch { + Logger.error("Failed to set default fee rate: \(error)") + } + if let request = incomingPaymentRequest { + guard isCurrentIncomingRequest(request.id) else { return } + guard await selectHardwareFundingSourceIfNeeded(amountSats: request.amountSats, requestId: request.id) else { return } + guard isCurrentIncomingRequest(request.id) else { return } + if !shouldShowSyncOverlay { + validatePaymentAfterSync() + } + } + } + } + /// Called when the sync overlay has been visible for `syncTimeoutSeconds` without channels becoming usable. /// Unified invoices fall back to onchain; everything else shows the connection-issues screen /// (via the forced `offlineSheetOverlay`) and keeps waiting for the peer. @@ -688,7 +708,8 @@ struct SendSheet: View { hwSend: hwSend, requestPinCheck: requestPinCheck, prepareIncomingPaymentRequest: prepareIncomingPaymentRequest, - routingCacheResetAttempted: routingCacheResetAttempted + routingCacheResetAttempted: routingCacheResetAttempted, + preparingRequest: isPreparingRequest ? config.preparation?.request : nil ) case .hardwareSign: HwSendSignView( diff --git a/BitkitTests/PaykitPaymentRequestServiceTests.swift b/BitkitTests/PaykitPaymentRequestServiceTests.swift index 111a513dc..77b87a909 100644 --- a/BitkitTests/PaykitPaymentRequestServiceTests.swift +++ b/BitkitTests/PaykitPaymentRequestServiceTests.swift @@ -26,6 +26,9 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { XCTAssertEqual(snapshot.processCallCount, mode == .full ? 1 : 0, "\(mode)") XCTAssertEqual(snapshot.receiveCallCount, mode >= .inbox ? 1 : 0, "\(mode)") XCTAssertEqual(snapshot.paymentRequestListCallCount, 1, "\(mode)") + let priorities = await sdk.operationPriorities + XCTAssertEqual(priorities["requests"], [.background], "\(mode)") + XCTAssertEqual(priorities["peers"], [.background], "\(mode)") } } @@ -4476,25 +4479,239 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { XCTAssertEqual(presentationCount, 2) } - func testClearInvalidatesInFlightPresentation() async throws { - let sdk = try PaymentRequestSdkMock(records: [paymentRequestRecord()]) + func testPreparingSheetRequiresCurrentPresentationSessionAndContext() async throws { + let expiresAt = Date().addingTimeInterval(60) + let sdk = try PaymentRequestSdkMock(records: [paymentRequestRecord( + expiresAt: PaykitSubscriptionTimestamp.string(from: expiresAt) + )]) let manager = paymentRequestManager(sdk: sdk) await manager.refresh() - var wasCurrentBeforeClear = false - var wasCurrentAfterClear = true + let profile = PubkyProfileManager() + profile.publicKey = "pubky\(String(repeating: "z", count: 52))" + let session = try XCTUnwrap(profile.currentSession) await manager.presentRequests { requests in guard let request = requests.first else { XCTFail("Expected an incoming payment request") return } - wasCurrentBeforeClear = manager.isCurrentPresentation(request) + let preparation = IncomingPaykitPaymentRequestPreparation(request: request, session: session) + XCTAssertEqual(preparation.visibleRequest(manager: manager, session: session, paymentContext: nil), request) + XCTAssertNil(preparation.visibleRequest(manager: manager, session: nil, paymentContext: nil)) + profile.publicKey = "pubky\(String(repeating: "y", count: 52))" + XCTAssertNil(preparation.visibleRequest(manager: manager, session: profile.currentSession, paymentContext: nil)) + XCTAssertNil(preparation.visibleRequest(manager: manager, session: session, paymentContext: nil, now: expiresAt)) + + let context = ContactPaymentContext(publicKey: request.counterparty, incomingPaymentRequest: request) + preparation.paymentContext = context + XCTAssertEqual(preparation.visibleRequest(manager: manager, session: session, paymentContext: context), request) + XCTAssertNil(preparation.visibleRequest(manager: manager, session: session, paymentContext: nil)) + XCTAssertNil(preparation.visibleRequest( + manager: manager, + session: session, + paymentContext: ContactPaymentContext(publicKey: request.counterparty, incomingPaymentRequest: request) + )) + + XCTAssertTrue(manager.isCurrentPresentation(request)) manager.clear() - wasCurrentAfterClear = manager.isCurrentPresentation(request) + XCTAssertFalse(manager.isCurrentPresentation(request)) + XCTAssertNil(preparation.visibleRequest(manager: manager, session: session, paymentContext: context)) + } + } + + func testPreparingSheetClearsWhileCanceledPreparationIsStillSuspended() async throws { + for manuallyRequested in [false, true] { + let manager = try paymentRequestManager(sdk: PaymentRequestSdkMock(records: [paymentRequestRecord( + metadata: #"{"note":"Dinner"}"# + )])) + await manager.refresh() + let request = try XCTUnwrap(manager.pendingRequests.first) + if manuallyRequested { + XCTAssertTrue(manager.requestPresentation(request)) + } + let profile = PubkyProfileManager() + profile.publicKey = "pubky\(String(repeating: "z", count: 52))" + let session = try XCTUnwrap(profile.currentSession) + let preparation = IncomingPaykitPaymentRequestPreparation(request: request, session: session) + var continuation: CheckedContinuation? + var preparationFinished = false + let task = Task { + await manager.presentRequests { requests in + XCTAssertEqual(requests, [request]) + defer { preparation.clear() } + do { + try await preparation.whilePreparing { + await withCheckedContinuation { continuation = $0 } + } + XCTFail("Canceled preparation must not advance to confirmation") + } catch is CancellationError { + } catch { + XCTFail("Unexpected preparation error: \(error)") + } + preparationFinished = true + } + } + defer { continuation?.resume() } + try await waitUntil { continuation != nil } + + let visible = preparation.visibleRequest(manager: manager, session: session, paymentContext: nil) + XCTAssertEqual(visible, request) + XCTAssertEqual(visible?.note, "Dinner") + XCTAssertEqual(manager.requestsForPresentation(), [request]) + XCTAssertFalse(manager.isApprovedForPayment(request)) + + task.cancel() + try await waitUntil { preparation.request == nil } + XCTAssertFalse(preparationFinished) + continuation?.resume() + continuation = nil + _ = await task.value + XCTAssertNil(preparation.request) + XCTAssertEqual(manager.requestsForPresentation(), [request]) } + } - XCTAssertTrue(wasCurrentBeforeClear) - XCTAssertFalse(wasCurrentAfterClear) + func testCanceledPreparationThrowsBeforeScheduledCleanup() async throws { + let manager = try paymentRequestManager(sdk: PaymentRequestSdkMock(records: [paymentRequestRecord()])) + await manager.refresh() + let request = try XCTUnwrap(manager.pendingRequests.first) + let preparation = IncomingPaykitPaymentRequestPreparation(request: request, session: nil) + let task = Task { + do { + try await preparation.whilePreparing { + withUnsafeCurrentTask { $0?.cancel() } + } + XCTFail("Canceled work returned before its scheduled UI cleanup") + } catch is CancellationError { + } catch { + XCTFail("Unexpected preparation error: \(error)") + } + } + await task.value + } + + func testClosingPreparingSheetIgnoresLateCompletionAndLeavesNextRequestAvailable() async throws { + for manuallyRequested in [false, true] { + let sdk = try PaymentRequestSdkMock(records: [paymentRequestRecord(id: "first"), paymentRequestRecord(id: "second")]) + let store = PaymentRequestPresentationMemoryStore() + let manager = paymentRequestManager(sdk: sdk, presentationStore: store) + await manager.refresh() + let request = try XCTUnwrap(manager.pendingRequests.first { $0.paymentRequestId == "first" }) + let next = try XCTUnwrap(manager.pendingRequests.first { $0.paymentRequestId == "second" }) + if manuallyRequested { XCTAssertTrue(manager.requestPresentation(request)) } + let profile = PubkyProfileManager() + profile.publicKey = "pubky\(String(repeating: "z", count: 52))" + let session = try XCTUnwrap(profile.currentSession) + let preparation = IncomingPaykitPaymentRequestPreparation(request: request, session: session) + let sheets = SheetViewModel() + let app = AppViewModel() + let identity = try XCTUnwrap(profile.publicKey) + let context = ContactPaymentContext(publicKey: request.counterparty, incomingPaymentRequest: request) + XCTAssertTrue(app.claimContactPaymentContext(context)) + preparation.paymentContext = context + sheets.showSheet(.send, data: SendConfig(view: .confirm, preparation: preparation, onDismiss: { + manager.dismissPreparingRequest(request) + preparation.clear() + })) + var continuation: CheckedContinuation? + let task = Task { + await manager.presentRequests { _ in + await withCheckedContinuation { continuation = $0 } + XCTAssertFalse(preparation.complete(route: .confirm, manager: manager, session: session, app: app, sheets: sheets)) + } + } + try await waitUntil { continuation != nil } + + if manuallyRequested { + sheets.hideSheet() + } else { + sheets.sendSheetItem = nil + } + app.resetSendState() + XCTAssertEqual(Set(manager.pendingRequests.map(\.id)), [request.id, next.id]) + XCTAssertEqual(manager.requestsForPresentation(), [next]) + XCTAssertNil(manager.requestedPresentationId) + XCTAssertTrue(try store.load(identity: identity).isEmpty) + XCTAssertFalse(manager.isApprovedForPayment(request)) + + continuation?.resume() + _ = await task.value + await manager.presentRequests { XCTAssertEqual($0, [next]) } + await manager.refresh() + XCTAssertEqual(manager.requestsForPresentation(), [next]) + XCTAssertTrue(try store.load(identity: identity).isEmpty) + + let restored = paymentRequestManager(sdk: sdk, presentationStore: store) + await restored.refresh() + XCTAssertEqual(Set(restored.requestsForPresentation().map(\.id)), [request.id, next.id]) + XCTAssertTrue(manager.requestPresentation(request)) + XCTAssertEqual(manager.requestsForPresentation(), [request]) + await manager.presentRequests { _ in manager.dismissPreparingRequest(request) } + manager.activate(identity: "pubky\(String(repeating: "y", count: 52))") + manager.activate(identity: identity) + await manager.refresh() + XCTAssertEqual(Set(manager.requestsForPresentation().map(\.id)), [request.id, next.id]) + } + } + + func testPreparingRequestBecomesReadyInTheSameSendSheetAndPreservesExplicitRetry() async throws { + let store = PaymentRequestPresentationMemoryStore() + let manager = try paymentRequestManager(sdk: PaymentRequestSdkMock(records: [paymentRequestRecord()]), presentationStore: store) + await manager.refresh() + let request = try XCTUnwrap(manager.pendingRequests.first) + let profile = PubkyProfileManager() + profile.publicKey = "pubky\(String(repeating: "z", count: 52))" + let session = try XCTUnwrap(profile.currentSession) + let preparation = IncomingPaykitPaymentRequestPreparation(request: request, session: session) + let sheets = SheetViewModel() + let app = AppViewModel() + sheets.showSheet(.send, data: SendConfig(view: .confirm, preparation: preparation, onDismiss: { + if preparation.resolvedRoute == nil, preparation.matchesSession(profile.currentSession), let request = preparation.request { + manager.dismissPreparingRequest(request) + } + preparation.clear() + })) + let presentationID = sheets.activeSheetConfiguration?.presentationID + XCTAssertTrue(sheets.sendSheetItem?.preparation === preparation) + XCTAssertNil(app.contactPaymentContext) + XCTAssertFalse(app.hasSendPaymentTarget) + + await manager.presentRequests { _ in + XCTAssertFalse(preparation.complete(route: .confirm, manager: manager, session: session, app: app, sheets: sheets)) + let context = ContactPaymentContext(publicKey: request.counterparty, incomingPaymentRequest: request) + XCTAssertTrue(app.claimContactPaymentContext(context)) + preparation.paymentContext = context + let otherProfile = PubkyProfileManager() + otherProfile.publicKey = "pubky\(String(repeating: "y", count: 52))" + XCTAssertFalse(preparation.complete(route: .confirm, manager: manager, session: otherProfile.currentSession, app: app, sheets: sheets)) + app.scannedOnchainInvoice = OnChainInvoice( + address: "bcrt1qrequest", + amountSatoshis: request.amountSats, + label: nil, + message: nil, + params: nil + ) + XCTAssertTrue(preparation.complete(route: .confirm, manager: manager, session: session, app: app, sheets: sheets)) + } + XCTAssertEqual(preparation.resolvedRoute, .confirm) + XCTAssertEqual(sheets.activeSheetConfiguration?.presentationID, presentationID) + XCTAssertTrue(sheets.sendSheetItem?.preparation === preparation) + XCTAssertEqual(manager.requestsForPresentation(), [request]) + XCTAssertFalse(manager.isApprovedForPayment(request)) + XCTAssertTrue(try store.load(identity: "pubky\(String(repeating: "z", count: 52))").isEmpty) + XCTAssertTrue(manager.markPresentedIfPending(request)) + XCTAssertEqual(try store.load(identity: "pubky\(String(repeating: "z", count: 52))"), [request.id]) + + app.resetSendState() + XCTAssertTrue(manager.requestPresentation(request)) + sheets.hideSheet(reason: "Retrying incoming payment request with fresh private payment details") + + XCTAssertNil(preparation.request) + XCTAssertNil(sheets.activeSheetConfiguration) + XCTAssertEqual(manager.requestedPresentationId, request.id) + XCTAssertEqual(manager.requestsForPresentation(), [request]) + XCTAssertFalse(manager.isApprovedForPayment(request)) + XCTAssertEqual(try store.load(identity: "pubky\(String(repeating: "z", count: 52))"), [request.id]) } func testManualPresentationSupersedesInFlightAutomaticPresentation() async throws { @@ -4503,8 +4720,12 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { let manager = paymentRequestManager(sdk: PaymentRequestSdkMock(records: [first, second])) await manager.refresh() let secondRequest = try XCTUnwrap(manager.pendingRequests.first { $0.paymentRequestId == "second" }) + let profile = PubkyProfileManager() + profile.publicKey = "pubky\(String(repeating: "z", count: 52))" + let session = try XCTUnwrap(profile.currentSession) var continuation: CheckedContinuation? var automaticPresentationRemainedCurrent = true + var preparation: IncomingPaykitPaymentRequestPreparation? let task = Task { await manager.presentRequests { requests in @@ -4512,13 +4733,16 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { XCTFail("Expected an incoming payment request") return } + preparation = IncomingPaykitPaymentRequestPreparation(request: automaticRequest, session: session) await withCheckedContinuation { continuation = $0 } automaticPresentationRemainedCurrent = manager.isCurrentPresentation(automaticRequest) } } try await waitUntil { continuation != nil } + XCTAssertNotNil(preparation?.visibleRequest(manager: manager, session: session, paymentContext: nil)) XCTAssertTrue(manager.requestPresentation(secondRequest)) + XCTAssertNil(preparation?.visibleRequest(manager: manager, session: session, paymentContext: nil)) continuation?.resume() _ = await task.value @@ -5188,6 +5412,9 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { manager.eligibleTargets, [PaykitPaymentRequestTarget(publicKey: savedKey)] ) + let priorities = await sdk.operationPriorities + XCTAssertEqual(priorities["identity"], [.background]) + XCTAssertEqual(priorities["peers"], [.background]) } func testCapabilityDiscoveryBoundsConcurrentReadsWithoutWaitingForSlowPeer() async throws { @@ -5851,6 +6078,10 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { } == true) XCTAssertEqual(snapshot.processCallCount, 0) XCTAssertEqual(snapshot.processedCounterparties, [publicKey]) + let priorities = await sdk.operationPriorities + XCTAssertEqual(priorities["identity"], [.background, .interactive]) + XCTAssertEqual(priorities["peers"], [.background, .interactive]) + XCTAssertEqual(priorities["delivery"], [.interactive]) } func testProposalsOnlyInspectAndDrainSelectedSavedTarget() async throws { @@ -6381,6 +6612,7 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { completedPaymentProofKinds: [PaykitPaymentRequest.ID: PaykitPaymentProofKind] = [:], inFlightPaymentRequestIds: Set = [], protectedRequestIdsForSubscriptionCancellation: Set = [], + presentationStore: PaymentRequestPresentationMemoryStore = PaymentRequestPresentationMemoryStore(), acceptanceStore: PaymentRequestPresentationMemoryStore? = nil, acceptedRecords: [PaymentRequestRecord] = [] ) -> PaykitPaymentRequestManager { @@ -6397,7 +6629,7 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { isPrivatePaymentPublishingEnabled: { isPrivatePaymentPublishingEnabled }, logWarning: { _ in } ), - presentationStore: PaymentRequestPresentationMemoryStore(), + presentationStore: presentationStore, acceptanceStore: acceptanceStore, subscriptionStateStore: subscriptionStateStore, subscriptionNotificationScheduler: subscriptionNotificationScheduler, @@ -6593,6 +6825,7 @@ private final class PaymentRequestSubscriptionStateMemoryStore: PaykitSubscripti } private actor PaymentRequestSdkMock: PaykitPaymentRequestSdkHandling, PaykitPaymentProofSdkHandling { + private(set) var operationPriorities: [String: [PaykitSdkOperationLock.Priority]] = [:] private var activeIdentity = "pubky\(String(repeating: "z", count: 52))" private var records: [PaymentRequestRecord] private var incomingRecords: [PaymentRequestRecord] = [] @@ -6664,6 +6897,11 @@ private actor PaymentRequestSdkMock: PaykitPaymentRequestSdkHandling, PaykitPaym ) } + func processOutboundPrivateMessages(counterparty: String, priority: PaykitSdkOperationLock.Priority) async throws -> OutboundPrivateSendReport { + operationPriorities["delivery", default: []].append(priority) + return try await processOutboundPrivateMessages(counterparty: counterparty) + } + private func processMessages(counterparty: String? = nil) async throws { if shouldPauseNextProcess, counterparty == nil || pausedProcessCounterparty == nil || counterparty == pausedProcessCounterparty @@ -6708,6 +6946,11 @@ private actor PaymentRequestSdkMock: PaykitPaymentRequestSdkHandling, PaykitPaym return snapshot } + func sharedPaymentRequests(priority: PaykitSdkOperationLock.Priority) async -> [PaymentRequestRecord] { + operationPriorities["requests", default: []].append(priority) + return await sharedPaymentRequests() + } + func sharedPaymentRequests(expectedIdentity: String) async throws -> [PaymentRequestRecord] { guard PubkyPublicKeyFormat.matches(activeIdentity, expectedIdentity) else { throw PaykitPaymentRequestError.requestUnavailable @@ -6724,6 +6967,11 @@ private actor PaymentRequestSdkMock: PaykitPaymentRequestSdkHandling, PaykitPaym IdentityStatus(publicKey: activeIdentity, capability: liveSessionAvailable ? .privateLinkCapable : .signedOut) } + func identityStatus(priority: PaykitSdkOperationLock.Priority) -> IdentityStatus? { + operationPriorities["identity", default: []].append(priority) + return identityStatus() + } + func submitPaymentProof( counterparty _: String, paymentRequestId _: String, @@ -6747,6 +6995,11 @@ private actor PaymentRequestSdkMock: PaykitPaymentRequestSdkHandling, PaykitPaym return snapshot } + func linkedPeers(priority: PaykitSdkOperationLock.Priority) async throws -> [LinkedPeerRecord] { + operationPriorities["peers", default: []].append(priority) + return try await linkedPeers() + } + func canReceivePaymentRequests(publicKey: String, priority: PaykitPublicReadPriority) async throws -> Bool { capabilityLookupPublicKeys.append(publicKey) capabilityLookupPriorities.append(priority) diff --git a/BitkitTests/SendConfirmationSwipeTests.swift b/BitkitTests/SendConfirmationSwipeTests.swift index ece2ce5bf..bb08aa77e 100644 --- a/BitkitTests/SendConfirmationSwipeTests.swift +++ b/BitkitTests/SendConfirmationSwipeTests.swift @@ -2,6 +2,24 @@ import XCTest final class SendConfirmationSwipeTests: XCTestCase { + func testPreparingRequestKeepsSwipeDisabledAndLoadingWithKnownFees() { + for walletType in [WalletType.lightning, .onchain] { + XCTAssertTrue(SendConfirmationView.isSwipeDisabled( + walletType: walletType, + isHardwarePayment: false, + isHardwareConfirmationUnavailable: false, + feeRate: 5, + isPreparingRequest: true + )) + } + XCTAssertTrue(SendConfirmationView.isSwipeLoading( + hasStartedAutomaticPayment: false, + isFeeRateMissing: false, + feeRateLoadFailed: false, + isPreparingRequest: true + )) + } + func testOnchainPaymentWithoutFeeRateDisablesSwipe() { XCTAssertTrue(SendConfirmationView.isSwipeDisabled( walletType: .onchain, diff --git a/changelog.d/next/856.changed.md b/changelog.d/next/856.changed.md index b49ee46bd..ca6d3e12f 100644 --- a/changelog.d/next/856.changed.md +++ b/changelog.d/next/856.changed.md @@ -1 +1 @@ -Share Paykit contacts and payment state with authorized apps while keeping wallet keys private, preserve disabled contact sharing during cleanup failures, prevent paid subscription periods from being offered again, show confirmed proposal delivery accurately, reduce repeated shared-state reads, and label received payments with their Paykit payer contact while preserving manual contact removal through backup and restore. +Share Paykit contacts and payment state with authorized apps while keeping wallet keys private, preserve disabled contact sharing during cleanup failures, prevent paid subscription periods from being offered again, show confirmed proposal delivery accurately, show incoming requests in the payment sheet while preparing, prioritize foreground payment actions, reduce repeated shared-state reads, and label received payments with their Paykit payer contact while preserving manual contact removal through backup and restore. diff --git a/journeys/payment-requests/README.md b/journeys/payment-requests/README.md index f30540d07..0d50b6e26 100644 --- a/journeys/payment-requests/README.md +++ b/journeys/payment-requests/README.md @@ -37,6 +37,15 @@ explicit refreshes, and maintenance rounds after 30 seconds, then every 60 secon Maintenance uses elapsed time, including slow requests; polling remains serialized and does not start catch-up rounds. Incoming messages wait for the next poll plus synchronization time. +During preparation, `automatic-presentation.xml` checks the existing Send confirmation sheet with +the selected request's sender, amount and note. Its payment swipe control stays disabled and shows +progress until fresh validation and wallet preparation finish. The same sheet then becomes usable. +Closing during preparation leaves the request pending +and manually reopenable, but suppresses automatic reopening for the current identity's app session. +That suppression is not persisted. The request is marked presented only once ready. +`requested-resolution-failure.xml` also checks the return to the underlying request list or details. +An unfunded wallet can verify loading followed by wallet rejection, not an enabled payment control. + ## Accepting install Acceptance intent is saved before the remote operation and included in wallet backups. @@ -141,6 +150,8 @@ the SDK and can push the Pay step well past the budget. - Screen: `PaymentRequestsScreen`. - Detail screen: `PaymentRequestDetailScreen`. - Detail amount and status: `PaymentRequestDetailsAmount`, `PaymentRequestDetailsStatus`. +- Detail Pay action: `PaymentRequestDetailsPay`. +- Payment swipe control, including its loading spinner: `GRAB`. - Request row: `PaymentRequestRow--one-time`; use the complete identifier because `wait-for-ui` does not support prefix matching. - Pay action: `PaymentRequestPay-`. - Dismiss action: `PaymentRequestDismiss-`. diff --git a/journeys/payment-requests/automatic-presentation.xml b/journeys/payment-requests/automatic-presentation.xml index 8d198c125..cc7ea79ed 100644 --- a/journeys/payment-requests/automatic-presentation.xml +++ b/journeys/payment-requests/automatic-presentation.xml @@ -5,19 +5,28 @@ On the payer, leave Bitkit unlocked and in the foreground on Home On the requester, send the payer contact a Payment Request for 21,000 sats with the note "First request" - On the payer, wait for the next private-message sync and verify Payment Request confirmation (id "PaymentRequestConfirm") appears automatically with 21,000 sats and For shows "First request" + While the payer prepares the request, verify the existing Payment Request confirmation sheet (id "PaymentRequestConfirm") shows the requester's contact name, 21,000 sats and "First request", with the payment swipe control (id "GRAB") disabled and showing progress + Close the confirmation while preparation is still pending + Allow preparation and another private-message sync to finish; verify the request does not reopen automatically + Open Payment Requests, verify "First request" is still pending, and tap Pay on it + Verify the existing Payment Request confirmation sheet shows the same sender, amount and note with its payment control disabled and showing progress + Wait for fresh validation and wallet preparation to finish; verify that the same sheet remains open and its payment swipe control becomes available without dismissing and reopening the sheet Close the confirmation without paying On the payer, open Receive and leave its sheet open On the requester, send the payer contact another Payment Request for 5,000 sats with the note "Second request" Wait for the requester's send operation to finish and for the payer to synchronize its pending requests On the payer, verify Receive stays open without being replaced by confirmation + Verify no preparing payment sheet or confirmation covers Receive Close Receive Verify Payment Request confirmation (id "PaymentRequestConfirm") appears automatically with 5,000 sats and For shows "Second request" Close the confirmation without paying Open Receive and close it again Verify Home remains visible and neither reviewed request reopens automatically + On the payer, open the Home menu (id "HeaderMenu") before the next request arrives On the requester, send a Payment Request above the payer's available balance with the note "Unaffordable request" - On the payer, verify an insufficient-balance toast appears once and no payment confirmation opens + Wait for the payer to synchronize the request and verify no preparing payment sheet or confirmation covers the open drawer + Close the drawer and verify the existing Payment Request confirmation sheet shows the request metadata with its payment control disabled and showing progress + On the payer, verify an insufficient-balance toast appears once and the preparing confirmation closes without enabling payment Allow another pending-request sync, then open Receive and close it; verify the unaffordable request does not automatically prepare again or repeat its toast Open Payment Requests and tap Pay on "Unaffordable request"; verify this explicit retry shows the insufficient-balance toast again diff --git a/journeys/payment-requests/issuer-interoperability.xml b/journeys/payment-requests/issuer-interoperability.xml index 32a24274d..a873eb4d7 100644 --- a/journeys/payment-requests/issuer-interoperability.xml +++ b/journeys/payment-requests/issuer-interoperability.xml @@ -17,9 +17,9 @@ Tap the pending Payment Requests bell (id "PaymentRequestsBell") Verify the incoming Payment Requests sheet (id "PaymentRequestsSheet") appears Tap Pay (id "PaymentRequestPay-71300000-0000-4000-8000-000000000001") - If confirmation is not already open, verify progress (id "PaymentRequestPreparing") is visible while the request is being prepared + Verify the existing Payment Request confirmation sheet shows 100,000 sats with its payment swipe control (id "GRAB") disabled and showing progress while the request is prepared Verify the Payment Request confirmation screen (id "PaymentRequestConfirm") shows 100,000 sats - Verify progress (id "PaymentRequestPreparing") is no longer visible + Wait for fresh validation and wallet preparation to finish; verify the same sheet stays open and its payment swipe control stops showing progress Tap Show details (id "SendConfirmToggleDetails") Verify the recipient (id "ReviewContactRecipient") is the saved fixture issuer contact diff --git a/journeys/payment-requests/requested-resolution-failure.xml b/journeys/payment-requests/requested-resolution-failure.xml index 557461ca7..ccff48242 100644 --- a/journeys/payment-requests/requested-resolution-failure.xml +++ b/journeys/payment-requests/requested-resolution-failure.xml @@ -3,6 +3,8 @@ Verifies a user explicitly opening an incoming Payment Request receives localized terminal feedback after resolution retries exhaust, while the request remains available for another attempt. Dev settings must be available for the final availability check. + An explicit Pay opens the existing Send confirmation sheet with known request metadata while + resolution runs; the underlying request list or details remain available after it closes. Precondition: onboarded dev wallet with Paykit UI enabled, a profile, and one linked saved contact. Seed exactly one proposed incoming Payment Request from that contact with a known @@ -13,14 +15,16 @@ Verify the incoming request row (id "PaymentRequestRow-<payment-request-id>-one-time") is visible Tap Pay (id "PaymentRequestPay-<payment-request-id>") - Verify progress (id "PaymentRequestPreparing") is visible while the request is being prepared + Verify the existing Payment Request confirmation sheet (id "PaymentRequestConfirm") shows the sender, amount and note, with the payment swipe control (id "GRAB") disabled and showing progress while the request is prepared Wait up to 35 seconds for the terminal error toast (id "PaymentRequestUnavailableToast") - Verify progress (id "PaymentRequestPreparing") is no longer visible + Verify the preparing confirmation sheet is no longer visible Verify the toast title is "Payment Request" and its description is "The payment request is no longer available." Verify Payment Requests (id "PaymentRequestsScreen") remains visible Verify the incoming request row (id "PaymentRequestRow-<payment-request-id>-one-time") remains visible for a later retry - Start another Pay attempt and verify progress (id "PaymentRequestPreparing") appears + Open the incoming request row and tap Pay in its details (id "PaymentRequestDetailsPay") + Verify the existing Payment Request confirmation sheet shows the same sender, amount and note, with the payment swipe control disabled and showing progress + Close the preparing confirmation and verify the underlying details remain available and the request does not immediately reopen While preparation is pending, navigate to Dev settings and turn off Enable Paykit UI (id "PaykitUiToggle") - Return to the wallet home screen and verify progress (id "PaymentRequestPreparing") is absent and no payment sheet opens + Return to the wallet home screen and verify no payment sheet opens From ec786b13d742023d76511fbfd0afd1dca2fbf200 Mon Sep 17 00:00:00 2001 From: benk10 Date: Tue, 6 Oct 2026 01:24:42 -0500 Subject: [PATCH 56/68] fix: reject unavailable prepared payment requests --- Bitkit/AppScene.swift | 13 --- .../PaykitPaymentRequestService.swift | 6 +- .../Views/Wallets/Send/LnurlPayConfirm.swift | 3 + .../Wallets/Send/SendConfirmationView.swift | 3 + Bitkit/Views/Wallets/Send/SendSheet.swift | 28 ++++++ .../PaykitPaymentRequestServiceTests.swift | 95 +++++++++++++++++++ 6 files changed, 134 insertions(+), 14 deletions(-) diff --git a/Bitkit/AppScene.swift b/Bitkit/AppScene.swift index fcb10e146..843472bfb 100644 --- a/Bitkit/AppScene.swift +++ b/Bitkit/AppScene.swift @@ -573,19 +573,6 @@ struct AppScene: View { .onChange(of: incomingPaykitPaymentRequestPresentationState) { previous, current in handleIncomingPaykitPaymentRequestPresentationStateChange(from: previous, to: current) } - .onChange(of: paykitPaymentRequestManager.pendingRequests) { _, requests in - guard let request = app.contactPaymentContext?.incomingPaymentRequest, - !requests.contains(where: { $0.id == request.id }), - !paykitPaymentRequestManager.isApprovedForPayment(request) - else { return } - - let activeSheetId = sheets.activeSheetConfiguration?.id - guard activeSheetId == .send || - (activeSheetId == .subscription && app.contactPaymentContext?.isInitialSubscriptionPayment == true) - else { return } - - sheets.hideSheet(reason: "Incoming payment request is no longer available") - } .onChange(of: navigation.currentRoute) { oldRoute, newRoute in guard shouldDiscardPendingImport(currentRoute: oldRoute, destination: newRoute) else { return diff --git a/Bitkit/Services/PaykitPaymentRequestService.swift b/Bitkit/Services/PaykitPaymentRequestService.swift index f0acbffc2..108e49cee 100644 --- a/Bitkit/Services/PaykitPaymentRequestService.swift +++ b/Bitkit/Services/PaykitPaymentRequestService.swift @@ -2024,7 +2024,11 @@ final class PaykitPaymentRequestManager { func isApprovedForPayment(_ request: PaykitPaymentRequest) -> Bool { guard approvedPaymentRequestIds.contains(request.id) else { return false } - guard let billingPeriod = request.billingPeriod else { return true } + guard let billingPeriod = request.billingPeriod else { + return historyRequests.contains { + $0.id == request.id && $0.direction == .incoming && $0.lifecycleState == .accepted + } + } return subscriptions.contains { request.belongs(to: $0) && $0.lifecycleState == .activeRecurring && !$0.paidPeriods.contains(billingPeriod) } diff --git a/Bitkit/Views/Wallets/Send/LnurlPayConfirm.swift b/Bitkit/Views/Wallets/Send/LnurlPayConfirm.swift index fa23d9878..b8182b7a1 100644 --- a/Bitkit/Views/Wallets/Send/LnurlPayConfirm.swift +++ b/Bitkit/Views/Wallets/Send/LnurlPayConfirm.swift @@ -11,6 +11,7 @@ struct LnurlPayConfirm: View { @EnvironmentObject var settings: SettingsViewModel @Binding var navigationPath: [SendRoute] + @Binding var isSubmittingPayment: Bool let requestPinCheck: () async -> Bool let prepareIncomingPaymentRequest: () async throws -> Void let routingCacheResetAttempted: Bool @@ -259,6 +260,8 @@ struct LnurlPayConfirm: View { } private func performPayment() async throws { + isSubmittingPayment = true + defer { isSubmittingPayment = false } guard let lnurlPayData = app.lnurlPayData else { throw NSError(domain: "LNURL", code: -1, userInfo: [NSLocalizedDescriptionKey: "Missing LNURL pay data"]) } diff --git a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift index 626dc27c8..6c78821c6 100644 --- a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift +++ b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift @@ -16,6 +16,7 @@ struct SendConfirmationView: View { @Environment(HwWalletManager.self) private var hwWalletManager @Binding var navigationPath: [SendRoute] + @Binding var isSubmittingPayment: Bool let hwSend: HwSendCoordinator let requestPinCheck: () async -> Bool let prepareIncomingPaymentRequest: () async throws -> Void @@ -940,6 +941,8 @@ struct SendConfirmationView: View { } private func performPayment(isAutomatic: Bool) async throws { + isSubmittingPayment = true + defer { isSubmittingPayment = false } var createdMetadataPaymentId: String? = nil let contactPaymentContext = app.contactPaymentContext let contactPublicKey = contactPaymentContext?.publicKey diff --git a/Bitkit/Views/Wallets/Send/SendSheet.swift b/Bitkit/Views/Wallets/Send/SendSheet.swift index f43be60b0..a49a4f5f5 100644 --- a/Bitkit/Views/Wallets/Send/SendSheet.swift +++ b/Bitkit/Views/Wallets/Send/SendSheet.swift @@ -129,6 +129,7 @@ struct SendSheet: View { @State private var quickPaySession = 0 @State private var hasValidatedAfterSync = false @State private var incomingPaymentRequest: PaykitPaymentRequest? + @State private var isSubmittingPayment = false @State private var pendingEmbeddedRetryRoute: SendRoute? @State private var routingCacheResetAttempted = false @State private var syncTimedOut = false @@ -151,6 +152,27 @@ struct SendSheet: View { config.preparation != nil && incomingPaymentRequest == nil } + private var shouldDismissUnavailableRequest: Bool { + guard let request = incomingPaymentRequest else { return false } + return Self.shouldDismissUnavailableRequest( + root: currentRoot, + path: navigationPath, + isSubmittingPayment: isSubmittingPayment, + isAvailable: paykitPaymentRequestManager.pendingRequests.contains(where: { $0.id == request.id }) || + paykitPaymentRequestManager.isApprovedForPayment(request) + ) + } + + static func shouldDismissUnavailableRequest(root: SendRoute, path: [SendRoute], isSubmittingPayment: Bool, isAvailable: Bool) -> Bool { + guard !isAvailable, !isSubmittingPayment else { return false } + switch path.last ?? root { + case .confirm, .lnurlPayConfirm, .amount, .utxoSelection, .feeRate, .feeCustom, .tag, .pin: + return true + default: + return false + } + } + /// How long the sync overlay may wait for channels to become usable before falling back private static let syncTimeoutSeconds: TimeInterval = 20 @@ -217,6 +239,10 @@ struct SendSheet: View { HardwarePairingSheet(config: HardwarePairingSheetItem()) } .offlineSheetOverlay(title: t("wallet__send_bitcoin"), forceShow: syncTimedOut, isEnabled: !isPreparingRequest) + .onChange(of: shouldDismissUnavailableRequest) { _, shouldDismiss in + guard shouldDismiss else { return } + sheets.hideSheetIfActive(isEmbedded ? .subscription : .send, reason: "Incoming payment request is no longer available") + } .onChange(of: shouldShowSyncOverlay, initial: true) { _, isShowing in Logger.debug("shouldShowSyncOverlay: \(isShowing) (node: \(wallet.nodeLifecycleState))", context: "SendSheet") } @@ -705,6 +731,7 @@ struct SendSheet: View { case .confirm: SendConfirmationView( navigationPath: $navigationPath, + isSubmittingPayment: $isSubmittingPayment, hwSend: hwSend, requestPinCheck: requestPinCheck, prepareIncomingPaymentRequest: prepareIncomingPaymentRequest, @@ -775,6 +802,7 @@ struct SendSheet: View { case .lnurlPayConfirm: LnurlPayConfirm( navigationPath: $navigationPath, + isSubmittingPayment: $isSubmittingPayment, requestPinCheck: requestPinCheck, prepareIncomingPaymentRequest: prepareIncomingPaymentRequest, routingCacheResetAttempted: routingCacheResetAttempted diff --git a/BitkitTests/PaykitPaymentRequestServiceTests.swift b/BitkitTests/PaykitPaymentRequestServiceTests.swift index 77b87a909..93a8b8cce 100644 --- a/BitkitTests/PaykitPaymentRequestServiceTests.swift +++ b/BitkitTests/PaykitPaymentRequestServiceTests.swift @@ -2,6 +2,7 @@ import BitkitCore import Foundation import LDKNode +import Observation import Paykit import UIKit import UserNotifications @@ -2446,6 +2447,16 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { XCTAssertEqual(manager.historyRequests.first?.id, request.id) XCTAssertEqual(manager.historyRequests.first?.lifecycleState, .proofSubmitted) XCTAssertEqual(manager.historyRequests.first?.paymentProofKind, proofKind) + let isAvailable = manager.pendingRequests.contains { $0.id == request.id } || manager.isApprovedForPayment(request) + XCTAssertFalse(isAvailable) + XCTAssertFalse(SendSheet.shouldDismissUnavailableRequest( + root: .confirm, path: [], isSubmittingPayment: true, isAvailable: isAvailable + )) + for result in [SendRoute.pending(paymentHash: nil, retryRoute: .confirm, paymentRequest: nil), .success(paymentId: "payment")] { + XCTAssertFalse(SendSheet.shouldDismissUnavailableRequest( + root: .confirm, path: [result], isSubmittingPayment: false, isAvailable: isAvailable + )) + } } } @@ -4930,6 +4941,90 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { XCTAssertTrue(try store.load(identity: identity).isEmpty) } + func testPreparedOneTimePaymentRequiresAcceptedHistory() async throws { + let states: [PaymentRequestLifecycleState?] = [.accepted, .canceled, .proofSubmitted, .rejected, nil] + for state in states { + let isAccepted = state == .accepted + let sdk = try PaymentRequestSdkMock(records: [paymentRequestRecord()]) + let manager = paymentRequestManager(sdk: sdk) + await manager.refresh() + let request = try XCTUnwrap(manager.pendingRequests.first) + try await manager.prepareForPayment(request) + XCTAssertTrue(manager.isApprovedForPayment(request)) + XCTAssertTrue(manager.pendingRequests.isEmpty) + await sdk.pauseNextLinkedPeers() + + let authorization = Task { try await manager.ensurePaymentAllowed(request) } + try await waitUntil { await sdk.linkedPeersIsPaused() } + let approvalChanged = isAccepted ? nil : expectation(description: "Prepared request history changed") + XCTAssertTrue(withObservationTracking { + manager.isApprovedForPayment(request) + } onChange: { + approvalChanged?.fulfill() + }) + let records = try state.map { try [paymentRequestRecord(state: $0)] } ?? [] + await sdk.setRecords(records) + await manager.refresh(mode: .stored) + if let approvalChanged { + await fulfillment(of: [approvalChanged], timeout: 1) + } + XCTAssertTrue(manager.pendingRequests.isEmpty) + XCTAssertEqual(manager.isApprovedForPayment(request), isAccepted, "\(String(describing: state))") + for root in [SendRoute.confirm, .lnurlPayConfirm] { + let isAvailable = manager.isApprovedForPayment(request) + XCTAssertEqual(SendSheet.shouldDismissUnavailableRequest( + root: root, path: [], isSubmittingPayment: false, isAvailable: isAvailable + ), !isAccepted) + XCTAssertFalse(SendSheet.shouldDismissUnavailableRequest( + root: root, path: [], isSubmittingPayment: true, isAvailable: isAvailable + )) + let retainedRoutes: [SendRoute] = [ + .pending(paymentHash: nil, retryRoute: .confirm, paymentRequest: nil), + .success(paymentId: "payment"), + .failure(SendFailureContext(error: PaykitPaymentRequestError.requestUnavailable, retryRoute: .confirm)), + .hardwareSign, + .quickpay, + ] + for result in retainedRoutes { + XCTAssertFalse(SendSheet.shouldDismissUnavailableRequest( + root: root, path: [result], isSubmittingPayment: false, isAvailable: isAvailable + )) + XCTAssertFalse(SendSheet.shouldDismissUnavailableRequest( + root: result, path: [], isSubmittingPayment: false, isAvailable: isAvailable + )) + } + } + await sdk.resumeLinkedPeers() + + do { + try await authorization.value + XCTAssertTrue(isAccepted, "Refreshed one-time history must still be accepted") + } catch { + XCTAssertFalse(isAccepted) + XCTAssertEqual(error as? PaykitPaymentRequestError, .requestUnavailable) + } + guard !isAccepted else { continue } + + let peerReads = await sdk.linkedPeersCalls() + do { + try await manager.ensurePaymentAllowed(request) + XCTFail("Unavailable history must not authorize payment") + } catch { + XCTAssertEqual(error as? PaykitPaymentRequestError, .requestUnavailable) + } + let peerReadsAfterRejection = await sdk.linkedPeersCalls() + XCTAssertEqual(peerReadsAfterRejection, peerReads) + do { + try await manager.prepareForPayment(request) { + XCTFail("Unavailable history must not consume another payment destination") + } + XCTFail("Unavailable history must not be prepared again") + } catch { + XCTAssertEqual(error as? PaykitPaymentRequestError, .requestUnavailable) + } + } + } + func testIdentitySwitchPreventsExecutionOfPreparedProposal() async throws { for switchDuringAuthorization in [false, true] { let sdk = try PaymentRequestSdkMock(records: [paymentRequestRecord()]) From 9cb87b840237a35fb9394ab79d8ee03af7eb32f7 Mon Sep 17 00:00:00 2001 From: benk10 Date: Tue, 6 Oct 2026 01:27:39 -0500 Subject: [PATCH 57/68] fix: prioritize incoming requests during polling --- Bitkit/AppScene.swift | 29 +++++--- .../PaykitPaymentRequestService.swift | 45 ++++++++---- Bitkit/Services/PubkyService.swift | 12 +++- .../PaykitPaymentRequestServiceTests.swift | 72 ++++++++++++++----- 4 files changed, 115 insertions(+), 43 deletions(-) diff --git a/Bitkit/AppScene.swift b/Bitkit/AppScene.swift index 843472bfb..69859665b 100644 --- a/Bitkit/AppScene.swift +++ b/Bitkit/AppScene.swift @@ -1176,7 +1176,8 @@ struct AppScene: View { private func refreshIncomingPaykitPaymentRequests( presentItems: Bool = true, mode: PaykitPaymentRequestRefreshMode = .full, - forceFresh: Bool = false + forceFresh: Bool = false, + messagePriority: PaykitSdkOperationLock.Priority = .ordered ) async { guard PaykitFeatureFlags.isUIEnabled, wallet.walletExists == true, @@ -1192,7 +1193,7 @@ struct AppScene: View { await PaykitPaymentProofService.shared.reconcile() } guard let identity = pubkyProfile.publicKey else { return } - await paykitPaymentRequestManager.refresh(mode: mode, forceFresh: forceFresh) + await paykitPaymentRequestManager.refresh(mode: mode, forceFresh: forceFresh, messagePriority: messagePriority) guard pubkyProfile.authState == .authenticated, PubkyPublicKeyFormat.matches(identity, pubkyProfile.publicKey) else { return } @@ -1248,7 +1249,7 @@ struct AppScene: View { guard scenePhase == .active, network.isConnected else { return } await PubkyService.republishIdentityIfNeeded(publicKey: pubkyProfile.publicKey) - await refreshIncomingPaykitPaymentRequests() + await refreshIncomingPaykitPaymentRequests(messagePriority: .background) var schedule = PaykitPaymentRequestPollingSchedule() while !Task.isCancelled { do { @@ -1268,15 +1269,27 @@ struct AppScene: View { case .refreshInboxAndMaintenance: mode = .full } + let session = pubkyProfile.currentSession if mode == .full { await PubkyService.republishIdentityIfNeeded(publicKey: pubkyProfile.publicKey) await retryPendingPaykitEndpointRemoval() - await PrivatePaykitService.shared.refreshKnownSavedContactEndpoints( - wallet: wallet, - reason: "payment request polling" - ) } - await refreshIncomingPaykitPaymentRequests(mode: mode) + await refreshIncomingPaykitPaymentRequests(mode: mode, messagePriority: .background) + guard mode == .full, + !Task.isCancelled, + scenePhase == .active, + network.isConnected, + PaykitFeatureFlags.isUIEnabled, + let session, session == pubkyProfile.currentSession, + !app.showDrawer, + sheets.activeSheetConfiguration == nil, + !sheets.isReplacingSheet, + app.contactPaymentContext == nil + else { continue } + await PrivatePaykitService.shared.refreshKnownSavedContactEndpoints( + wallet: wallet, + reason: "payment request polling" + ) } } diff --git a/Bitkit/Services/PaykitPaymentRequestService.swift b/Bitkit/Services/PaykitPaymentRequestService.swift index 108e49cee..8ada577eb 100644 --- a/Bitkit/Services/PaykitPaymentRequestService.swift +++ b/Bitkit/Services/PaykitPaymentRequestService.swift @@ -494,8 +494,9 @@ protocol PaykitPaymentRequestSdkHandling: Sendable { func processOutboundPrivateMessages(counterparty: String) async throws -> Paykit.OutboundPrivateSendReport func processOutboundPrivateMessages(counterparty: String, priority: PaykitSdkOperationLock.Priority) async throws -> Paykit .OutboundPrivateSendReport - func processPendingPrivateMessages() async throws -> [Paykit.OutboundPrivateCounterpartySendReport] - func receivePrivateMessagesFromLinkedPeers() async throws -> [Paykit.PrivateStreamCounterpartyIntakeReport] + func processPendingPrivateMessages(priority: PaykitSdkOperationLock.Priority) async throws -> [Paykit.OutboundPrivateCounterpartySendReport] + func receivePrivateMessagesFromLinkedPeers(priority: PaykitSdkOperationLock.Priority) async throws + -> [Paykit.PrivateStreamCounterpartyIntakeReport] func paymentRequests() async throws -> [Paykit.PaymentRequestRecord] func sharedPaymentRequests() async throws -> [Paykit.PaymentRequestRecord] func sharedPaymentRequests(priority: PaykitSdkOperationLock.Priority) async throws -> [Paykit.PaymentRequestRecord] @@ -564,13 +565,14 @@ struct PaykitPaymentRequestService { func synchronize( mode: PaykitPaymentRequestRefreshMode = .full, - priority: PaykitSdkOperationLock.Priority = .ordered + priority: PaykitSdkOperationLock.Priority = .ordered, + messagePriority: PaykitSdkOperationLock.Priority = .ordered ) async throws -> PaykitPaymentRequestSnapshot { if mode == .full { - try await processPendingMessages() + try await processPendingMessages(priority: messagePriority) } if mode >= .inbox { - let intakeReports = try await sdk.receivePrivateMessagesFromLinkedPeers() + let intakeReports = try await sdk.receivePrivateMessagesFromLinkedPeers(priority: messagePriority) logIntakeFailures(intakeReports) } let synchronizationDate = now() @@ -994,7 +996,10 @@ struct PaykitPaymentRequestService { } @discardableResult - private func processPendingMessages(to counterparty: String? = nil) async throws -> [Paykit.OutboundPrivateCounterpartySendReport] { + private func processPendingMessages( + to counterparty: String? = nil, + priority: PaykitSdkOperationLock.Priority = .ordered + ) async throws -> [Paykit.OutboundPrivateCounterpartySendReport] { do { let reports: [Paykit.OutboundPrivateCounterpartySendReport] if let counterparty { @@ -1005,7 +1010,7 @@ struct PaykitPaymentRequestService { error: nil )] } else { - reports = try await sdk.processPendingPrivateMessages() + reports = try await sdk.processPendingPrivateMessages(priority: priority) } for report in reports { if let error = report.error { @@ -1607,8 +1612,17 @@ final class PaykitPaymentRequestManager { return subscription } - func refresh(mode: PaykitPaymentRequestRefreshMode = .full, forceFresh: Bool = false) async { - await refresh(excludingProtectedRequestId: nil, mode: mode, forceFresh: forceFresh) + func refresh( + mode: PaykitPaymentRequestRefreshMode = .full, + forceFresh: Bool = false, + messagePriority: PaykitSdkOperationLock.Priority = .ordered + ) async { + await refresh( + excludingProtectedRequestId: nil, + mode: mode, + forceFresh: forceFresh, + messagePriority: forceFresh ? .ordered : messagePriority + ) } /// Applies a changed subscription clock offset: waits for a refresh already reading the old clock, then refreshes again. @@ -1632,13 +1646,14 @@ final class PaykitPaymentRequestManager { private func refresh( excludingProtectedRequestId: PaykitPaymentRequest.ID?, mode: PaykitPaymentRequestRefreshMode = .full, - forceFresh: Bool = false + forceFresh: Bool = false, + messagePriority: PaykitSdkOperationLock.Priority = .ordered ) async { if let refreshTask { let generation = stateGeneration await refreshTask.task.value if forceFresh || mode > refreshTask.mode, generation == stateGeneration, !Task.isCancelled { - await refresh(excludingProtectedRequestId: excludingProtectedRequestId, mode: mode) + await refresh(excludingProtectedRequestId: excludingProtectedRequestId, mode: mode, messagePriority: messagePriority) } return } @@ -1650,7 +1665,8 @@ final class PaykitPaymentRequestManager { await performRefresh( generation: generation, excludingProtectedRequestId: excludingProtectedRequestId, - mode: mode + mode: mode, + messagePriority: messagePriority ) guard generation == refreshGeneration else { return } refreshTask = nil @@ -2202,10 +2218,11 @@ final class PaykitPaymentRequestManager { private func performRefresh( generation: Int, excludingProtectedRequestId: PaykitPaymentRequest.ID?, - mode: PaykitPaymentRequestRefreshMode + mode: PaykitPaymentRequestRefreshMode, + messagePriority: PaykitSdkOperationLock.Priority ) async { do { - let snapshot = try await service.synchronize(mode: mode, priority: .background) + let snapshot = try await service.synchronize(mode: mode, priority: .background, messagePriority: messagePriority) guard generation == refreshGeneration, let activeIdentity else { return } async let completedProofKinds = completedPaymentProofKinds(activeIdentity) async let inFlightRequestIds = inFlightPaymentRequestIds(activeIdentity) diff --git a/Bitkit/Services/PubkyService.swift b/Bitkit/Services/PubkyService.swift index cc64804c2..183e7ce87 100644 --- a/Bitkit/Services/PubkyService.swift +++ b/Bitkit/Services/PubkyService.swift @@ -895,7 +895,11 @@ actor PaykitSdkService { @discardableResult func receivePrivateMessagesFromLinkedPeers() async throws -> [PrivateStreamCounterpartyIntakeReport] { - try await withStateRevisionTracking { sdk in + try await receivePrivateMessagesFromLinkedPeers(priority: .ordered) + } + + func receivePrivateMessagesFromLinkedPeers(priority: PaykitSdkOperationLock.Priority) async throws -> [PrivateStreamCounterpartyIntakeReport] { + try await withStateRevisionTracking(priority: priority) { sdk in try await sdk.receivePrivateMessagesFromLinkedPeers() } } @@ -923,7 +927,11 @@ actor PaykitSdkService { @discardableResult func processPendingPrivateMessages() async throws -> [OutboundPrivateCounterpartySendReport] { - try await withStateRevisionTracking { sdk in + try await processPendingPrivateMessages(priority: .ordered) + } + + func processPendingPrivateMessages(priority: PaykitSdkOperationLock.Priority) async throws -> [OutboundPrivateCounterpartySendReport] { + try await withStateRevisionTracking(priority: priority) { sdk in try await sdk.processPendingPrivateMessages() } } diff --git a/BitkitTests/PaykitPaymentRequestServiceTests.swift b/BitkitTests/PaykitPaymentRequestServiceTests.swift index 93a8b8cce..afadb8194 100644 --- a/BitkitTests/PaykitPaymentRequestServiceTests.swift +++ b/BitkitTests/PaykitPaymentRequestServiceTests.swift @@ -15,21 +15,30 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { let incoming = try paymentRequestRecord(id: "incoming") let modes: [PaykitPaymentRequestRefreshMode] = [.stored, .inbox, .full] for mode in modes { - let sdk = PaymentRequestSdkMock(records: [stored]) - await sdk.setIncomingRecords([incoming]) - let manager = paymentRequestManager(sdk: sdk) + for priority in [PaykitSdkOperationLock.Priority.ordered, .background] { + let context = "\(mode), \(priority)" + let sdk = PaymentRequestSdkMock(records: [stored]) + await sdk.setIncomingRecords([incoming]) + let manager = paymentRequestManager(sdk: sdk) - await manager.refresh(mode: mode) + if priority == .ordered { + await manager.refresh(mode: mode) + } else { + await manager.refresh(mode: mode, messagePriority: priority) + } - let expectedIds = mode == .stored ? [stored.paymentRequestId] : [stored.paymentRequestId, incoming.paymentRequestId] - XCTAssertEqual(Set(manager.pendingRequests.map(\.paymentRequestId)), Set(expectedIds), "\(mode)") - let snapshot = await sdk.snapshot() - XCTAssertEqual(snapshot.processCallCount, mode == .full ? 1 : 0, "\(mode)") - XCTAssertEqual(snapshot.receiveCallCount, mode >= .inbox ? 1 : 0, "\(mode)") - XCTAssertEqual(snapshot.paymentRequestListCallCount, 1, "\(mode)") - let priorities = await sdk.operationPriorities - XCTAssertEqual(priorities["requests"], [.background], "\(mode)") - XCTAssertEqual(priorities["peers"], [.background], "\(mode)") + let expectedIds = mode == .stored ? [stored.paymentRequestId] : [stored.paymentRequestId, incoming.paymentRequestId] + XCTAssertEqual(Set(manager.pendingRequests.map(\.paymentRequestId)), Set(expectedIds), context) + let snapshot = await sdk.snapshot() + XCTAssertEqual(snapshot.processCallCount, mode == .full ? 1 : 0, context) + XCTAssertEqual(snapshot.receiveCallCount, mode >= .inbox ? 1 : 0, context) + XCTAssertEqual(snapshot.paymentRequestListCallCount, 1, context) + let priorities = await sdk.operationPriorities + XCTAssertEqual(priorities["pending"] ?? [], mode == .full ? [priority] : [], context) + XCTAssertEqual(priorities["receive"] ?? [], mode >= .inbox ? [priority] : [], context) + XCTAssertEqual(priorities["requests"], [.background], context) + XCTAssertEqual(priorities["peers"], [.background], context) + } } } @@ -41,7 +50,7 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { let sdk = PaymentRequestSdkMock(records: []) let manager = paymentRequestManager(sdk: sdk) await sdk.pauseNextPaymentRequestList() - let initialRefresh = Task { await manager.refresh(mode: initialMode) } + let initialRefresh = Task { await manager.refresh(mode: initialMode, messagePriority: .background) } try await waitUntil { await sdk.paymentRequestListIsPaused() } let overlappingRefreshStarted = expectation(description: context) let overlappingRefresh = Task { @@ -63,6 +72,17 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { XCTAssertEqual(snapshot.processCallCount, max(initialMode, requestedMode) == .full ? 1 : 0, context) XCTAssertEqual(snapshot.receiveCallCount, (initialMode >= .inbox ? 1 : 0) + (upgrades ? 1 : 0), context) XCTAssertEqual(snapshot.paymentRequestListCallCount, upgrades ? 2 : 1, context) + let priorities = await sdk.operationPriorities + XCTAssertEqual( + priorities["pending"] ?? [], + initialMode == .full ? [.background] : requestedMode == .full ? [.ordered] : [], + context + ) + XCTAssertEqual( + priorities["receive"] ?? [], + (initialMode >= .inbox ? [.background] : []) + (upgrades ? [.ordered] : []), + context + ) } } } @@ -72,14 +92,14 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { let sdk = PaymentRequestSdkMock(records: [record]) let manager = paymentRequestManager(sdk: sdk) await sdk.pauseNextPaymentRequestList() - let first = Task { await manager.refresh() } + let first = Task { await manager.refresh(messagePriority: .background) } try await waitUntil { await sdk.paymentRequestListIsPaused() } await sdk.setRecords([]) let refreshStarted = expectation(description: "Request state refresh started") let fresh = Task { refreshStarted.fulfill() - await manager.refresh(forceFresh: true) + await manager.refresh(forceFresh: true, messagePriority: .background) } await fulfillment(of: [refreshStarted], timeout: 1) await sdk.resumePaymentRequestList() @@ -89,6 +109,9 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { XCTAssertTrue(manager.pendingRequests.isEmpty) let snapshot = await sdk.snapshot() XCTAssertEqual(snapshot.paymentRequestListCallCount, 2) + let priorities = await sdk.operationPriorities + XCTAssertEqual(priorities["pending"], [.background, .ordered]) + XCTAssertEqual(priorities["receive"], [.background, .ordered]) } func testFreshRefreshRereadsProofStateChangedDuringNotificationSynchronization() async throws { @@ -3345,12 +3368,15 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { await sdk.failNextProcess() let manager = paymentRequestManager(sdk: sdk) - await manager.refresh() + await manager.refresh(messagePriority: .background) XCTAssertEqual(manager.pendingRequests.count, 1) let snapshot = await sdk.snapshot() XCTAssertEqual(snapshot.processCallCount, 1) XCTAssertEqual(snapshot.receiveCallCount, 1) + let priorities = await sdk.operationPriorities + XCTAssertEqual(priorities["pending"], [.background]) + XCTAssertEqual(priorities["receive"], [.background]) } func testFailedRefreshKeepsPreviouslyLoadedRequests() async throws { @@ -5284,7 +5310,7 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { } let sdk = PaymentRequestSdkMock(records: [firstRecord, thirdRecord, fourthRecord]) let manager = paymentRequestManager(sdk: sdk) - await manager.refresh() + await manager.refresh(messagePriority: .background) let request = try XCTUnwrap(manager.pendingRequests.first(where: { $0.paymentRequestId == firstRecord.paymentRequestId && $0.counterparty == firstRecord.counterparty @@ -5305,6 +5331,8 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { paymentRequestId: request.paymentRequestId )] ) + let priorities = await sdk.operationPriorities + XCTAssertEqual(priorities["pending"], [.background, .ordered]) } func testAcceptRechecksExpirationImmediatelyBeforeAction() async throws { @@ -6979,6 +7007,11 @@ private actor PaymentRequestSdkMock: PaykitPaymentRequestSdkHandling, PaykitPaym } func processPendingPrivateMessages() async throws -> [OutboundPrivateCounterpartySendReport] { + try await processPendingPrivateMessages(priority: .ordered) + } + + func processPendingPrivateMessages(priority: PaykitSdkOperationLock.Priority) async throws -> [OutboundPrivateCounterpartySendReport] { + operationPriorities["pending", default: []].append(priority) processCallCount += 1 try await processMessages() return processReports @@ -7015,7 +7048,8 @@ private actor PaymentRequestSdkMock: PaykitPaymentRequestSdkHandling, PaykitPaym throw PaymentRequestSdkMockError.process } - func receivePrivateMessagesFromLinkedPeers() throws -> [PrivateStreamCounterpartyIntakeReport] { + func receivePrivateMessagesFromLinkedPeers(priority: PaykitSdkOperationLock.Priority) throws -> [PrivateStreamCounterpartyIntakeReport] { + operationPriorities["receive", default: []].append(priority) receiveCallCount += 1 if let receiveError { throw receiveError From 1d4973582194cc51373161128d80efe31266c01e Mon Sep 17 00:00:00 2001 From: benk10 Date: Tue, 6 Oct 2026 02:59:00 -0500 Subject: [PATCH 58/68] fix: retain due reminders until request refresh succeeds --- Bitkit/AppScene.swift | 20 ++++-- .../PaykitPaymentRequestService.swift | 40 ++++++----- .../PaykitPaymentRequestServiceTests.swift | 69 +++++++++++++++---- journeys/paykit-clock-changes.md | 1 + 4 files changed, 94 insertions(+), 36 deletions(-) diff --git a/Bitkit/AppScene.swift b/Bitkit/AppScene.swift index 69859665b..a356c07a7 100644 --- a/Bitkit/AppScene.swift +++ b/Bitkit/AppScene.swift @@ -1173,30 +1173,31 @@ struct AppScene: View { } } + @discardableResult private func refreshIncomingPaykitPaymentRequests( presentItems: Bool = true, mode: PaykitPaymentRequestRefreshMode = .full, forceFresh: Bool = false, messagePriority: PaykitSdkOperationLock.Priority = .ordered - ) async { + ) async -> Bool { guard PaykitFeatureFlags.isUIEnabled, wallet.walletExists == true, pubkyProfile.authState == .authenticated else { paykitPaymentRequestManager.clearEligibleTargets() - return + return false } - guard !Task.isCancelled else { return } + guard !Task.isCancelled else { return false } paykitPaymentRequestManager.updateSavedPublicKeys(contactsManager.contacts.map(\.publicKey)) if mode == .full { await PaykitPaymentProofService.shared.reconcile() } - guard let identity = pubkyProfile.publicKey else { return } - await paykitPaymentRequestManager.refresh(mode: mode, forceFresh: forceFresh, messagePriority: messagePriority) + guard let identity = pubkyProfile.publicKey else { return false } + let refreshed = await paykitPaymentRequestManager.refresh(mode: mode, forceFresh: forceFresh, messagePriority: messagePriority) guard pubkyProfile.authState == .authenticated, PubkyPublicKeyFormat.matches(identity, pubkyProfile.publicKey) - else { return } + else { return false } let contacts = paykitPaymentRequestManager.receivedPaymentContacts do { try await CoreService.shared.activity.backfillReceivedPaykitContacts( @@ -1215,6 +1216,7 @@ struct AppScene: View { if mode == .full { await paykitPaymentRequestManager.refreshEligibleTargets(savedPublicKeys: contactsManager.contacts.map(\.publicKey)) } + return refreshed } private func associateResolvedPaykitOnchainPayment(_ resolution: PaykitOnchainPaymentResolution) async { @@ -1583,7 +1585,11 @@ struct AppScene: View { !sheets.isReplacingSheet, app.contactPaymentContext == nil else { return } - await refreshIncomingPaykitPaymentRequests(presentItems: false, mode: .stored) + guard await refreshIncomingPaykitPaymentRequests(presentItems: false, mode: .stored), + !Task.isCancelled, + PubkyPublicKeyFormat.matches(identity, pubkyProfile.publicKey), + PaykitSubscriptionNotificationTargetStore.load() == target + else { return } guard let request = paykitPaymentRequestManager.pendingRequests.first(where: target.matches) else { if paykitPaymentRequestManager.historyRequests.contains(where: target.matches) { PaykitSubscriptionNotificationTargetStore.clear() diff --git a/Bitkit/Services/PaykitPaymentRequestService.swift b/Bitkit/Services/PaykitPaymentRequestService.swift index 8ada577eb..b9631a665 100644 --- a/Bitkit/Services/PaykitPaymentRequestService.swift +++ b/Bitkit/Services/PaykitPaymentRequestService.swift @@ -1241,7 +1241,7 @@ final class PaykitPaymentRequestManager { private var expiredRequestedPresentations: [PaykitPaymentRequest] = [] private var unavailableRequestedPresentations: [PaykitPaymentRequest] = [] private var isPresentingRequests = false - private var refreshTask: (mode: PaykitPaymentRequestRefreshMode, task: Task)? + private var refreshTask: (mode: PaykitPaymentRequestRefreshMode, task: Task)? private var expirationTask: Task? private var presentationRetryTask: Task? private var refreshGeneration = 0 @@ -1612,11 +1612,12 @@ final class PaykitPaymentRequestManager { return subscription } + @discardableResult func refresh( mode: PaykitPaymentRequestRefreshMode = .full, forceFresh: Bool = false, messagePriority: PaykitSdkOperationLock.Priority = .ordered - ) async { + ) async -> Bool { await refresh( excludingProtectedRequestId: nil, mode: mode, @@ -1643,36 +1644,40 @@ final class PaykitPaymentRequestManager { ) } + @discardableResult private func refresh( excludingProtectedRequestId: PaykitPaymentRequest.ID?, mode: PaykitPaymentRequestRefreshMode = .full, forceFresh: Bool = false, messagePriority: PaykitSdkOperationLock.Priority = .ordered - ) async { + ) async -> Bool { if let refreshTask { let generation = stateGeneration - await refreshTask.task.value - if forceFresh || mode > refreshTask.mode, generation == stateGeneration, !Task.isCancelled { - await refresh(excludingProtectedRequestId: excludingProtectedRequestId, mode: mode, messagePriority: messagePriority) + let refreshed = await refreshTask.task.value + guard generation == stateGeneration, !Task.isCancelled else { return false } + if forceFresh || mode > refreshTask.mode { + return await refresh(excludingProtectedRequestId: excludingProtectedRequestId, mode: mode, messagePriority: messagePriority) } - return + return refreshed } refreshGeneration += 1 let generation = refreshGeneration let task = Task { [weak self] in - guard let self else { return } - await performRefresh( + guard let self else { return false } + let refreshed = await performRefresh( generation: generation, excludingProtectedRequestId: excludingProtectedRequestId, mode: mode, messagePriority: messagePriority ) - guard generation == refreshGeneration else { return } + guard generation == refreshGeneration else { return false } refreshTask = nil + return refreshed } refreshTask = (mode, task) - await task.value + let refreshed = await task.value + return refreshed && !Task.isCancelled } func ensurePaymentAllowed(_ request: PaykitPaymentRequest) async throws { @@ -2220,17 +2225,17 @@ final class PaykitPaymentRequestManager { excludingProtectedRequestId: PaykitPaymentRequest.ID?, mode: PaykitPaymentRequestRefreshMode, messagePriority: PaykitSdkOperationLock.Priority - ) async { + ) async -> Bool { do { let snapshot = try await service.synchronize(mode: mode, priority: .background, messagePriority: messagePriority) - guard generation == refreshGeneration, let activeIdentity else { return } + guard generation == refreshGeneration, let activeIdentity else { return false } async let completedProofKinds = completedPaymentProofKinds(activeIdentity) async let inFlightRequestIds = inFlightPaymentRequestIds(activeIdentity) let (locallyCompletedProofKinds, locallyInFlightRequestIds) = await (completedProofKinds, inFlightRequestIds) let locallyCompletedRequestIds = Set(locallyCompletedProofKinds.keys) guard generation == refreshGeneration, PubkyPublicKeyFormat.matches(self.activeIdentity, activeIdentity) - else { return } + else { return false } pruneAcceptedRequestIds(snapshot.history, identity: activeIdentity) let refreshDate = now() let subscriptionDate = subscriptionNow() @@ -2332,12 +2337,15 @@ final class PaykitPaymentRequestManager { persistPresentedRequestIds() discardExpiredRequests(handledRequestedExpirationId: handledRequestedExpirationId) schedulePresentationRetry() + return generation == refreshGeneration && + PubkyPublicKeyFormat.matches(self.activeIdentity, activeIdentity) && !Task.isCancelled } catch is CancellationError { - return + return false } catch { - guard generation == refreshGeneration else { return } + guard generation == refreshGeneration else { return false } discardExpiredRequests() logWarning("Failed to refresh incoming Paykit payment requests: \(error)") + return false } } diff --git a/BitkitTests/PaykitPaymentRequestServiceTests.swift b/BitkitTests/PaykitPaymentRequestServiceTests.swift index afadb8194..4b2561a03 100644 --- a/BitkitTests/PaykitPaymentRequestServiceTests.swift +++ b/BitkitTests/PaykitPaymentRequestServiceTests.swift @@ -55,7 +55,7 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { let overlappingRefreshStarted = expectation(description: context) let overlappingRefresh = Task { overlappingRefreshStarted.fulfill() - await manager.refresh(mode: requestedMode) + return await manager.refresh(mode: requestedMode) } await fulfillment(of: [overlappingRefreshStarted], timeout: 1) @@ -64,8 +64,10 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { XCTAssertEqual(pausedSnapshot.receiveCallCount, initialMode >= .inbox ? 1 : 0, context) XCTAssertEqual(pausedSnapshot.paymentRequestListCallCount, 1, context) await sdk.resumePaymentRequestList() - await initialRefresh.value - await overlappingRefresh.value + let initialSucceeded = await initialRefresh.value + let overlappingSucceeded = await overlappingRefresh.value + XCTAssertTrue(initialSucceeded, context) + XCTAssertTrue(overlappingSucceeded, context) let upgrades = requestedMode > initialMode let snapshot = await sdk.snapshot() @@ -87,6 +89,46 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { } } + func testStoredRefreshReportsCoalescedFailureBeforeLoadingSubscriptions() async throws { + let now = try XCTUnwrap(ISO8601DateFormatter().date(from: "2027-01-15T08:00:00Z")) + let recurrence = PaymentRequestRecurrence( + every: 1, unit: "month", startsAt: timestamp(now), anchor: timestamp(now), endsAt: nil + ) + let record = try paymentRequestRecord(state: .activeRecurring, recurrence: recurrence) + let sdk = PaymentRequestSdkMock(records: [record]) + let manager = paymentRequestManager(sdk: sdk, clock: PaymentRequestTestClock(now)) + await sdk.setReceiveError(.receive) + await sdk.pauseNextProcess() + let fullRefresh = Task { await manager.refresh() } + try await waitUntil { await sdk.processIsPaused() } + + let storedRefreshStarted = expectation(description: "Stored subscription refresh started") + let storedRefresh = Task { + storedRefreshStarted.fulfill() + return await manager.refresh(mode: .stored) + } + await fulfillment(of: [storedRefreshStarted], timeout: 1) + await sdk.resumeProcess() + let fullSucceeded = await fullRefresh.value + let storedSucceeded = await storedRefresh.value + + XCTAssertFalse(fullSucceeded) + XCTAssertFalse(storedSucceeded) + XCTAssertTrue(manager.subscriptions.isEmpty) + let failedSnapshot = await sdk.snapshot() + XCTAssertEqual(failedSnapshot.paymentRequestListCallCount, 0) + + let retrySucceeded = await manager.refresh(mode: .stored) + XCTAssertTrue(retrySucceeded) + let request = try XCTUnwrap(manager.pendingRequests.first) + XCTAssertEqual(request.paymentRequestId, record.paymentRequestId) + XCTAssertNotNil(request.billingPeriod) + XCTAssertTrue(manager.markPresentedIfPending(request)) + XCTAssertTrue(manager.requestsForPresentation().isEmpty) + XCTAssertTrue(manager.requestPresentation(request)) + XCTAssertEqual(manager.requestsForPresentation(), [request]) + } + func testFreshRefreshRereadsRequestsChangedAfterSnapshot() async throws { let record = try paymentRequestRecord() let sdk = PaymentRequestSdkMock(records: [record]) @@ -103,7 +145,7 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { } await fulfillment(of: [refreshStarted], timeout: 1) await sdk.resumePaymentRequestList() - await first.value + _ = await first.value await fresh.value XCTAssertTrue(manager.pendingRequests.isEmpty) @@ -157,7 +199,7 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { } await fulfillment(of: [refreshStarted], timeout: 1) await center.resumePendingRequests() - await first.value + _ = await first.value await afterFailure.value XCTAssertEqual(manager.pendingRequests.map(\.id), [requestId]) @@ -186,7 +228,7 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { manager.clear() await sdk.resumePaymentRequestList() - await initialRefresh.value + _ = await initialRefresh.value await overlappingRefresh.value XCTAssertTrue(manager.pendingRequests.isEmpty, context) @@ -214,7 +256,7 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { fullRefresh.cancel() await sdk.resumePaymentRequestList() - await storedRefresh.value + _ = await storedRefresh.value await fullRefresh.value XCTAssertEqual(manager.pendingRequests.count, 1) @@ -244,7 +286,7 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { default: manager.activate(identity: "pubky\(String(repeating: "y", count: 52))") } await sdk.resumePaymentRequestList() - await first.value + _ = await first.value await fresh.value let snapshot = await sdk.snapshot() @@ -3632,7 +3674,7 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { XCTAssertTrue(manager.requestsForPresentation().isEmpty) await sdk.resumePaymentRequestList() - await refresh.value + _ = await refresh.value XCTAssertTrue(manager.requestsForPresentation().isEmpty) XCTAssertTrue(manager.requestPresentation(request)) XCTAssertEqual(manager.requestsForPresentation(), [request]) @@ -4047,7 +4089,7 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { ).isEmpty) await notificationCenter.resumePendingRequests() - await refreshTask.value + _ = await refreshTask.value } func testRefreshPreservesUnavailableOutcomeForRequestedPresentation() async throws { @@ -4131,7 +4173,7 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { XCTAssertEqual(manager.requestedPresentationExpirationTrigger, 0) await notificationCenter.resumePendingRequests() - await refreshTask.value + _ = await refreshTask.value let dispatches = IncomingPaykitPaymentRequestPresentationDispatcher.handleStateChange( from: previous, @@ -4177,7 +4219,7 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { XCTAssertTrue(manager.requestsForPresentation().isEmpty) await notificationCenter.resumePendingRequests() - await refreshTask.value + _ = await refreshTask.value XCTAssertEqual(manager.pendingRequests, [retriedRequest]) XCTAssertTrue(manager.requestsForPresentation().isEmpty) @@ -5397,8 +5439,9 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { try await waitUntil { await sdk.paymentRequestListIsPaused() } try await manager.prepareForPayment(request) await sdk.resumePaymentRequestList() - await refreshTask.value + let refreshed = await refreshTask.value + XCTAssertFalse(refreshed) XCTAssertTrue(manager.pendingRequests.isEmpty) } diff --git a/journeys/paykit-clock-changes.md b/journeys/paykit-clock-changes.md index a73df6992..a01471297 100644 --- a/journeys/paykit-clock-changes.md +++ b/journeys/paykit-clock-changes.md @@ -25,6 +25,7 @@ On iOS, Ring identities are selected from the shared identity list. There is no 4. Restore the correct clock and verify reminders still work. On Android, WorkManager delivery is best effort and may be delayed by retry backoff or OS scheduling; this check does not require exact delivery to the second. 5. While a payment request is temporarily unavailable and presentation is retrying, move the clock forward and backward. Retry intervals should remain short, while actual payment expiry and approval continue to use absolute timestamps. 6. Tap a due subscription reminder during background contact preparation, then repeat with Bitkit closed before the tap. After authentication and unlock, the app must resolve the due period from shared state without waiting for the whole contact-preparation round. Normal maintenance must still run afterward. Record tap-to-sheet timing separately from authentication and any SDK lock wait. +7. On iOS, repeat the cold-launch reminder while private-message intake fails before the stored request snapshot loads. Keep the reminder pending through the failure. Restore connectivity and allow a later refresh to open the due period without tapping the reminder again. Clear a stale reminder only after a successful snapshot confirms that period was handled or the subscription is inactive. These steps describe the remaining manual verification. Unit tests cover injected restoration failures, state preservation, retry timing, UTC recurrence, and notification scheduling; they do not replace a live grant-session clock-change test. From 00e8d6bdcdc13467fb63c58f414767e47def09c0 Mon Sep 17 00:00:00 2001 From: benk10 Date: Tue, 6 Oct 2026 03:15:50 -0500 Subject: [PATCH 59/68] docs: clarify reminder checks and summarize paykit changes --- changelog.d/next/856.changed.md | 2 +- journeys/paykit-clock-changes.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/changelog.d/next/856.changed.md b/changelog.d/next/856.changed.md index ca6d3e12f..a8611abdb 100644 --- a/changelog.d/next/856.changed.md +++ b/changelog.d/next/856.changed.md @@ -1 +1 @@ -Share Paykit contacts and payment state with authorized apps while keeping wallet keys private, preserve disabled contact sharing during cleanup failures, prevent paid subscription periods from being offered again, show confirmed proposal delivery accurately, show incoming requests in the payment sheet while preparing, prioritize foreground payment actions, reduce repeated shared-state reads, and label received payments with their Paykit payer contact while preserving manual contact removal through backup and restore. +Share Paykit contacts and payment state with authorized apps while keeping wallet keys private, prepare incoming payments in the payment sheet, preserve subscription reminders through retries, and label received payments with their Paykit contact. diff --git a/journeys/paykit-clock-changes.md b/journeys/paykit-clock-changes.md index a01471297..666bde2a3 100644 --- a/journeys/paykit-clock-changes.md +++ b/journeys/paykit-clock-changes.md @@ -25,7 +25,7 @@ On iOS, Ring identities are selected from the shared identity list. There is no 4. Restore the correct clock and verify reminders still work. On Android, WorkManager delivery is best effort and may be delayed by retry backoff or OS scheduling; this check does not require exact delivery to the second. 5. While a payment request is temporarily unavailable and presentation is retrying, move the clock forward and backward. Retry intervals should remain short, while actual payment expiry and approval continue to use absolute timestamps. 6. Tap a due subscription reminder during background contact preparation, then repeat with Bitkit closed before the tap. After authentication and unlock, the app must resolve the due period from shared state without waiting for the whole contact-preparation round. Normal maintenance must still run afterward. Record tap-to-sheet timing separately from authentication and any SDK lock wait. -7. On iOS, repeat the cold-launch reminder while private-message intake fails before the stored request snapshot loads. Keep the reminder pending through the failure. Restore connectivity and allow a later refresh to open the due period without tapping the reminder again. Clear a stale reminder only after a successful snapshot confirms that period was handled or the subscription is inactive. +7. Repeat the cold-launch reminder while the startup request refresh fails before the stored request snapshot loads. Keep the reminder pending through the failure. Restore connectivity and allow a later refresh to open the exact due period without tapping the reminder again. Clear a stale reminder only after a successful snapshot confirms that period was handled or the subscription is inactive. These steps describe the remaining manual verification. Unit tests cover injected restoration failures, state preservation, retry timing, UTC recurrence, and notification scheduling; they do not replace a live grant-session clock-change test. From 39a97f44dc91e931b2b823d06b6b9207e5c24ef7 Mon Sep 17 00:00:00 2001 From: benk10 Date: Tue, 6 Oct 2026 03:33:31 -0500 Subject: [PATCH 60/68] fix: prioritize foreground sharing withdrawal completion --- Bitkit/Services/ContactPaymentsService.swift | 7 +- Bitkit/Services/PubkyService.swift | 4 +- Bitkit/Services/PublicPaykitService.swift | 12 ++- BitkitTests/ContactPaymentsServiceTests.swift | 25 +++++- BitkitTests/PaykitSdkOperationLockTests.swift | 79 +++++++++++++++++++ 5 files changed, 118 insertions(+), 9 deletions(-) diff --git a/Bitkit/Services/ContactPaymentsService.swift b/Bitkit/Services/ContactPaymentsService.swift index 10f61db4a..08b38bf0d 100644 --- a/Bitkit/Services/ContactPaymentsService.swift +++ b/Bitkit/Services/ContactPaymentsService.swift @@ -26,7 +26,12 @@ enum ContactPaymentsService { static func live(wallet: WalletViewModel) -> Operations { Operations( syncPublicEndpoints: { publish, isChangeCurrent in - try await PublicPaykitService.syncPublishedEndpoints(wallet: wallet, publish: publish, isSessionCurrent: isChangeCurrent) + try await PublicPaykitService.syncPublishedEndpoints( + wallet: wallet, + publish: publish, + isSessionCurrent: isChangeCurrent, + appSyncPriority: publish ? .ordered : .interactive + ) }, preparePrivateEndpoints: { contactPublicKeys, requireImmediatePublication, isChangeCurrent in await PrivatePaykitService.shared.prepareSavedContacts( diff --git a/Bitkit/Services/PubkyService.swift b/Bitkit/Services/PubkyService.swift index 183e7ce87..9ac9404cd 100644 --- a/Bitkit/Services/PubkyService.swift +++ b/Bitkit/Services/PubkyService.swift @@ -819,8 +819,8 @@ actor PaykitSdkService { } } - func syncPaykitApp(privatePaymentsEnabled: Bool) async throws { - try await withStateRevisionTracking { sdk in + func syncPaykitApp(privatePaymentsEnabled: Bool, priority: PaykitSdkOperationLock.Priority = .ordered) async throws { + try await withStateRevisionTracking(priority: priority) { sdk in var capabilities = try await appCapabilities(for: sdk.identityStatus()) capabilities.privatePayments = capabilities.privatePayments && privatePaymentsEnabled _ = try await sdk.publishPaykitApp(displayName: "Bitkit", capabilities: capabilities) diff --git a/Bitkit/Services/PublicPaykitService.swift b/Bitkit/Services/PublicPaykitService.swift index fb8983199..ab7b4269c 100644 --- a/Bitkit/Services/PublicPaykitService.swift +++ b/Bitkit/Services/PublicPaykitService.swift @@ -318,13 +318,14 @@ enum PublicPaykitService { static func syncPublishedEndpoints( wallet: WalletViewModel, publish: Bool, - isSessionCurrent: (@MainActor () -> Bool)? = nil + isSessionCurrent: (@MainActor () -> Bool)? = nil, + appSyncPriority: PaykitSdkOperationLock.Priority = .ordered ) async throws { try await syncPublishedEndpoints( publish: publish, isSessionCurrent: isSessionCurrent, buildEndpoints: { try await buildWalletEndpoints(wallet: wallet, refreshIfNeeded: true, requireEndpoint: true) }, - syncApp: { try await syncPaykitApp() }, + syncApp: { try await syncPaykitApp(priority: appSyncPriority) }, applyEndpoints: applyPublishedEndpointsLocked ) } @@ -378,9 +379,12 @@ enum PublicPaykitService { try await applyPublishedEndpoints([]) } - static func syncPaykitApp(privateSharingEnabled: Bool? = nil) async throws { + static func syncPaykitApp( + privateSharingEnabled: Bool? = nil, + priority: PaykitSdkOperationLock.Priority = .ordered + ) async throws { let privateSharing = privateSharingEnabled ?? UserDefaults.standard.bool(forKey: PrivatePaykitService.publishingEnabledKey) - try await PaykitSdkService.shared.syncPaykitApp(privatePaymentsEnabled: privateSharing) + try await PaykitSdkService.shared.syncPaykitApp(privatePaymentsEnabled: privateSharing, priority: priority) } static func hasPayablePublicEndpoint(publicKey: String) async throws -> Bool { diff --git a/BitkitTests/ContactPaymentsServiceTests.swift b/BitkitTests/ContactPaymentsServiceTests.swift index 6dd3aa913..6db8d4b98 100644 --- a/BitkitTests/ContactPaymentsServiceTests.swift +++ b/BitkitTests/ContactPaymentsServiceTests.swift @@ -792,7 +792,7 @@ final class ContactPaymentsServiceTests: XCTestCase { func testContactPaymentsEnableWinsOverAnOlderDisableStillRemoving() async throws { enum HeldRemoval { - case privateBeforeLock, publicBeforeLock, publicHoldingLock + case privateBeforeLock, publicBeforeLock, publicHoldingLock, publicCapabilitySync } let cases: [(name: String, held: HeldRemoval, writes: [String])] = [ ("private removal waiting for its lock", .privateBeforeLock, ["public:published", "private:published"]), @@ -804,6 +804,10 @@ final class ContactPaymentsServiceTests: XCTestCase { "public removal holding its lock", .publicHoldingLock, ["private:removed", "public:removed", "public:published", "private:published"] ), + ( + "public capability sync holding its lock", .publicCapabilitySync, + ["private:removed", "public:removed", "public:published", "private:published"] + ), ] let ownerKey = try useLocalPubkySecretKey() let record = contactRecord(publicKey: "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg") @@ -832,6 +836,13 @@ final class ContactPaymentsServiceTests: XCTestCase { return await endpoints.writePrivateReturningError("private:published", isChangeCurrent: isChangeCurrent) } operations.syncPublicEndpoints = { publish, isChangeCurrent in + if !publish, held == .publicCapabilitySync { + try await endpoints.removePublic(isChangeCurrent: isChangeCurrent) { + reachedHeldPoint.fulfill() + for await _ in gate {} + } + return + } if !publish, held == .publicBeforeLock { reachedHeldPoint.fulfill() for await _ in gate {} @@ -866,7 +877,7 @@ final class ContactPaymentsServiceTests: XCTestCase { XCTAssertFalse(try disableResult.get(), testCase.name) XCTAssertTrue(try enableResult.get(), testCase.name) XCTAssertEqual(endpoints.entries, testCase.writes, testCase.name) - XCTAssertEqual(operations.publicCleanupValues.last, false, testCase.name) + XCTAssertEqual(operations.publicCleanupValues, [true, false], testCase.name) XCTAssertEqual(operations.privateCleanupValues.last, false, testCase.name) XCTAssertTrue(defaults.bool(forKey: PublicPaykitService.publishingEnabledKey), testCase.name) XCTAssertTrue(defaults.bool(forKey: PrivatePaykitService.publishingEnabledKey), testCase.name) @@ -1073,6 +1084,16 @@ final class ContactPaymentsServiceTests: XCTestCase { entries.append(entry) } } + + func removePublic(isChangeCurrent: @escaping ContactPaymentsService.ChangeCheck, syncApp: () async throws -> Void) async throws { + try await PublicPaykitService.syncPublishedEndpoints( + publish: false, + isSessionCurrent: isChangeCurrent, + buildEndpoints: { [] }, + syncApp: syncApp, + applyEndpoints: { _ in self.entries.append("public:removed") } + ) + } } private final class OperationsSpy { diff --git a/BitkitTests/PaykitSdkOperationLockTests.swift b/BitkitTests/PaykitSdkOperationLockTests.swift index 247aae1c9..eda032247 100644 --- a/BitkitTests/PaykitSdkOperationLockTests.swift +++ b/BitkitTests/PaykitSdkOperationLockTests.swift @@ -33,6 +33,61 @@ final class PaykitSdkOperationLockTests: XCTestCase { ) } + func testCapabilitySyncPriorityPreservesOrderedWorkAndOnlyOvertakesQueuedIntake() async throws { + let cases: [(priority: PaykitSdkOperationLock.Priority?, orderedBarrier: Bool, expected: [String])] = [ + (nil, false, ["active", "intake", "publish"]), + (.interactive, false, ["active", "publish", "intake"]), + (.interactive, true, ["active", "intake", "ordered", "publish"]), + ] + for testCase in cases { + let recorder = Recorder() + let (gate, release) = AsyncStream.makeStream() + defer { release.finish() } + let started = expectation(description: "Active SDK operation started") + let sdk = PublicReadSdk(noPointer: .init()) + sdk.lockedRead = { + if await recorder.events.isEmpty { + await recorder.record("active") + started.fulfill() + for await _ in gate {} + } else { + await recorder.record("ordered") + } + } + sdk.intake = { await recorder.record("intake") } + sdk.publication = { capabilities in + XCTAssertFalse(capabilities.privatePayments) + await recorder.record("publish") + } + let service = PaykitSdkService(sdkFactory: { sdk }) + let active = Task { _ = try await service.contactRecords() } + await fulfillment(of: [started], timeout: 2) + + let intake = Task { _ = try await service.receivePrivateMessagesFromLinkedPeers(priority: .background) } + try await Task.sleep(for: .milliseconds(50)) + let barrier = testCase.orderedBarrier ? Task { _ = try await service.contactRecords() } : nil + if barrier != nil { try await Task.sleep(for: .milliseconds(50)) } + let publication = Task { + if let priority = testCase.priority { + try await service.syncPaykitApp(privatePaymentsEnabled: false, priority: priority) + } else { + try await service.syncPaykitApp(privatePaymentsEnabled: false) + } + } + try await Task.sleep(for: .milliseconds(50)) + let heldEvents = await recorder.events + XCTAssertEqual(heldEvents, ["active"]) + + release.finish() + try await active.value + try await intake.value + try await barrier?.value + try await publication.value + let events = await recorder.events + XCTAssertEqual(events, testCase.expected) + } + } + private func assertQueuedOrder( priorities: [PaykitSdkOperationLock.Priority], expected: [Int], @@ -350,6 +405,30 @@ final class PaykitSdkOperationLockTests: XCTestCase { private final class PublicReadSdk: PaykitSdk, @unchecked Sendable { var lockedRead: () async -> Void = {} var publicRead: () async -> Void = {} + var intake: () async -> Void = {} + var publication: (PaykitAppCapabilities) async -> Void = { _ in } + + override func identityStatus() async throws -> IdentityStatus? { + IdentityStatus(publicKey: nil, capability: .privateLinkCapable) + } + + override func stateRevision() throws -> String? { + "state" + } + + override func backupStateRevision() async throws -> String { + "revision" + } + + override func receivePrivateMessagesFromLinkedPeers() async throws -> [PrivateStreamCounterpartyIntakeReport] { + await intake() + return [] + } + + override func publishPaykitApp(displayName _: String, capabilities: PaykitAppCapabilities) async throws -> PaykitAppRegistry { + await publication(capabilities) + return PaykitAppRegistry(keyGeneration: 1, noisePublicKey: nil, apps: [], defaultAppId: nil, defaultAppsByEndpoint: [:]) + } override func resolvePublicContactPayment(counterparty _: String, amount _: PaymentAmountContext?) async throws -> PublicContactPaymentResolution From 6c86c8319fa901f751179e179c1c2af309814b78 Mon Sep 17 00:00:00 2001 From: benk10 Date: Tue, 6 Oct 2026 05:42:16 -0500 Subject: [PATCH 61/68] fix: keep request preparation stable through retries --- Bitkit/AppScene.swift | 62 ++++++++++++----- .../PaykitPaymentRequestService.swift | 16 ++++- .../PaykitPaymentRequestServiceTests.swift | 68 +++++++++++++++++-- journeys/paykit-clock-changes.md | 1 + .../requested-resolution-failure.xml | 3 +- 5 files changed, 125 insertions(+), 25 deletions(-) diff --git a/Bitkit/AppScene.swift b/Bitkit/AppScene.swift index a356c07a7..7e0a52abc 100644 --- a/Bitkit/AppScene.swift +++ b/Bitkit/AppScene.swift @@ -134,7 +134,7 @@ final class IncomingPaykitPaymentRequestPreparation { ) -> PaykitPaymentRequest? { guard let request, matchesSession(session), paymentContext == self.paymentContext, - manager.isCurrentPresentation(request), + manager.isCurrentPresentation(request) || manager.isWaitingForPresentationRetry(request), !request.isExpired(at: now) else { return nil } return request @@ -162,7 +162,8 @@ final class IncomingPaykitPaymentRequestPreparation { sheets: SheetViewModel ) -> Bool { guard !Task.isCancelled, ownsSheet(sheets), paymentContext != nil, - visibleRequest(manager: manager, session: session, paymentContext: app.contactPaymentContext) != nil + let request = visibleRequest(manager: manager, session: session, paymentContext: app.contactPaymentContext), + manager.isCurrentPresentation(request) else { return false } resolvedRoute = route return true @@ -1301,29 +1302,48 @@ struct AppScene: View { PaykitFeatureFlags.isUIEnabled, pubkyProfile.currentSession != nil, !app.showDrawer, - sheets.activeSheetConfiguration == nil, + sheets.activeSheetConfiguration == nil || canRetryIncomingPaymentRequestPreparation, !sheets.isReplacingSheet, app.contactPaymentContext == nil else { return } var shouldPresentNextRequest = true let attemptedPresentation = await paykitPaymentRequestManager.presentRequests { requests in - guard sheets.activeSheetConfiguration == nil, !sheets.isReplacingSheet, app.contactPaymentContext == nil else { return } + guard sheets.activeSheetConfiguration == nil || canRetryIncomingPaymentRequestPreparation, + !sheets.isReplacingSheet, app.contactPaymentContext == nil + else { return } for request in requests { guard paykitPaymentRequestManager.isCurrentPresentation(request) else { return } - let preparation = IncomingPaykitPaymentRequestPreparation(request: request, session: pubkyProfile.currentSession) - incomingPaymentRequestPreparation = preparation - sheets.showSheet(.send, data: SendConfig(view: .confirm, preparation: preparation, onDismiss: { - if preparation.resolvedRoute == nil, scenePhase == .active, - preparation.matchesSession(pubkyProfile.currentSession), let request = preparation.request - { - paykitPaymentRequestManager.dismissPreparingRequest(request) - } - preparation.clear() - })) + let preparation: IncomingPaykitPaymentRequestPreparation + if let current = incomingPaymentRequestPreparation, current.ownsSheet(sheets) { + guard current.request?.id == request.id else { continue } + preparation = current + } else { + preparation = IncomingPaykitPaymentRequestPreparation(request: request, session: pubkyProfile.currentSession) + incomingPaymentRequestPreparation = preparation + sheets.showSheet(.send, data: SendConfig(view: .confirm, preparation: preparation, onDismiss: { + if preparation.resolvedRoute == nil, scenePhase == .active, + preparation.matchesSession(pubkyProfile.currentSession), let request = preparation.request + { + paykitPaymentRequestManager.dismissPreparingRequest(request) + } + preparation.clear() + })) + } defer { if preparation.resolvedRoute == nil { - endIncomingPaymentRequestPreparation(preparation) + if !Task.isCancelled, preparation.ownsSheet(sheets), + preparation.matchesSession(pubkyProfile.currentSession), + paykitPaymentRequestManager.isWaitingForPresentationRetry(request) + { + if let context = preparation.paymentContext, app.ownsContactPaymentContext(context) { + app.resetSendState() + wallet.resetSendState(speed: settings.defaultTransactionSpeed) + } + preparation.paymentContext = nil + } else { + endIncomingPaymentRequestPreparation(preparation) + } } } do { @@ -1488,6 +1508,11 @@ struct AppScene: View { ) != nil } + private var canRetryIncomingPaymentRequestPreparation: Bool { + guard let preparation = incomingPaymentRequestPreparation, preparation.resolvedRoute == nil else { return false } + return isCurrentIncomingPaymentRequestPreparation(preparation) + } + private var isIncomingPaymentRequestPreparationInvalid: Bool { guard let preparation = incomingPaymentRequestPreparation else { return false } if preparation.resolvedRoute != nil { return !preparation.ownsSheet(sheets) } @@ -1608,15 +1633,20 @@ struct AppScene: View { if paykitPaymentRequestManager.requestedPresentationId != request.id { guard paykitPaymentRequestManager.requestPresentation(request) else { return } } + PaykitSubscriptionNotificationTargetStore.clear() await presentNextIncomingPaykitPaymentRequest() } private func presentNextIncomingPaykitItem() async { guard scenePhase == .active, isPinVerified || !settings.pinEnabled, - sheets.activeSheetConfiguration == nil, + sheets.activeSheetConfiguration == nil || canRetryIncomingPaymentRequestPreparation, !sheets.isReplacingSheet else { return } + if canRetryIncomingPaymentRequestPreparation { + await presentNextIncomingPaykitPaymentRequest() + return + } if PaykitSubscriptionNotificationTargetStore.load() != nil { await handlePendingPaykitSubscriptionNotification() guard PaykitSubscriptionNotificationTargetStore.load() == nil, diff --git a/Bitkit/Services/PaykitPaymentRequestService.swift b/Bitkit/Services/PaykitPaymentRequestService.swift index b9631a665..c7ba58f70 100644 --- a/Bitkit/Services/PaykitPaymentRequestService.swift +++ b/Bitkit/Services/PaykitPaymentRequestService.swift @@ -129,6 +129,8 @@ struct PaykitPaymentRequest: Identifiable, Hashable { return .failure(.nonActionableState) } lifecycleState = actionableState + } else if expectedRole == .payer, let actionableState = actionableLifecycleState(for: record) { + lifecycleState = actionableState } else { lifecycleState = record.state } @@ -1792,7 +1794,9 @@ final class PaykitPaymentRequestManager { } private func clearNotificationTarget(matching request: PaykitPaymentRequest) { - if PaykitSubscriptionNotificationTargetStore.load()?.matches(request) == true { + if let identity = activeIdentity, let target = PaykitSubscriptionNotificationTargetStore.load(), + target.matches(identity: identity), target.matches(request) + { PaykitSubscriptionNotificationTargetStore.clear() } } @@ -1932,6 +1936,7 @@ final class PaykitPaymentRequestManager { presentationRetryAttempts.removeValue(forKey: request.id) presentationRetryDeadlines.removeValue(forKey: request.id) requestedPresentationId = request.id + clearNotificationTarget(matching: request) schedulePresentationRetry() return true } @@ -1942,6 +1947,7 @@ final class PaykitPaymentRequestManager { !presentedRequestIds.contains(request.id) || requestedPresentationId == request.id else { return } dismissedPreparingRequestIds.insert(request.id) + clearNotificationTarget(matching: request) presentationGeneration += 1 if requestedPresentationId == request.id { requestedPresentationId = nil @@ -2043,6 +2049,14 @@ final class PaykitPaymentRequestManager { return requestedPresentationId.map { $0 == request.id } ?? true } + func isWaitingForPresentationRetry(_ request: PaykitPaymentRequest) -> Bool { + pendingRequests.contains(where: { $0.id == request.id }) && + !processingRequestIds.contains(request.id) && + !dismissedPreparingRequestIds.contains(request.id) && + presentationRetryDeadlines[request.id] != nil && + (requestedPresentationId.map { $0 == request.id } ?? !presentedRequestIds.contains(request.id)) + } + func isApprovedForPayment(_ request: PaykitPaymentRequest) -> Bool { guard approvedPaymentRequestIds.contains(request.id) else { return false } guard let billingPeriod = request.billingPeriod else { diff --git a/BitkitTests/PaykitPaymentRequestServiceTests.swift b/BitkitTests/PaykitPaymentRequestServiceTests.swift index 4b2561a03..61be3111a 100644 --- a/BitkitTests/PaykitPaymentRequestServiceTests.swift +++ b/BitkitTests/PaykitPaymentRequestServiceTests.swift @@ -90,11 +90,21 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { } func testStoredRefreshReportsCoalescedFailureBeforeLoadingSubscriptions() async throws { + defer { PaykitSubscriptionNotificationTargetStore.clear() } let now = try XCTUnwrap(ISO8601DateFormatter().date(from: "2027-01-15T08:00:00Z")) let recurrence = PaymentRequestRecurrence( every: 1, unit: "month", startsAt: timestamp(now), anchor: timestamp(now), endsAt: nil ) - let record = try paymentRequestRecord(state: .activeRecurring, recurrence: recurrence) + let record = try paymentRequestRecord( + counterparty: "pubky\(String(repeating: "y", count: 52))", state: .activeRecurring, recurrence: recurrence + ) + let target = try XCTUnwrap(PaykitSubscriptionNotificationTarget(userInfo: [ + "payer_identity": "pubky\(String(repeating: "z", count: 52))", + "payment_request_id": record.paymentRequestId, + "counterparty": record.counterparty, + "billing_period_starts_at": PaykitSubscriptionTimestamp.string(from: now), + ])) + PaykitSubscriptionNotificationTargetStore.save(target) let sdk = PaymentRequestSdkMock(records: [record]) let manager = paymentRequestManager(sdk: sdk, clock: PaymentRequestTestClock(now)) await sdk.setReceiveError(.receive) @@ -114,6 +124,7 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { XCTAssertFalse(fullSucceeded) XCTAssertFalse(storedSucceeded) + XCTAssertEqual(PaykitSubscriptionNotificationTargetStore.load(), target) XCTAssertTrue(manager.subscriptions.isEmpty) let failedSnapshot = await sdk.snapshot() XCTAssertEqual(failedSnapshot.paymentRequestListCallCount, 0) @@ -123,10 +134,19 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { let request = try XCTUnwrap(manager.pendingRequests.first) XCTAssertEqual(request.paymentRequestId, record.paymentRequestId) XCTAssertNotNil(request.billingPeriod) + XCTAssertTrue(target.matches(request)) XCTAssertTrue(manager.markPresentedIfPending(request)) XCTAssertTrue(manager.requestsForPresentation().isEmpty) XCTAssertTrue(manager.requestPresentation(request)) + XCTAssertNil(PaykitSubscriptionNotificationTargetStore.load()) XCTAssertEqual(manager.requestsForPresentation(), [request]) + XCTAssertEqual(manager.deferPresentation(request), .retryScheduled) + PaykitSubscriptionNotificationTargetStore.save(target) + manager.dismissPreparingRequest(request) + XCTAssertNil(PaykitSubscriptionNotificationTargetStore.load()) + XCTAssertEqual(manager.pendingRequests, [request]) + XCTAssertTrue(manager.requestsForPresentation().isEmpty) + XCTAssertTrue(manager.requestPresentation(request)) } func testFreshRefreshRereadsRequestsChangedAfterSnapshot() async throws { @@ -3805,12 +3825,14 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { for _ in 0 ..< 14 { XCTAssertEqual(manager.deferPresentation(request), .retryScheduled) + XCTAssertTrue(manager.isWaitingForPresentationRetry(request)) XCTAssertTrue(manager.requestsForPresentation().isEmpty) clock.advance(by: 2) XCTAssertEqual(manager.requestsForPresentation(), [request]) } XCTAssertEqual(manager.deferPresentation(request), .requestedPresentationEnded) + XCTAssertFalse(manager.isWaitingForPresentationRetry(request)) XCTAssertTrue(manager.requestsForPresentation().isEmpty) XCTAssertNil(manager.requestedPresentationId) @@ -4592,6 +4614,8 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { )) XCTAssertTrue(manager.isCurrentPresentation(request)) + XCTAssertEqual(manager.deferPresentation(request), .retryScheduled) + XCTAssertEqual(preparation.visibleRequest(manager: manager, session: session, paymentContext: context), request) manager.clear() XCTAssertFalse(manager.isCurrentPresentation(request)) XCTAssertNil(preparation.visibleRequest(manager: manager, session: session, paymentContext: context)) @@ -4695,11 +4719,15 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { var continuation: CheckedContinuation? let task = Task { await manager.presentRequests { _ in + if manuallyRequested { + XCTAssertEqual(manager.deferPresentation(request), .retryScheduled) + } await withCheckedContinuation { continuation = $0 } XCTAssertFalse(preparation.complete(route: .confirm, manager: manager, session: session, app: app, sheets: sheets)) } } try await waitUntil { continuation != nil } + XCTAssertEqual(preparation.visibleRequest(manager: manager, session: session, paymentContext: context), request) if manuallyRequested { sheets.hideSheet() @@ -4735,7 +4763,8 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { func testPreparingRequestBecomesReadyInTheSameSendSheetAndPreservesExplicitRetry() async throws { let store = PaymentRequestPresentationMemoryStore() - let manager = try paymentRequestManager(sdk: PaymentRequestSdkMock(records: [paymentRequestRecord()]), presentationStore: store) + let clock = PaymentRequestTestClock(Date()) + let manager = try paymentRequestManager(sdk: PaymentRequestSdkMock(records: [paymentRequestRecord()]), clock: clock, presentationStore: store) await manager.refresh() let request = try XCTUnwrap(manager.pendingRequests.first) let profile = PubkyProfileManager() @@ -4755,6 +4784,19 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { XCTAssertNil(app.contactPaymentContext) XCTAssertFalse(app.hasSendPaymentTarget) + for _ in 0 ..< 2 { + await manager.presentRequests { _ in + XCTAssertEqual(manager.deferPresentation(request), .retryScheduled) + XCTAssertFalse(manager.isCurrentPresentation(request)) + XCTAssertEqual(preparation.visibleRequest(manager: manager, session: session, paymentContext: nil), request) + } + XCTAssertEqual(preparation.visibleRequest(manager: manager, session: session, paymentContext: nil), request) + XCTAssertEqual(sheets.activeSheetConfiguration?.presentationID, presentationID) + XCTAssertFalse(manager.isApprovedForPayment(request)) + XCTAssertTrue(try store.load(identity: "pubky\(String(repeating: "z", count: 52))").isEmpty) + clock.advance(by: 2) + } + await manager.presentRequests { _ in XCTAssertFalse(preparation.complete(route: .confirm, manager: manager, session: session, app: app, sheets: sheets)) let context = ContactPaymentContext(publicKey: request.counterparty, incomingPaymentRequest: request) @@ -5010,9 +5052,21 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { } func testPreparedOneTimePaymentRequiresAcceptedHistory() async throws { - let states: [PaymentRequestLifecycleState?] = [.accepted, .canceled, .proofSubmitted, .rejected, nil] - for state in states { - let isAccepted = state == .accepted + let cases: [(PaymentRequestRecord?, Bool)] = try [ + (paymentRequestRecord(state: .accepted), true), + (paymentRequestRecord(state: .canceled), false), + (paymentRequestRecord(state: .proofSubmitted), false), + (paymentRequestRecord(state: .rejected), false), + (nil, false), + (paymentRequestRecord(state: .recoveryRequired, acceptedEventId: "accepted"), true), + (paymentRequestRecord(state: .recoveryRequired), false), + (paymentRequestRecord(state: .recoveryRequired, acceptedEventId: "accepted", canceledEventId: "canceled"), false), + (paymentRequestRecord(state: .recoveryRequired, acceptedEventId: "accepted", rejectedEventId: "rejected"), false), + (paymentRequestRecord(state: .recoveryRequired, acceptedEventId: "accepted", paymentProofs: [ + paymentProofRecord(endpoint: "btc-lightning-bolt11", kind: .lightning), + ]), false), + ] + for (record, isAccepted) in cases { let sdk = try PaymentRequestSdkMock(records: [paymentRequestRecord()]) let manager = paymentRequestManager(sdk: sdk) await manager.refresh() @@ -5030,14 +5084,14 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { } onChange: { approvalChanged?.fulfill() }) - let records = try state.map { try [paymentRequestRecord(state: $0)] } ?? [] + let records = record.map { [$0] } ?? [] await sdk.setRecords(records) await manager.refresh(mode: .stored) if let approvalChanged { await fulfillment(of: [approvalChanged], timeout: 1) } XCTAssertTrue(manager.pendingRequests.isEmpty) - XCTAssertEqual(manager.isApprovedForPayment(request), isAccepted, "\(String(describing: state))") + XCTAssertEqual(manager.isApprovedForPayment(request), isAccepted, "\(String(describing: record?.state))") for root in [SendRoute.confirm, .lnurlPayConfirm] { let isAvailable = manager.isApprovedForPayment(request) XCTAssertEqual(SendSheet.shouldDismissUnavailableRequest( diff --git a/journeys/paykit-clock-changes.md b/journeys/paykit-clock-changes.md index 666bde2a3..1ae623f67 100644 --- a/journeys/paykit-clock-changes.md +++ b/journeys/paykit-clock-changes.md @@ -26,6 +26,7 @@ On iOS, Ring identities are selected from the shared identity list. There is no 5. While a payment request is temporarily unavailable and presentation is retrying, move the clock forward and backward. Retry intervals should remain short, while actual payment expiry and approval continue to use absolute timestamps. 6. Tap a due subscription reminder during background contact preparation, then repeat with Bitkit closed before the tap. After authentication and unlock, the app must resolve the due period from shared state without waiting for the whole contact-preparation round. Normal maintenance must still run afterward. Record tap-to-sheet timing separately from authentication and any SDK lock wait. 7. Repeat the cold-launch reminder while the startup request refresh fails before the stored request snapshot loads. Keep the reminder pending through the failure. Restore connectivity and allow a later refresh to open the exact due period without tapping the reminder again. Clear a stale reminder only after a successful snapshot confirms that period was handled or the subscription is inactive. +8. Open a due reminder whose payment cannot be prepared. The same Send sheet must remain open through resolution retries and close once on terminal feedback, without reopening from that reminder. Repeat and close the preparing sheet between retries; it must stay closed while the period remains pending and manually reopenable. An insufficient-balance or pending-private-link result must also end that reminder's presentation without an automatic reopen cycle. These steps describe the remaining manual verification. Unit tests cover injected restoration failures, state preservation, retry timing, UTC recurrence, and notification scheduling; they do not replace a live grant-session clock-change test. diff --git a/journeys/payment-requests/requested-resolution-failure.xml b/journeys/payment-requests/requested-resolution-failure.xml index ccff48242..f8cb258f2 100644 --- a/journeys/payment-requests/requested-resolution-failure.xml +++ b/journeys/payment-requests/requested-resolution-failure.xml @@ -16,6 +16,7 @@ Verify the incoming request row (id "PaymentRequestRow-<payment-request-id>-one-time") is visible Tap Pay (id "PaymentRequestPay-<payment-request-id>") Verify the existing Payment Request confirmation sheet (id "PaymentRequestConfirm") shows the sender, amount and note, with the payment swipe control (id "GRAB") disabled and showing progress while the request is prepared + Verify the same preparing confirmation stays open across resolution retries without dismissing and reopening Wait up to 35 seconds for the terminal error toast (id "PaymentRequestUnavailableToast") Verify the preparing confirmation sheet is no longer visible Verify the toast title is "Payment Request" and its description is "The payment request is no longer available." @@ -23,7 +24,7 @@ Verify the incoming request row (id "PaymentRequestRow-<payment-request-id>-one-time") remains visible for a later retry Open the incoming request row and tap Pay in its details (id "PaymentRequestDetailsPay") Verify the existing Payment Request confirmation sheet shows the same sender, amount and note, with the payment swipe control disabled and showing progress - Close the preparing confirmation and verify the underlying details remain available and the request does not immediately reopen + Close the preparing confirmation between resolution retries and verify the underlying details remain available and the request does not immediately reopen or retry automatically While preparation is pending, navigate to Dev settings and turn off Enable Paykit UI (id "PaykitUiToggle") Return to the wallet home screen and verify no payment sheet opens From 591ac1240dc027429f39085ea2e1740a36968cd3 Mon Sep 17 00:00:00 2001 From: benk10 Date: Tue, 6 Oct 2026 08:25:44 -0500 Subject: [PATCH 62/68] perf: adopt Paykit rc63 runtime updates --- Bitkit.xcodeproj/project.pbxproj | 2 +- .../xcshareddata/swiftpm/Package.resolved | 4 +- Bitkit/Managers/ContactsManager.swift | 41 +++------ Bitkit/Services/PubkyService.swift | 82 +++++++++++++---- .../Testing/ContactImportUITestFixture.swift | 7 +- .../CreatePaymentRequestView.swift | 4 +- BitkitTests/ContactsManagerTests.swift | 89 ++++++++++-------- .../PaykitBackupStateTrackingTests.swift | 92 +++++++++++-------- BitkitTests/PaykitContactLifecycleTests.swift | 42 +++++++-- BitkitTests/PaykitSdkClientConfigTests.swift | 58 ++++++++++++ BitkitTests/PaykitSdkIdentityCheckTests.swift | 45 ++++++++- BitkitTests/PaykitSdkOperationLockTests.swift | 4 + journeys/payment-requests/request-summary.xml | 1 + 13 files changed, 336 insertions(+), 135 deletions(-) diff --git a/Bitkit.xcodeproj/project.pbxproj b/Bitkit.xcodeproj/project.pbxproj index 1831f6044..02877fb46 100644 --- a/Bitkit.xcodeproj/project.pbxproj +++ b/Bitkit.xcodeproj/project.pbxproj @@ -1178,7 +1178,7 @@ repositoryURL = "https://github.com/pubky/paykit-rs"; requirement = { kind = exactVersion; - version = "0.1.0-rc62"; + version = "0.1.0-rc63"; }; }; 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 712dcf93c..1510cd2eb 100644 --- a/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -33,8 +33,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pubky/paykit-rs", "state" : { - "revision" : "f2c5f7127b1cb0f29052ce76e43f3c0ae468fd12", - "version" : "0.1.0-rc62" + "revision" : "cec06146ceeefdb9341a00208388514d1aef47ac", + "version" : "0.1.0-rc63" } }, { diff --git a/Bitkit/Managers/ContactsManager.swift b/Bitkit/Managers/ContactsManager.swift index 6283c8164..8a27b2c21 100644 --- a/Bitkit/Managers/ContactsManager.swift +++ b/Bitkit/Managers/ContactsManager.swift @@ -628,41 +628,33 @@ class ContactsManager: ObservableObject { /// Remembers the profiles it saves for this session, so the Contacts list shows them at once rather than only their /// saved labels. A placeholder for a follow whose lookup failed is not remembered, so that profile is still looked up. /// An import outlives its screens, so a reset or another identity's load while it runs, such as after a sign-out, - /// stops it quietly: it saves nothing more, adds nothing to the next session's list and reports no error. + /// drops its result quietly: it adds nothing to the next session's list and reports no error. func importContacts( contacts selected: [PubkyContact], - saveContact: (String, String) async throws -> Void = { publicKey, label in - _ = try await PubkyService.saveContact(publicKey: publicKey, label: label, restorePrivateConnection: true) + saveContacts: ([ContactUpdate], String?) async throws -> Void = { updates, expectedIdentity in + _ = try await PubkyService.saveContacts(updates: updates, expectedIdentity: expectedIdentity) } ) async throws { let profilesGeneration = resolvedProfilesGeneration - var imported: [PubkyContact] = [] + let expectedIdentity = resolvedProfilesOwner var existingKeys = Set(contacts.map(\.publicKey)) - var firstError: Error? + let imported = selected.filter { existingKeys.insert($0.publicKey).inserted } + try Task.checkCancellation() + guard !imported.isEmpty else { return } - for contact in selected { + do { + try await saveContacts(imported.map { ContactUpdate(publicKey: $0.publicKey, label: $0.displayName) }, expectedIdentity) + } catch is CancellationError { + throw CancellationError() + } catch { try Task.checkCancellation() - guard profilesGeneration == resolvedProfilesGeneration else { break } - guard !existingKeys.contains(contact.publicKey) else { continue } - do { - // The preview already resolved this profile. - try await saveContact(contact.publicKey, contact.displayName) - imported.append(contact) - existingKeys.insert(contact.publicKey) - } catch is CancellationError { - throw CancellationError() - } catch { - firstError = firstError ?? error - Logger.warn( - "Failed to save imported contact '\(PubkyPublicKeyFormat.redacted(contact.publicKey))': \(error)", - context: "ContactsManager" - ) - } + guard profilesGeneration == resolvedProfilesGeneration else { return } + throw error } try Task.checkCancellation() guard profilesGeneration == resolvedProfilesGeneration else { - Logger.info("Stopped a contact import that a reset overtook after \(imported.count) saves", context: "ContactsManager") + Logger.info("Discarded a contact import that a reset overtook", context: "ContactsManager") return } for contact in imported { @@ -671,9 +663,6 @@ class ContactsManager: ObservableObject { let currentKeys = Set(contacts.map(\.publicKey)) contacts = (contacts + imported.filter { !currentKeys.contains($0.publicKey) }).sorted(by: Self.isOrderedByName) Logger.info("Imported \(imported.count) new contacts", context: "ContactsManager") - if let firstError { - throw firstError - } } // MARK: - Update Contact diff --git a/Bitkit/Services/PubkyService.swift b/Bitkit/Services/PubkyService.swift index 9ac9404cd..fb31c8c89 100644 --- a/Bitkit/Services/PubkyService.swift +++ b/Bitkit/Services/PubkyService.swift @@ -307,6 +307,10 @@ enum PubkyService { ) } + static func saveContacts(updates: [Paykit.ContactUpdate], expectedIdentity: String? = nil) async throws -> [Paykit.ContactRecord] { + try await PaykitSdkService.shared.saveContacts(updates: updates, expectedIdentity: expectedIdentity) + } + static func removeContact(publicKey: String) async throws -> Paykit.ContactRecord? { try await PaykitSdkService.shared.removeContact(publicKey: publicKey) } @@ -761,6 +765,35 @@ actor PaykitSdkService { } } + func saveContacts(updates: [Paykit.ContactUpdate], expectedIdentity: String? = nil) async throws -> [Paykit.ContactRecord] { + guard !updates.isEmpty else { return [] } + return try await withStateRevisionTracking { sdk in + if let expectedIdentity { + try await Self.requireSignedInIdentity(expectedIdentity, in: sdk) + } + let blockedPeers = try await sdk.linkedPeers().filter { peer in + peer.state == .blocked && updates.contains { PubkyPublicKeyFormat.matches($0.publicKey, peer.counterparty) } + } + do { + for peer in blockedPeers { + _ = try await sdk.unblockPeer(counterparty: peer.counterparty) + } + return try await sdk.saveContacts(updates: updates) + } catch { + let restorationError = error + for peer in blockedPeers { + do { + _ = try await sdk.blockPeer(counterparty: peer.counterparty) + } catch { + invalidatePaykitKeyIfNeeded(after: error) + Logger.error("Failed to restore peer block after contact save failed: \(error)", context: "PaykitSdkService") + } + } + throw restorationError + } + } + } + func removeContact(publicKey: String) async throws -> Paykit.ContactRecord? { try await withStateRevisionTracking { sdk in let peers = try await sdk.linkedPeers().filter { PubkyPublicKeyFormat.matches($0.counterparty, publicKey) } @@ -1237,6 +1270,11 @@ actor PaykitSdkService { return try await Self.withBackupStateRevisionTracking( readRevision: { try await self.withSdkErrorHandling { try await sdk.backupStateRevision() } }, readStateRevision: { try sdk.stateRevision() }, + readObservedSnapshot: { + try sdk.observedBackupStateRevision().map { + BackupStateSnapshot(stateRevision: $0.stateRevision, backupRevision: $0.backupRevision) + } + }, cachedSnapshot: self.cachedBackupState, onSnapshot: { self.cachedBackupState = $0 }, onChange: { self.markWalletBackupDataChanged() }, @@ -1248,18 +1286,18 @@ actor PaykitSdkService { static func withBackupStateRevisionTracking( readRevision: () async throws -> String, readStateRevision: () throws -> String? = { nil }, + readObservedSnapshot: () throws -> BackupStateSnapshot? = { nil }, cachedSnapshot: BackupStateSnapshot? = nil, onSnapshot: (BackupStateSnapshot?) -> Void = { _ in }, onChange: () async -> Void, operation: () async throws -> T ) async throws -> T { - let observedStateRevision = try? readStateRevision() - let previousRevision: String? = if let cachedSnapshot, observedStateRevision == cachedSnapshot.stateRevision { + // An intervening SDK read must not replace the last backup comparison baseline. + let previousRevision: String? = if let cachedSnapshot { cachedSnapshot.backupRevision } else { try? await readRevision() } - let previousStateRevision = try? readStateRevision() let result: T do { result = try await operation() @@ -1269,22 +1307,26 @@ actor PaykitSdkService { await onChange() throw error } - let nextStateRevision = try? readStateRevision() - if let previousStateRevision, - previousStateRevision == nextStateRevision, - let previousRevision + let nextRevision: String? + let nextSnapshot: BackupStateSnapshot? + if Task.isCancelled { + nextRevision = nil + nextSnapshot = nil + } else if let observed = try? readObservedSnapshot(), + observed.stateRevision == (try? readStateRevision()) { - onSnapshot(BackupStateSnapshot(stateRevision: previousStateRevision, backupRevision: previousRevision)) - return result - } - - let nextRevision = try? await readRevision() - if let stateRevision = try? readStateRevision(), let nextRevision { - onSnapshot(BackupStateSnapshot(stateRevision: stateRevision, backupRevision: nextRevision)) + nextRevision = observed.backupRevision + nextSnapshot = observed } else { - onSnapshot(nil) + nextRevision = try? await readRevision() + if let stateRevision = try? readStateRevision(), let nextRevision { + nextSnapshot = BackupStateSnapshot(stateRevision: stateRevision, backupRevision: nextRevision) + } else { + nextSnapshot = nil + } } - if previousRevision == nil || nextRevision == nil || previousRevision != nextRevision { + onSnapshot(Task.isCancelled ? nil : nextSnapshot) + if Task.isCancelled || previousRevision == nil || nextRevision == nil || previousRevision != nextRevision { await onChange() } return result @@ -1303,8 +1345,10 @@ actor PaykitSdkService { private func refreshPaykitKey(force: Bool = false) async throws { if force { cachedPaykitKey = nil + cachedBackupState = nil } guard let root = try sessionProvider.loadLocalSecretKey() else { + if cachedPaykitKey != nil { cachedBackupState = nil } cachedPaykitKey = nil return } @@ -1315,6 +1359,7 @@ actor PaykitSdkService { return } cachedPaykitKey = nil + cachedBackupState = nil let key = try await paykitKey(for: root) sessionProvider.setPaykitIdentitySecretKey(key) cachedPaykitKey = (publicKey, key.keyGeneration()) @@ -1428,7 +1473,10 @@ actor PaykitSdkService { // Read cached identity metadata without restoring the grant we are about to replace. sessionProvider.suspendStoredSessionAccess() defer { sessionProvider.resumeStoredSessionAccess() } - return try await handle().identityStatus()?.publicKey + if sdk == nil { + sdk = try sdkFactory?() + } + return try await sdk?.identityStatus()?.publicKey } /// Throws `identityChanged` unless `expectedIdentity` is the identity `sdk` is signed in as. Run it inside the locked diff --git a/Bitkit/Utilities/Testing/ContactImportUITestFixture.swift b/Bitkit/Utilities/Testing/ContactImportUITestFixture.swift index e21364973..3e1334282 100644 --- a/Bitkit/Utilities/Testing/ContactImportUITestFixture.swift +++ b/Bitkit/Utilities/Testing/ContactImportUITestFixture.swift @@ -1,4 +1,5 @@ #if DEBUG + import Paykit import SwiftUI struct ContactImportUITestFixture: View { @@ -70,17 +71,17 @@ override func importContacts( contacts selected: [PubkyContact], - saveContact: (String, String) async throws -> Void + saveContacts: ([ContactUpdate], String?) async throws -> Void ) async throws { importCount += 1 - try await super.importContacts(contacts: selected) { key, _ in + try await super.importContacts(contacts: selected) { updates, _ in if self.savedKeys.isEmpty { await withCheckedContinuation { continuation in self.pendingSave = continuation self.isSavePending = true } } - self.savedKeys.append(key) + self.savedKeys.append(contentsOf: updates.map(\.publicKey)) } } diff --git a/Bitkit/Views/PaymentRequests/CreatePaymentRequestView.swift b/Bitkit/Views/PaymentRequests/CreatePaymentRequestView.swift index 0338dc6c1..df40d3de3 100644 --- a/Bitkit/Views/PaymentRequests/CreatePaymentRequestView.swift +++ b/Bitkit/Views/PaymentRequests/CreatePaymentRequestView.swift @@ -541,7 +541,9 @@ struct PaymentRequestSentView: View { var body: some View { VStack(alignment: .leading, spacing: 0) { - SheetHeader(title: t("wallet__payment_request_sent_title")) + SheetHeader(title: t(request.deliveryStatus == .sent + ? "wallet__payment_request_sent_title" + : "subscriptions__proposal_queued_title")) Spacer(minLength: 8) diff --git a/BitkitTests/ContactsManagerTests.swift b/BitkitTests/ContactsManagerTests.swift index 271046cb5..053d4e530 100644 --- a/BitkitTests/ContactsManagerTests.swift +++ b/BitkitTests/ContactsManagerTests.swift @@ -43,13 +43,14 @@ final class ContactsManagerTests: XCTestCase { func testImportSavesPreparedContactsWithoutNetworkAndSkipsDuplicates() async throws { let manager = ContactsManager() let prepared = (0 ..< 62).map { makeContact(publicKey: "pubky-contact-\($0)") } - var saved: [String] = [] - try await manager.importContacts(contacts: prepared + prepared) { key, label in - XCTAssertEqual(label, "Alice") - saved.append(key) + var batches: [[ContactUpdate]] = [] + try await manager.importContacts(contacts: prepared + prepared) { updates, _ in + XCTAssertTrue(updates.allSatisfy { $0.label == "Alice" }) + batches.append(updates) } - XCTAssertEqual(saved, prepared.map(\.publicKey)) + XCTAssertEqual(batches.count, 1) + XCTAssertEqual(batches.first?.map(\.publicKey), prepared.map(\.publicKey)) XCTAssertEqual(Set(manager.contacts), Set(prepared)) XCTAssertEqual(manager.contacts.count, 62) } @@ -76,52 +77,62 @@ final class ContactsManagerTests: XCTestCase { XCTAssertEqual(manager.pendingImportContacts, expected) var saved: [String] = [] - try await manager.importContacts(contacts: manager.pendingImportContacts) { publicKey, _ in - XCTAssertFalse(PubkyPublicKeyFormat.matches(publicKey, ownPublicKey)) - saved.append(publicKey) + try await manager.importContacts(contacts: manager.pendingImportContacts) { updates, _ in + XCTAssertFalse(updates.contains { PubkyPublicKeyFormat.matches($0.publicKey, ownPublicKey) }) + saved.append(contentsOf: updates.map(\.publicKey)) } XCTAssertEqual(saved, expected.map(\.publicKey)) XCTAssertEqual(manager.contacts, expected) } } - func testImportPreservesSavedContactsOnFailureAndRetriesMissingContacts() async throws { + func testImportPreservesExistingContactsOnBatchFailureAndRetriesSelectedContacts() async throws { let manager = ContactsManager() + let existing = makeContact(publicKey: "pubky-existing") + manager.contacts = [existing] let alice = makeContact(publicKey: "pubky-alice") let bob = makeContact(publicKey: "pubky-bob") - var saved: [String] = [] + var attempted: [[String]] = [] do { - try await manager.importContacts(contacts: [alice, bob]) { key, _ in - if key == bob.publicKey { - throw CocoaError(.fileWriteUnknown) - } - saved.append(key) + try await manager.importContacts(contacts: [existing, alice, bob]) { updates, _ in + attempted.append(updates.map(\.publicKey)) + throw CocoaError(.fileWriteUnknown) } XCTFail("Import should report the failed save") } catch { - XCTAssertEqual(manager.contacts, [alice]) + XCTAssertEqual(manager.contacts, [existing]) } - try await manager.importContacts(contacts: [alice, bob]) { key, _ in saved.append(key) } - XCTAssertEqual(saved, [alice.publicKey, bob.publicKey]) - XCTAssertEqual(Set(manager.contacts), Set([alice, bob])) + try await manager.importContacts(contacts: [existing, alice, bob]) { updates, _ in + attempted.append(updates.map(\.publicKey)) + } + XCTAssertEqual(attempted, Array(repeating: [alice.publicKey, bob.publicKey], count: 2)) + XCTAssertEqual(Set(manager.contacts), Set([existing, alice, bob])) } func testCancelledImportStopsSavingAndDoesNotPublishStaleResults() async throws { - let manager = ContactsManager() let prepared = (0 ..< 3).map { makeContact(publicKey: "pubky-contact-\($0)") } - var attempted: [String] = [] - do { - try await manager.importContacts(contacts: prepared) { key, _ in - attempted.append(key) - if key == prepared[1].publicKey { - throw CancellationError() + for heldSaveSucceeds in [true, false] { + let manager = ContactsManager() + let saves = HeldProfileLookups(profiles: heldSaveSucceeds ? [prepared[0].publicKey: "Alice"] : [:]) + await saves.hold() + let importTask = Task { + try await manager.importContacts(contacts: prepared) { updates, _ in + XCTAssertEqual(updates.map(\.publicKey), prepared.map(\.publicKey)) + _ = try await saves.fetch(XCTUnwrap(updates.first).publicKey) } } - XCTFail("Import should propagate cancellation") - } catch is CancellationError { - XCTAssertEqual(attempted, Array(prepared.prefix(2)).map(\.publicKey)) - XCTAssertTrue(manager.contacts.isEmpty) + while await saves.heldCount < 1 { + await Task.yield() + } + importTask.cancel() + await saves.release() + do { + try await importTask.value + XCTFail("Import should propagate cancellation") + } catch is CancellationError { + XCTAssertTrue(manager.contacts.isEmpty) + } } } @@ -717,7 +728,7 @@ final class ContactsManagerTests: XCTestCase { XCTAssertEqual(changes.count, 2, "An import announces its new contacts once") XCTAssertEqual(changes.last, Set([contactProfileKey] + imported.map(\.publicKey))) - try await manager.importContacts(contacts: imported) { _, _ in } + try await manager.importContacts(contacts: imported) { _, _ in XCTFail("Already saved contacts must not be saved again") } XCTAssertEqual(changes.count, 2, "An import that adds nothing announces nothing") } @@ -1750,8 +1761,9 @@ final class ContactsManagerTests: XCTestCase { let saves = HeldProfileLookups(profiles: [contactProfileKey: "Alice"]) await saves.hold() let importTask = Task { - try await manager.importContacts(contacts: [Bitkit.PubkyContact(publicKey: contactProfileKey, profile: published)]) { key, _ in - _ = try await saves.fetch(key) + try await manager.importContacts(contacts: [Bitkit.PubkyContact(publicKey: contactProfileKey, profile: published)]) { updates, identity in + XCTAssertEqual(identity, "owner") + _ = try await saves.fetch(XCTUnwrap(updates.first).publicKey) } } while await saves.heldCount < 1 { @@ -1772,17 +1784,18 @@ final class ContactsManagerTests: XCTestCase { await manager.waitForProfileRefreshForTesting() } - func testImportThatAResetOvertakesStopsSavingAndReportsNothing() async throws { + func testImportThatAResetOvertakesDoesNotPublishOrReportFailure() async throws { let prepared = (0 ..< 3).map { makeContact(publicKey: "pubky-contact-\($0)") } - // The save running when the user signs out may land first or fail after the sign-out; later saves would fail. for heldSaveSucceeds in [true, false] { let manager = ContactsManager() try await manager.loadContacts(for: "owner", fetchContactRecords: { [] }, fetchRemoteProfile: { _ in nil }) let saves = HeldProfileLookups(profiles: heldSaveSucceeds ? [prepared[0].publicKey: "Alice"] : [:]) await saves.hold() let importTask = Task { - try await manager.importContacts(contacts: prepared) { key, _ in - _ = try await saves.fetch(key) + try await manager.importContacts(contacts: prepared) { updates, identity in + XCTAssertEqual(identity, "owner") + XCTAssertEqual(updates.map(\.publicKey), prepared.map(\.publicKey)) + _ = try await saves.fetch(XCTUnwrap(updates.first).publicKey) } } while await saves.heldCount < 1 { @@ -1798,7 +1811,7 @@ final class ContactsManagerTests: XCTestCase { } let attempted = await saves.fetchedKeys - XCTAssertEqual(attempted, [prepared[0].publicKey], "A reset stops the import before its next save") + XCTAssertEqual(attempted, [prepared[0].publicKey], "The import makes one batch save") XCTAssertTrue(manager.contacts.isEmpty, "An import a reset overtook adds nothing to the cleared list") } } diff --git a/BitkitTests/PaykitBackupStateTrackingTests.swift b/BitkitTests/PaykitBackupStateTrackingTests.swift index 81cc26ff8..6f98d6503 100644 --- a/BitkitTests/PaykitBackupStateTrackingTests.swift +++ b/BitkitTests/PaykitBackupStateTrackingTests.swift @@ -50,73 +50,89 @@ final class PaykitBackupStateTrackingTests: XCTestCase { } func testCancellationAfterMutationStillRequestsBackup() async { - var revision = "before" - var changes = 0 - let task = Task { - try await PaykitSdkService.withBackupStateRevisionTracking( - readRevision: { - try Task.checkCancellation() - return revision - }, - onChange: { changes += 1 }, - operation: { - revision = "after" - withUnsafeCurrentTask { $0?.cancel() } - try Task.checkCancellation() - } - ) - } - do { - try await task.value - XCTFail("Expected cancellation") - } catch { - XCTAssertTrue(error is CancellationError) + for throwsCancellation in [false, true] { + var revision = "before" + var snapshot: PaykitSdkService.BackupStateSnapshot? = .init(stateRevision: "state", backupRevision: revision) + var reads = 0 + var changes = 0 + let task = Task { + try await PaykitSdkService.withBackupStateRevisionTracking( + readRevision: { reads += 1; return revision }, + readStateRevision: { "state" }, + readObservedSnapshot: { .init(stateRevision: "state", backupRevision: revision) }, + cachedSnapshot: snapshot, + onSnapshot: { snapshot = $0 }, + onChange: { changes += 1 }, + operation: { + revision = "after" + withUnsafeCurrentTask { $0?.cancel() } + if throwsCancellation { try Task.checkCancellation() } + } + ) + } + do { + try await task.value + XCTAssertFalse(throwsCancellation) + } catch { + XCTAssertTrue(throwsCancellation) + XCTAssertTrue(error is CancellationError) + } + XCTAssertEqual(revision, "after") + XCTAssertEqual(changes, 1) + XCTAssertEqual(reads, 0) + XCTAssertNil(snapshot) } - XCTAssertEqual(revision, "after") - XCTAssertEqual(changes, 1) } func testUnchangedOperationsReuseBackupFingerprintWithoutRemoteReads() async throws { var snapshot: PaykitSdkService.BackupStateSnapshot? + var state = "initial" var reads = 0 var changes = 0 - for _ in 0 ..< 3 { + for index in 0 ..< 3 { try await PaykitSdkService.withBackupStateRevisionTracking( readRevision: { reads += 1; return "content" }, - readStateRevision: { "state" }, + readStateRevision: { state }, + readObservedSnapshot: { .init(stateRevision: state, backupRevision: "content") }, cachedSnapshot: snapshot, onSnapshot: { snapshot = $0 }, onChange: { changes += 1 }, - operation: {} + operation: { state = "lease-\(index)" } ) } XCTAssertEqual(reads, 1) XCTAssertEqual(changes, 0) + XCTAssertEqual(snapshot?.stateRevision, "lease-2") XCTAssertEqual(snapshot?.backupRevision, "content") } - func testStorageRevisionChangesStillCompareBackupContent() async throws { - for (cachedState, finalContent, expectedReads, expectedChanges) in [ - ("before", "content", 1, 0), - ("before", "changed", 1, 1), - ("stale", "changed", 2, 1), + func testObservedRevisionsCompareWithCachedBackupContent() async throws { + for (observedState, finalContent, expectedReads, expectedChanges) in [ + ("current", "content", 0, 0), + ("current", "changed", 0, 1), + ("unavailable", "changed", 1, 1), + ("stale", "changed", 1, 1), + ("unreadable", "changed", 1, 1), ] { - var state = "before" - var content = "content" var reads = 0 var changes = 0 var snapshot: PaykitSdkService.BackupStateSnapshot? try await PaykitSdkService.withBackupStateRevisionTracking( - readRevision: { reads += 1; return content }, - readStateRevision: { state }, - cachedSnapshot: .init(stateRevision: cachedState, backupRevision: "content"), + readRevision: { reads += 1; return finalContent }, + readStateRevision: { "current" }, + readObservedSnapshot: { + if observedState == "unavailable" { return nil } + if observedState == "unreadable" { throw Failure.revision } + return .init(stateRevision: observedState, backupRevision: finalContent) + }, + cachedSnapshot: .init(stateRevision: observedState == "current" ? "previous" : "current", backupRevision: "content"), onSnapshot: { snapshot = $0 }, onChange: { changes += 1 }, - operation: { state = "after"; content = finalContent } + operation: {} ) XCTAssertEqual(reads, expectedReads) XCTAssertEqual(changes, expectedChanges) - XCTAssertEqual(snapshot?.stateRevision, "after") + XCTAssertEqual(snapshot?.stateRevision, "current") XCTAssertEqual(snapshot?.backupRevision, finalContent) } } diff --git a/BitkitTests/PaykitContactLifecycleTests.swift b/BitkitTests/PaykitContactLifecycleTests.swift index 1a3bbebba..98e273f40 100644 --- a/BitkitTests/PaykitContactLifecycleTests.swift +++ b/BitkitTests/PaykitContactLifecycleTests.swift @@ -156,16 +156,23 @@ final class PaykitContactLifecycleTests: XCTestCase { } catch {} XCTAssertNil(sdk.record) XCTAssertTrue(sdk.peers.allSatisfy { $0.state == .blocked }) - _ = try await service.saveContact(publicKey: sdk.publicKey, label: "Readded", restorePrivateConnection: true) - XCTAssertTrue(sdk.peers.allSatisfy { $0.state == .notLinked }) + var unselectedPeer = try XCTUnwrap(sdk.peers.first) + unselectedPeer.counterparty = "pubky" + String(repeating: "z", count: 52) + sdk.peers.append(unselectedPeer) + _ = try await service.saveContacts(updates: [ContactUpdate(publicKey: sdk.publicKey, label: "Readded")]) + XCTAssertEqual(sdk.peers.first?.state, .notLinked) + XCTAssertEqual(sdk.peers.last?.state, .blocked) XCTAssertEqual(sdk.record?.label, "Readded") } func testFailedPrivateConnectionRestoreCanBeRetriedWithoutASavedContact() async throws { - let failures: [(peerLookup: Bool, unblock: Bool, saveContact: Bool)] = [ - (true, false, false), - (false, true, false), - (false, false, true), + let failures: [(peerLookup: Bool, unblock: Bool, saveContact: Bool, bulk: Bool)] = [ + (true, false, false, false), + (false, true, false, false), + (false, false, true, false), + (true, false, false, true), + (false, true, false, true), + (false, false, true, true), ] for failure in failures { let sdk = ContactLifecycleSdk(noPointer: .init()) @@ -174,8 +181,15 @@ final class PaykitContactLifecycleTests: XCTestCase { sdk.failLinkedPeers = failure.peerLookup sdk.failUnblock = failure.unblock sdk.failSaveContact = failure.saveContact + let save = { + if failure.bulk { + _ = try await service.saveContacts(updates: [ContactUpdate(publicKey: sdk.publicKey, label: "Contact")]) + } else { + _ = try await service.saveContact(publicKey: sdk.publicKey, label: "Contact", restorePrivateConnection: true) + } + } do { - _ = try await service.saveContact(publicKey: sdk.publicKey, label: "Contact", restorePrivateConnection: true) + try await save() XCTFail("Expected restoration to fail") } catch {} XCTAssertNil(sdk.record) @@ -183,7 +197,7 @@ final class PaykitContactLifecycleTests: XCTestCase { sdk.failLinkedPeers = false sdk.failUnblock = false sdk.failSaveContact = false - _ = try await service.saveContact(publicKey: sdk.publicKey, label: "Contact", restorePrivateConnection: true) + try await save() XCTAssertNotNil(sdk.record) XCTAssertTrue(sdk.peers.allSatisfy { $0.state == .notLinked }) } @@ -326,6 +340,10 @@ private final class ContactLifecycleSdk: PaykitSdk, @unchecked Sendable { "revision" } + override func observedBackupStateRevision() throws -> ObservedBackupStateRevision? { + nil + } + override func stateRevision() throws -> String? { nil } @@ -414,4 +432,12 @@ private final class ContactLifecycleSdk: PaykitSdk, @unchecked Sendable { record = saved return saved } + + override func saveContacts(updates: [ContactUpdate]) async throws -> [ContactRecord] { + var saved: [ContactRecord] = [] + for update in updates { + try await saved.append(saveContact(update: update)) + } + return saved + } } diff --git a/BitkitTests/PaykitSdkClientConfigTests.swift b/BitkitTests/PaykitSdkClientConfigTests.swift index 65512ed48..5b09d4785 100644 --- a/BitkitTests/PaykitSdkClientConfigTests.swift +++ b/BitkitTests/PaykitSdkClientConfigTests.swift @@ -173,21 +173,30 @@ final class PaykitSdkClientConfigTests: XCTestCase { _ = try await service.contactRecords() _ = try await service.contactRecords() XCTAssertEqual(sdk.registryPublicKeys, [publicKeys[0]]) + try await service.syncPaykitApp(privatePaymentsEnabled: false) + try await service.syncPaykitApp(privatePaymentsEnabled: false) + XCTAssertEqual(sdk.backupReads, 1) sdk.registry = PaykitAppRegistry(keyGeneration: 2, noisePublicKey: nil, apps: [], defaultAppId: nil, defaultAppsByEndpoint: [:]) let authorizationKey = try await service.paykitKeyForAuthorization(secretKeyHex: secrets[0]) XCTAssertEqual(authorizationKey.keyGeneration(), 2) _ = try await service.contactRecords() XCTAssertEqual(sdk.registryPublicKeys.count, 3) + try await service.syncPaykitApp(privatePaymentsEnabled: false) + XCTAssertEqual(sdk.backupReads, 2) try Keychain.upsert(key: .pubkySecretKey, data: Data(secrets[1].utf8)) _ = try await service.contactRecords() XCTAssertEqual(sdk.registryPublicKeys.last, publicKeys[1]) XCTAssertEqual(sdk.registryPublicKeys.count, 4) + try await service.syncPaykitApp(privatePaymentsEnabled: false) + XCTAssertEqual(sdk.backupReads, 3) await service.clearState() _ = try await service.contactRecords() XCTAssertEqual(sdk.registryPublicKeys.count, 5) + try await service.syncPaykitApp(privatePaymentsEnabled: false) + XCTAssertEqual(sdk.backupReads, 4) sdk.registryError = PubkyServiceError.authFailed("Registry unavailable") do { @@ -199,6 +208,14 @@ final class PaykitSdkClientConfigTests: XCTestCase { XCTFail("Failed forced validation must not reuse the previous key cache") } catch {} XCTAssertEqual(sdk.registryPublicKeys.count, 7) + sdk.registryError = nil + try await service.syncPaykitApp(privatePaymentsEnabled: false) + XCTAssertEqual(sdk.backupReads, 5) + + try Keychain.delete(key: .pubkySecretKey) + try await service.syncPaykitApp(privatePaymentsEnabled: false) + try await service.syncPaykitApp(privatePaymentsEnabled: false) + XCTAssertEqual(sdk.backupReads, 6) } @MainActor @@ -596,6 +613,37 @@ final class PaykitSdkClientConfigTests: XCTestCase { try await activation.value } + func testSessionImportDoesNotCreateRuntimeBeforeBootstrapSucceeds() async throws { + let savedSecret = try Keychain.load(key: .pubkySecretKey) + defer { + if let savedSecret { + try? Keychain.upsert(key: .pubkySecretKey, data: savedSecret) + } else { + try? Keychain.delete(key: .pubkySecretKey) + } + } + try Keychain.delete(key: .pubkySecretKey) + let failure = PubkyServiceError.authFailed("Session unavailable") + var didImport = false + let bootstrap = RecoveryBootstrap(noPointer: .init()) + bootstrap.importSessionOperation = { + didImport = true + throw failure + } + let service = PaykitSdkService(bootstrapFactory: { _, _ in bootstrap }) + + do { + _ = try await service.importSession(secret: "saved-session") + XCTFail("Expected session import to fail") + } catch { + XCTAssertEqual(error.localizedDescription, failure.localizedDescription) + } + + XCTAssertTrue(didImport) + let runtime = try XCTUnwrap(Mirror(reflecting: service).children.first { $0.label == "sdk" }) + XCTAssertNil(runtime.value as? PaykitSdk) + } + func testSessionRecoveryCannotReactivateCredentialsAfterForget() async throws { let savedReference = AdoptedPubkyReference.current let secret = String(repeating: "01", count: 32) @@ -766,6 +814,7 @@ private final class CacheActivationSdk: PaykitSdk, @unchecked Sendable { var publicationCalls = 0 var authorizationError: Error? var backupError: Error? + var backupReads = 0 override func ensureLinkWithPeer(counterparty: String, maxAdvanceSteps: UInt32) async throws -> LinkedPeerHandshakeReport { handshakeAdvanceSteps.append(maxAdvanceSteps) @@ -831,10 +880,15 @@ private final class CacheActivationSdk: PaykitSdk, @unchecked Sendable { } override func backupStateRevision() async throws -> String { + backupReads += 1 if let backupError { throw backupError } return "unchanged" } + override func observedBackupStateRevision() throws -> ObservedBackupStateRevision? { + ObservedBackupStateRevision(stateRevision: "state", backupRevision: "unchanged") + } + override func stateRevision() throws -> String? { "state" } @@ -904,6 +958,10 @@ private final class RecoverySdk: PaykitSdk, @unchecked Sendable { "unchanged" } + override func observedBackupStateRevision() throws -> ObservedBackupStateRevision? { + nil + } + override func stateRevision() throws -> String? { nil } diff --git a/BitkitTests/PaykitSdkIdentityCheckTests.swift b/BitkitTests/PaykitSdkIdentityCheckTests.swift index 08df9bec9..715e547dd 100644 --- a/BitkitTests/PaykitSdkIdentityCheckTests.swift +++ b/BitkitTests/PaykitSdkIdentityCheckTests.swift @@ -38,9 +38,14 @@ final class PaykitSdkIdentityCheckTests: XCTestCase { let saved = try await service.saveContact(publicKey: contactKey, label: "Alice", expectedIdentity: bareIdentityA) _ = try await service.saveContact(publicKey: contactKey, label: "Unbound") + let imported = try await service.saveContacts( + updates: [ContactUpdate(publicKey: contactKey, label: "Imported")], + expectedIdentity: bareIdentityA + ) XCTAssertEqual(saved.label, "Alice", "The identity matches in either key form") - XCTAssertEqual(sdk.writes, ["save:Alice", "save:Unbound"], "A save for no identity saves for whichever one is signed in") + XCTAssertEqual(imported.first?.label, "Imported") + XCTAssertEqual(sdk.writes, ["save:Alice", "save:Unbound", "batch:1"], "A save for no identity saves for whichever one is signed in") } func testProfilePublicationForAnIdentityThatIsNoLongerSignedInWritesNothing() async throws { @@ -132,6 +137,12 @@ final class PaykitSdkIdentityCheckTests: XCTestCase { if case .identityChanged? = error as? PubkyServiceError { return true } return false }), + ("contact batch save", { service in + _ = try await service.saveContacts(updates: [ContactUpdate(publicKey: contactKey, label: "Alice")], expectedIdentity: identityA) + }, { error in + if case .identityChanged? = error as? PubkyServiceError { return true } + return false + }), ("profile avatar upload", { service in _ = try await service.uploadAvatar(bytes: Data([1]), contentType: "image/jpeg", expectedIdentity: identityA) }, { error in @@ -175,6 +186,29 @@ final class PaykitSdkIdentityCheckTests: XCTestCase { } } + func testContactBatchCancelledWhileWaitingForTheSdkLockWritesNothing() async throws { + let sdk = IdentitySwitchingSdk(noPointer: .init()) + sdk.identity = identityA + let service = PaykitSdkService(sdkFactory: { sdk }) + await sdk.contactRecordsGate.close() + let lockHolder = Task { try await service.contactRecords() } + try await sdk.contactRecordsStarted.waitForEntries(count: 1) + + let save = Task { + try await service.saveContacts(updates: [ContactUpdate(publicKey: contactKey, label: "Alice")], expectedIdentity: identityA) + } + try await Task.sleep(for: .milliseconds(50)) + save.cancel() + await sdk.contactRecordsGate.open() + _ = try await lockHolder.value + + do { + _ = try await save.value + XCTFail("Expected queued contact import to be cancelled") + } catch is CancellationError {} + XCTAssertTrue(sdk.writes.isEmpty) + } + /// A sign-out queues for the SDK lock right behind a contact save. The lock is handed over in order, so the save runs /// first and saves while its identity is signed in. Were its identity checked in a locked operation of its own, the /// sign-out would take the lock between that check and the save, and the save would land after it. @@ -308,6 +342,10 @@ private final class IdentitySwitchingSdk: PaykitSdk, @unchecked Sendable { "revision" } + override func observedBackupStateRevision() throws -> ObservedBackupStateRevision? { + nil + } + override func stateRevision() throws -> String? { nil } @@ -337,6 +375,11 @@ private final class IdentitySwitchingSdk: PaykitSdk, @unchecked Sendable { return Self.record(publicKey: update.publicKey, label: update.label) } + override func saveContacts(updates: [ContactUpdate]) async throws -> [ContactRecord] { + recordWrite("batch:\(updates.count)") + return updates.map { Self.record(publicKey: $0.publicKey, label: $0.label) } + } + override func uploadProfileAvatar(bytes: Data, contentType _: String) async throws -> PaykitBlobRecord { guard identity != nil, hasLiveSession else { throw noLiveSessionError } recordWrite("upload") diff --git a/BitkitTests/PaykitSdkOperationLockTests.swift b/BitkitTests/PaykitSdkOperationLockTests.swift index eda032247..a72097fef 100644 --- a/BitkitTests/PaykitSdkOperationLockTests.swift +++ b/BitkitTests/PaykitSdkOperationLockTests.swift @@ -420,6 +420,10 @@ private final class PublicReadSdk: PaykitSdk, @unchecked Sendable { "revision" } + override func observedBackupStateRevision() throws -> ObservedBackupStateRevision? { + nil + } + override func receivePrivateMessagesFromLinkedPeers() async throws -> [PrivateStreamCounterpartyIntakeReport] { await intake() return [] diff --git a/journeys/payment-requests/request-summary.xml b/journeys/payment-requests/request-summary.xml index ea1394589..7ee861bd0 100644 --- a/journeys/payment-requests/request-summary.xml +++ b/journeys/payment-requests/request-summary.xml @@ -4,6 +4,7 @@ On the requester, send the payer contact a Payment Request for 21,000 sats with the note "Lunch last week" + On the requester, verify completion (id "PaymentRequestSent") says "Sent" with "You have sent a payment request", or "Queued" with "Your payment request is queued and will send automatically"; the title and description must agree On the payer, verify the Payment Request confirmation screen (id "PaymentRequestConfirm") appears with 21,000 sats without opening the pending Payment Requests bell Verify From (id "PaymentRequestFrom") shows the requester's contact name Verify For (id "PaymentRequestFor") shows "Lunch last week" From 90799f2db6a540ec064d33314180530ed4324ecd Mon Sep 17 00:00:00 2001 From: benk10 Date: Tue, 6 Oct 2026 09:30:36 -0500 Subject: [PATCH 63/68] Prioritize manual payments over pending subscription reminders --- Bitkit/AppScene.swift | 88 +++++++----- .../PaykitPaymentRequestServiceTests.swift | 125 ++++++++++++++++++ journeys/contacts/README.md | 16 ++- journeys/paykit-clock-changes.md | 2 +- 4 files changed, 193 insertions(+), 38 deletions(-) diff --git a/Bitkit/AppScene.swift b/Bitkit/AppScene.swift index 7e0a52abc..b7891c064 100644 --- a/Bitkit/AppScene.swift +++ b/Bitkit/AppScene.swift @@ -183,6 +183,45 @@ final class IncomingPaykitPaymentRequestPreparation { @MainActor enum IncomingPaykitPaymentRequestPresentationDispatcher { + static func canHandleSubscriptionNotification( + manager: PaykitPaymentRequestManager, + app: AppViewModel, + sheets: SheetViewModel + ) -> Bool { + manager.requestedPresentationId == nil && + sheets.activeSheetConfiguration == nil && + !sheets.isReplacingSheet && + app.contactPaymentContext == nil + } + + static func presentNextItem( + manager: PaykitPaymentRequestManager, + sheets: SheetViewModel, + canRetryPreparation: Bool, + handleSubscriptionNotification: () async -> Void, + presentPaymentRequest: () async -> Void + ) async { + guard sheets.activeSheetConfiguration == nil || canRetryPreparation, + !sheets.isReplacingSheet + else { return } + if canRetryPreparation || manager.requestedPresentationId != nil { + await presentPaymentRequest() + return + } + if PaykitSubscriptionNotificationTargetStore.load() != nil { + await handleSubscriptionNotification() + guard PaykitSubscriptionNotificationTargetStore.load() == nil, + sheets.activeSheetConfiguration == nil, + !sheets.isReplacingSheet + else { return } + } + if let subscription = manager.subscriptionProposalForPresentation() { + sheets.showSheet(.subscription, data: SubscriptionSheetItem(route: .review(subscription))) + return + } + await presentPaymentRequest() + } + static func feedback( deferring request: PaykitPaymentRequest, reason: IncomingPaykitPaymentRequestFailureReason, @@ -1601,19 +1640,21 @@ struct AppScene: View { pubkyProfile.authState == .authenticated else { return } guard let target = PaykitSubscriptionNotificationTargetStore.load() else { return } - guard let identity = pubkyProfile.publicKey else { return } - guard target.matches(identity: identity) else { + guard let session = pubkyProfile.currentSession else { return } + guard target.matches(identity: session.publicKey) else { PaykitSubscriptionNotificationTargetStore.clear() return } - guard sheets.activeSheetConfiguration == nil, - !sheets.isReplacingSheet, - app.contactPaymentContext == nil - else { return } + guard IncomingPaykitPaymentRequestPresentationDispatcher.canHandleSubscriptionNotification( + manager: paykitPaymentRequestManager, app: app, sheets: sheets + ) else { return } guard await refreshIncomingPaykitPaymentRequests(presentItems: false, mode: .stored), !Task.isCancelled, - PubkyPublicKeyFormat.matches(identity, pubkyProfile.publicKey), - PaykitSubscriptionNotificationTargetStore.load() == target + pubkyProfile.currentSession == session, + PaykitSubscriptionNotificationTargetStore.load() == target, + IncomingPaykitPaymentRequestPresentationDispatcher.canHandleSubscriptionNotification( + manager: paykitPaymentRequestManager, app: app, sheets: sheets + ) else { return } guard let request = paykitPaymentRequestManager.pendingRequests.first(where: target.matches) else { if paykitPaymentRequestManager.historyRequests.contains(where: target.matches) { @@ -1630,35 +1671,22 @@ struct AppScene: View { return } - if paykitPaymentRequestManager.requestedPresentationId != request.id { - guard paykitPaymentRequestManager.requestPresentation(request) else { return } - } + guard paykitPaymentRequestManager.requestPresentation(request) else { return } PaykitSubscriptionNotificationTargetStore.clear() await presentNextIncomingPaykitPaymentRequest() } private func presentNextIncomingPaykitItem() async { guard scenePhase == .active, - isPinVerified || !settings.pinEnabled, - sheets.activeSheetConfiguration == nil || canRetryIncomingPaymentRequestPreparation, - !sheets.isReplacingSheet + isPinVerified || !settings.pinEnabled else { return } - if canRetryIncomingPaymentRequestPreparation { - await presentNextIncomingPaykitPaymentRequest() - return - } - if PaykitSubscriptionNotificationTargetStore.load() != nil { - await handlePendingPaykitSubscriptionNotification() - guard PaykitSubscriptionNotificationTargetStore.load() == nil, - sheets.activeSheetConfiguration == nil, - !sheets.isReplacingSheet - else { return } - } - if let subscription = paykitPaymentRequestManager.subscriptionProposalForPresentation() { - sheets.showSheet(.subscription, data: SubscriptionSheetItem(route: .review(subscription))) - return - } - await presentNextIncomingPaykitPaymentRequest() + await IncomingPaykitPaymentRequestPresentationDispatcher.presentNextItem( + manager: paykitPaymentRequestManager, + sheets: sheets, + canRetryPreparation: canRetryIncomingPaymentRequestPreparation, + handleSubscriptionNotification: handlePendingPaykitSubscriptionNotification, + presentPaymentRequest: presentNextIncomingPaykitPaymentRequest + ) } private func retryPendingPaykitEndpointRemoval() async { diff --git a/BitkitTests/PaykitPaymentRequestServiceTests.swift b/BitkitTests/PaykitPaymentRequestServiceTests.swift index 61be3111a..d6620c4f8 100644 --- a/BitkitTests/PaykitPaymentRequestServiceTests.swift +++ b/BitkitTests/PaykitPaymentRequestServiceTests.swift @@ -4835,6 +4835,131 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { XCTAssertEqual(try store.load(identity: "pubky\(String(repeating: "z", count: 52))"), [request.id]) } + func testManualPaymentRetainsUnrelatedReminderThroughConfirmationAndRetry() async throws { + defer { PaykitSubscriptionNotificationTargetStore.clear() } + let now = try XCTUnwrap(ISO8601DateFormatter().date(from: "2027-01-15T08:00:00Z")) + let manualRecord = try paymentRequestRecord(id: "manual") + let reminderRecord = try paymentRequestRecord( + id: "reminder", counterparty: "pubky\(String(repeating: "y", count: 52))", state: .activeRecurring, + recurrence: PaymentRequestRecurrence(every: 1, unit: "month", startsAt: timestamp(now), anchor: timestamp(now), endsAt: nil) + ) + let target = try XCTUnwrap(PaykitSubscriptionNotificationTarget(userInfo: [ + "payer_identity": "pubky\(String(repeating: "z", count: 52))", + "payment_request_id": reminderRecord.paymentRequestId, + "counterparty": reminderRecord.counterparty, + "billing_period_starts_at": PaykitSubscriptionTimestamp.string(from: now), + ])) + let sdk = PaymentRequestSdkMock(records: [manualRecord]) + let manager = paymentRequestManager(sdk: sdk, clock: PaymentRequestTestClock(now)) + await manager.refresh(mode: .stored) + let request = try XCTUnwrap(manager.pendingRequests.first) + let sheets = SheetViewModel() + let app = AppViewModel() + PaykitSubscriptionNotificationTargetStore.save(target) + XCTAssertTrue(manager.requestPresentation(request)) + + await IncomingPaykitPaymentRequestPresentationDispatcher.presentNextItem( + manager: manager, sheets: sheets, canRetryPreparation: false, + handleSubscriptionNotification: { XCTFail("A reminder must not block explicit Pay") }, + presentPaymentRequest: { + XCTAssertEqual(manager.requestsForPresentation(), [request]) + sheets.showSheet(.send, data: SendConfig(view: .confirm)) + } + ) + XCTAssertEqual(sheets.activeSheetConfiguration?.id, .send) + XCTAssertEqual(PaykitSubscriptionNotificationTargetStore.load(), target) + + var retriedPreparation = false + await IncomingPaykitPaymentRequestPresentationDispatcher.presentNextItem( + manager: manager, sheets: sheets, canRetryPreparation: true, + handleSubscriptionNotification: { XCTFail("The preparing payment still owns the sheet") }, + presentPaymentRequest: { retriedPreparation = true } + ) + XCTAssertTrue(retriedPreparation) + XCTAssertTrue(app.claimContactPaymentContext(ContactPaymentContext(publicKey: request.counterparty, incomingPaymentRequest: request))) + XCTAssertTrue(manager.markPresentedIfPending(request)) + + await sdk.setRecords([manualRecord, reminderRecord]) + await manager.refresh(mode: .stored) + let reminder = try XCTUnwrap(manager.pendingRequests.first(where: target.matches)) + XCTAssertFalse(IncomingPaykitPaymentRequestPresentationDispatcher.canHandleSubscriptionNotification( + manager: manager, + app: app, + sheets: sheets + )) + await IncomingPaykitPaymentRequestPresentationDispatcher.presentNextItem( + manager: manager, sheets: sheets, canRetryPreparation: false, + handleSubscriptionNotification: { XCTFail("A ready reminder must not replace confirmation") }, + presentPaymentRequest: { XCTFail("Confirmation must retain its payment") } + ) + XCTAssertEqual(PaykitSubscriptionNotificationTargetStore.load(), target) + + app.resetSendState() + XCTAssertTrue(manager.requestPresentation(request)) + sheets.hideSheet(reason: "Retrying incoming payment request with fresh private payment details") + await IncomingPaykitPaymentRequestPresentationDispatcher.presentNextItem( + manager: manager, sheets: sheets, canRetryPreparation: false, + handleSubscriptionNotification: { XCTFail("Explicit retry must keep priority over the ready reminder") }, + presentPaymentRequest: { + XCTAssertEqual(manager.requestsForPresentation(), [request]) + sheets.showSheet(.send, data: SendConfig(view: .confirm, onDismiss: { manager.dismissPreparingRequest(request) })) + } + ) + XCTAssertEqual(sheets.activeSheetConfiguration?.id, .send) + XCTAssertEqual(PaykitSubscriptionNotificationTargetStore.load(), target) + + sheets.hideSheet() + XCTAssertTrue(IncomingPaykitPaymentRequestPresentationDispatcher.canHandleSubscriptionNotification( + manager: manager, + app: app, + sheets: sheets + )) + await IncomingPaykitPaymentRequestPresentationDispatcher.presentNextItem( + manager: manager, sheets: sheets, canRetryPreparation: false, + handleSubscriptionNotification: { + XCTAssertEqual(PaykitSubscriptionNotificationTargetStore.load(), target) + XCTAssertTrue(manager.requestPresentation(reminder)) + sheets.showSheet(.send, data: SendConfig(view: .confirm)) + }, + presentPaymentRequest: { XCTFail("The reminder handler owns its presentation") } + ) + XCTAssertEqual(manager.requestedPresentationId, reminder.id) + XCTAssertNil(PaykitSubscriptionNotificationTargetStore.load()) + } + + func testSubscriptionNotificationWaitsForExplicitSelectionAndActivePaymentOwnership() async throws { + let manager = try paymentRequestManager(sdk: PaymentRequestSdkMock(records: [paymentRequestRecord()])) + await manager.refresh(mode: .stored) + let request = try XCTUnwrap(manager.pendingRequests.first) + let sheets = SheetViewModel() + let app = AppViewModel() + func canHandleNotification() -> Bool { + IncomingPaykitPaymentRequestPresentationDispatcher.canHandleSubscriptionNotification(manager: manager, app: app, sheets: sheets) + } + XCTAssertTrue(canHandleNotification()) + XCTAssertTrue(manager.requestPresentation(request)) + XCTAssertFalse(canHandleNotification()) + manager.dismissPreparingRequest(request) + XCTAssertTrue(canHandleNotification()) + + XCTAssertTrue(app.claimContactPaymentContext(ContactPaymentContext(publicKey: request.counterparty, incomingPaymentRequest: request))) + XCTAssertFalse(canHandleNotification()) + app.resetSendState() + XCTAssertTrue(canHandleNotification()) + for sheet in [SheetID.send, .scanner] { + sheets.showSheet(sheet) + XCTAssertFalse(canHandleNotification()) + await IncomingPaykitPaymentRequestPresentationDispatcher.presentNextItem( + manager: manager, sheets: sheets, canRetryPreparation: false, + handleSubscriptionNotification: { XCTFail("The active sheet must retain ownership") }, + presentPaymentRequest: { XCTFail("The active sheet must retain ownership") } + ) + XCTAssertEqual(sheets.activeSheetConfiguration?.id, sheet) + sheets.hideSheet() + XCTAssertTrue(canHandleNotification()) + } + } + func testManualPresentationSupersedesInFlightAutomaticPresentation() async throws { let first = try paymentRequestRecord(id: "first") let second = try paymentRequestRecord(id: "second") diff --git a/journeys/contacts/README.md b/journeys/contacts/README.md index ad325f6e3..c509a7c5c 100644 --- a/journeys/contacts/README.md +++ b/journeys/contacts/README.md @@ -6,21 +6,23 @@ drawer and Contacts intro, pending lookup, and leaving while lookup waits requir or network fault injection, which the journey runner does not provide. Check those cases using the PR manual checks. -Import journeys require a disposable identity with a known following list. They save local Bitkit contacts; payment sharing remains a separate step. +Import journeys require a disposable identity with a known following list. They save Bitkit contacts; payment sharing remains a separate step. Continue waits for public payment setup, not private linking with every imported contact. Private preparation runs in the background. Repeat Import All with a large following list containing unavailable profiles, then delete a contact while preparation is running. It must not be republished after deletion. Unavailable private-link lookups are retried after five minutes rather than on each refresh. -Network and storage fault injection are outside journey-runner capabilities. Manually disable -connectivity after the preview has loaded: importing the prepared contacts must still finish. -Simulate a failed local save: stay on import, preserve successful saves, and retry only missing -contacts without claiming complete success. On Android, a failed Continue on the payment-sharing -screen should offer recovery guidance and leave saved contacts intact. +Network and storage fault injection are outside journey-runner capabilities. After the preview has +loaded, verify that importing its prepared contacts does not repeat profile lookups; saving shared +contact state still requires Pubky storage access. Simulate a failed contact batch: stay on import, +preserve previously saved contacts, and retry the entire unsaved selection without claiming partial +success. Duplicate selections and contacts already saved are skipped. Payment sharing remains a +separate step. On Android, a failed Continue on the payment-sharing screen should offer recovery +guidance and leave saved contacts intact. `ContactImportUITests.swift` checks pending imports using the DEBUG-only `-contact-import-ui-test` -fixture. It holds the first local save until the test taps Finish save, without network or SDK +fixture. It holds the contact batch until the test taps Finish save, without network or SDK storage. The tests drive the real overview and selection views, attempt repeated import taps, and verify that Select stays disabled, the preview stays intact, and completion saves the chosen contacts. This controlled fixture is separate from the identity-backed journeys above. diff --git a/journeys/paykit-clock-changes.md b/journeys/paykit-clock-changes.md index 1ae623f67..4f5096d5e 100644 --- a/journeys/paykit-clock-changes.md +++ b/journeys/paykit-clock-changes.md @@ -25,7 +25,7 @@ On iOS, Ring identities are selected from the shared identity list. There is no 4. Restore the correct clock and verify reminders still work. On Android, WorkManager delivery is best effort and may be delayed by retry backoff or OS scheduling; this check does not require exact delivery to the second. 5. While a payment request is temporarily unavailable and presentation is retrying, move the clock forward and backward. Retry intervals should remain short, while actual payment expiry and approval continue to use absolute timestamps. 6. Tap a due subscription reminder during background contact preparation, then repeat with Bitkit closed before the tap. After authentication and unlock, the app must resolve the due period from shared state without waiting for the whole contact-preparation round. Normal maintenance must still run afterward. Record tap-to-sheet timing separately from authentication and any SDK lock wait. -7. Repeat the cold-launch reminder while the startup request refresh fails before the stored request snapshot loads. Keep the reminder pending through the failure. Restore connectivity and allow a later refresh to open the exact due period without tapping the reminder again. Clear a stale reminder only after a successful snapshot confirms that period was handled or the subscription is inactive. +7. Repeat the cold-launch reminder while the startup request refresh fails before the stored request snapshot loads. Keep the reminder pending through the failure. If another request is available, open it manually: preparation and payment must remain usable, and a second Pay must not replace the active payment. Restore connectivity during confirmation and retry that manual payment; the reminder must wait until the manual flow ends. Dismiss it and allow a later refresh to open the reminder's exact due period without tapping the reminder again. Clear a stale reminder only after a successful snapshot confirms that period was handled or the subscription is inactive. Switching identities must discard the previous identity's pending reminder. 8. Open a due reminder whose payment cannot be prepared. The same Send sheet must remain open through resolution retries and close once on terminal feedback, without reopening from that reminder. Repeat and close the preparing sheet between retries; it must stay closed while the period remains pending and manually reopenable. An insufficient-balance or pending-private-link result must also end that reminder's presentation without an automatic reopen cycle. These steps describe the remaining manual verification. Unit tests cover injected restoration failures, state preservation, retry timing, UTC recurrence, and notification scheduling; they do not replace a live grant-session clock-change test. From f09f1c5b7597ec0a7f0e253ef2a9e9984b19972c Mon Sep 17 00:00:00 2001 From: benk10 Date: Tue, 6 Oct 2026 11:39:57 -0500 Subject: [PATCH 64/68] fix: coalesce failed pubky recovery attempts --- Bitkit/Managers/PubkyProfileManager.swift | 8 +++++ BitkitTests/PubkyProfileManagerTests.swift | 38 ++++++++++++++++++++++ 2 files changed, 46 insertions(+) diff --git a/Bitkit/Managers/PubkyProfileManager.swift b/Bitkit/Managers/PubkyProfileManager.swift index 15bfd4649..4fbf5d945 100644 --- a/Bitkit/Managers/PubkyProfileManager.swift +++ b/Bitkit/Managers/PubkyProfileManager.swift @@ -90,6 +90,7 @@ class PubkyProfileManager: ObservableObject { private var isSignupInFlight = false private var initializationTask: Task? + private var completedRecoveryVersion = 0 private var savedIdentityLookup: (revision: UUID, exists: Bool)? private static var isRingAdoptionInFlight = false private static var sessionRevision = UUID() @@ -183,8 +184,12 @@ class PubkyProfileManager: ObservableObject { } guard Self.sessionMutationCount == 0 else { return } isRestoringSession = true + let revision = Self.sessionRevision let task = Task { defer { + if case .automaticRecovery = mode, revision == Self.sessionRevision { + completedRecoveryVersion += 1 + } initializationTask = nil isRestoringSession = false } @@ -202,9 +207,12 @@ class PubkyProfileManager: ObservableObject { try await PubkyProfileManager.initializePersistedSession() } ) async { + let recoveryVersion = completedRecoveryVersion + let revision = Self.sessionRevision if let initializationTask { await initializationTask.value } + if completedRecoveryVersion != recoveryVersion, revision == Self.sessionRevision { return } guard !Task.isCancelled, publicKey == nil, authState == .idle, Self.sessionMutationCount == 0 else { return } do { guard try hasStoredIdentity() else { return } diff --git a/BitkitTests/PubkyProfileManagerTests.swift b/BitkitTests/PubkyProfileManagerTests.swift index 6441ffb21..efe83552d 100644 --- a/BitkitTests/PubkyProfileManagerTests.swift +++ b/BitkitTests/PubkyProfileManagerTests.swift @@ -650,6 +650,44 @@ final class PubkyProfileManagerTests: XCTestCase { XCTAssertNil(manager.initializationErrorMessage) } + @MainActor + func testOverlappingRecoverySharesFailureAndAllowsLaterRetry() async { + let manager = RecoveryProfileManager() + let started = expectation(description: "recovery started") + let (stream, continuation) = AsyncStream.makeStream() + let recovery = Task { + await manager.restoreSessionIfNeeded(hasStoredIdentity: { true }) { + started.fulfill() + for await _ in stream {} + throw PubkyServiceError.authFailed("offline") + } + } + await fulfillment(of: [started], timeout: 2) + let waiting = expectation(description: "recovery callers waiting") + waiting.expectedFulfillmentCount = 2 + let retries = (0 ..< 2).map { _ in + Task { + waiting.fulfill() + await manager.restoreSessionIfNeeded(hasStoredIdentity: { true }) { + XCTFail("Waiting callers must share the completed recovery attempt") + return .noSession + } + } + } + await fulfillment(of: [waiting], timeout: 2) + continuation.finish() + await recovery.value + for retry in retries { + await retry.value + } + XCTAssertNil(manager.publicKey) + XCTAssertFalse(manager.isRestoringSession) + + await manager.restoreSessionIfNeeded(hasStoredIdentity: { true }) { .restored(publicKey: "existing-identity") } + XCTAssertEqual(manager.publicKey, "existing-identity") + XCTAssertEqual(manager.authState, .authenticated) + } + @MainActor func testAutomaticRecoveryKeepsUsableStateWhileRetryFails() async { let manager = RecoveryProfileManager() From 32fcc1d5ecd9da40f7c2a96cadddf03636b66421 Mon Sep 17 00:00:00 2001 From: benk10 Date: Tue, 6 Oct 2026 13:00:24 -0500 Subject: [PATCH 65/68] fix: batch profile deletion and use paykit rc64 --- Bitkit.xcodeproj/project.pbxproj | 2 +- .../xcshareddata/swiftpm/Package.resolved | 4 +- Bitkit/Managers/ContactsManager.swift | 50 ++++++++----------- Bitkit/Managers/PubkyProfileManager.swift | 6 ++- ...PrivatePaykitAddressReservationStore.swift | 10 ++-- .../PrivatePaykitService+Contacts.swift | 32 ++++++++---- .../Services/PrivatePaykitService+State.swift | 29 +++++++---- Bitkit/Services/PrivatePaykitService.swift | 1 + Bitkit/Services/PubkyService.swift | 20 ++++++++ Bitkit/Views/Profile/EditProfileView.swift | 10 +++- BitkitTests/ContactsManagerTests.swift | 5 +- BitkitTests/PaykitContactLifecycleTests.swift | 14 ++++++ BitkitTests/PrivatePaykitServiceTests.swift | 17 +++++++ journeys/profile/delete-profile.xml | 2 + 14 files changed, 141 insertions(+), 61 deletions(-) diff --git a/Bitkit.xcodeproj/project.pbxproj b/Bitkit.xcodeproj/project.pbxproj index 02877fb46..031f21866 100644 --- a/Bitkit.xcodeproj/project.pbxproj +++ b/Bitkit.xcodeproj/project.pbxproj @@ -1178,7 +1178,7 @@ repositoryURL = "https://github.com/pubky/paykit-rs"; requirement = { kind = exactVersion; - version = "0.1.0-rc63"; + version = "0.1.0-rc64"; }; }; 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 1510cd2eb..9bc402e84 100644 --- a/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -33,8 +33,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pubky/paykit-rs", "state" : { - "revision" : "cec06146ceeefdb9341a00208388514d1aef47ac", - "version" : "0.1.0-rc63" + "revision" : "27fa6ed181d9fc428a6e2854f884c699a524dbc6", + "version" : "0.1.0-rc64" } }, { diff --git a/Bitkit/Managers/ContactsManager.swift b/Bitkit/Managers/ContactsManager.swift index 8a27b2c21..39e55d25f 100644 --- a/Bitkit/Managers/ContactsManager.swift +++ b/Bitkit/Managers/ContactsManager.swift @@ -148,6 +148,7 @@ class ContactsManager: ObservableObject { private let fetchRemoteProfile: @Sendable (_ publicKey: String, _ priority: PaykitPublicReadPriority) async throws -> PubkyProfile? private let saveContactLabel: @Sendable (_ publicKey: String, _ label: String, _ expectedIdentity: String) async throws -> Void private let removeContactRecord: @Sendable (_ publicKey: String) async throws -> Void + private let removeContactRecords: @Sendable (_ publicKeys: [String]) async throws -> [ContactRecord] private let forgetRemovedContacts: @Sendable (_ publicKeys: [String]) async -> Void private let currentDate: @Sendable () -> Date @@ -163,6 +164,9 @@ class ContactsManager: ObservableObject { removeContactRecord: @escaping @Sendable (_ publicKey: String) async throws -> Void = { _ = try await PubkyService.removeContact(publicKey: $0) }, + removeContactRecords: @escaping @Sendable (_ publicKeys: [String]) async throws -> [ContactRecord] = { + try await PubkyService.removeContacts(publicKeys: $0) + }, forgetRemovedContacts: @escaping @Sendable (_ publicKeys: [String]) async -> Void = { await PrivatePaykitService.shared.removeSavedContacts(publicKeys: $0) }, @@ -173,6 +177,7 @@ class ContactsManager: ObservableObject { self.fetchRemoteProfile = fetchRemoteProfile self.saveContactLabel = saveContactLabel self.removeContactRecord = removeContactRecord + self.removeContactRecords = removeContactRecords self.forgetRemovedContacts = forgetRemovedContacts self.currentDate = currentDate } @@ -840,7 +845,7 @@ class ContactsManager: ObservableObject { func deleteAllContacts() async throws { stopProfileRefresh() let contactRecords = contactRecords - let removeContactRecord = removeContactRecord + let removeContactRecords = removeContactRecords let records: [ContactRecord] do { records = try await Task.detached { @@ -858,35 +863,17 @@ class ContactsManager: ObservableObject { return } - var deletedKeys = Set() - var firstError: Error? - - for record in records { - guard let contactKey = PubkyPublicKeyFormat.normalized(record.publicKey) else { continue } - do { - try await Task.detached { - try await removeContactRecord(contactKey) - }.value - deletedKeys.insert(contactKey) - } catch { - firstError = firstError ?? error - Logger.warn("Failed to delete contact '\(PubkyPublicKeyFormat.redacted(contactKey))': \(error)", context: "ContactsManager") - } - } - - if let firstError { - if !deletedKeys.isEmpty { - await forgetRemovedContacts(Array(deletedKeys)) - for publicKey in deletedKeys { - Self.removeContactProfileOverride(publicKey: publicKey) - } - contacts.removeAll { deletedKeys.contains($0.publicKey) } - } - throw firstError + let keys = records.compactMap { PubkyPublicKeyFormat.normalized($0.publicKey) } + let removed = try await Task.detached { try await removeContactRecords(keys) }.value + let deletedKeys = Set(removed.compactMap { PubkyPublicKeyFormat.normalized($0.publicKey) }) + await forgetRemovedContacts(Array(deletedKeys)) + Self.removeContactProfileOverrides(publicKeys: deletedKeys) + contacts.removeAll { deletedKeys.contains($0.publicKey) } + guard deletedKeys.isSuperset(of: keys) else { + throw PrivatePaykitError.privateUnavailable } // All remote deletes succeeded, so clear any local-only contacts too. - await forgetRemovedContacts(Array(deletedKeys)) Self.clearContactProfileOverrides() await PrivatePaykitService.shared.pruneUnsavedContactState(savedPublicKeys: []) contacts.removeAll() @@ -1170,9 +1157,12 @@ class ContactsManager: ObservableObject { private nonisolated static func removeContactProfileOverride(publicKey: String) { guard let prefixedKey = PubkyPublicKeyFormat.normalized(publicKey) else { return } - var overrides = loadContactProfileOverrides() - overrides.removeValue(forKey: prefixedKey) - saveContactProfileOverrides(overrides) + removeContactProfileOverrides(publicKeys: [prefixedKey]) + } + + private nonisolated static func removeContactProfileOverrides(publicKeys: Set) { + guard !publicKeys.isEmpty else { return } + saveContactProfileOverrides(loadContactProfileOverrides().filter { !publicKeys.contains($0.key) }) } private nonisolated static func clearContactProfileOverrides() { diff --git a/Bitkit/Managers/PubkyProfileManager.swift b/Bitkit/Managers/PubkyProfileManager.swift index 4fbf5d945..3979ccd82 100644 --- a/Bitkit/Managers/PubkyProfileManager.swift +++ b/Bitkit/Managers/PubkyProfileManager.swift @@ -887,8 +887,10 @@ class PubkyProfileManager: ObservableObject { return true } - func deleteProfile() async throws { - await Self.removePrivatePaykitEndpointsBestEffort(context: "PubkyProfileManager.deleteProfile") + func deleteProfile(cleanPrivatePaykitEndpoints: Bool = true) async throws { + if cleanPrivatePaykitEndpoints { + await Self.removePrivatePaykitEndpointsBestEffort(context: "PubkyProfileManager.deleteProfile") + } do { try await Task.detached { try await PubkyService.deletePaykitProfile() diff --git a/Bitkit/Services/PrivatePaykitAddressReservationStore.swift b/Bitkit/Services/PrivatePaykitAddressReservationStore.swift index 351cfcb03..8248c10fc 100644 --- a/Bitkit/Services/PrivatePaykitAddressReservationStore.swift +++ b/Bitkit/Services/PrivatePaykitAddressReservationStore.swift @@ -295,12 +295,16 @@ actor PrivatePaykitAddressReservationStore { } func clearContactAssignment(publicKey: String) { - guard let normalizedKey = PubkyPublicKeyFormat.normalized(publicKey) else { return } + clearContactAssignments(publicKeys: [publicKey]) + } + + func clearContactAssignments(publicKeys: [String]) { + let normalizedKeys = Set(publicKeys.compactMap(PubkyPublicKeyFormat.normalized)) let previousCount = ledger.contactAssignments.count let previousHistoryCount = ledger.contactAssignmentHistory.count - ledger.contactAssignments.removeValue(forKey: normalizedKey) - ledger.contactAssignmentHistory.removeValue(forKey: normalizedKey) + ledger.contactAssignments = ledger.contactAssignments.filter { !normalizedKeys.contains($0.key) } + ledger.contactAssignmentHistory = ledger.contactAssignmentHistory.filter { !normalizedKeys.contains($0.key) } let removedCurrent = ledger.contactAssignments.count != previousCount let removedHistory = ledger.contactAssignmentHistory.count != previousHistoryCount guard removedCurrent || removedHistory else { return } diff --git a/Bitkit/Services/PrivatePaykitService+Contacts.swift b/Bitkit/Services/PrivatePaykitService+Contacts.swift index 334368501..475c9ae8a 100644 --- a/Bitkit/Services/PrivatePaykitService+Contacts.swift +++ b/Bitkit/Services/PrivatePaykitService+Contacts.swift @@ -161,6 +161,7 @@ extension PrivatePaykitService { requeueActive: Bool = false, operation: @escaping ([String], Bool) async -> Void ) { + guard !isDeletingProfile else { return } let keys = publicKeys.filter { requeueActive || forceRefreshLightning || !activePreparationKeys.contains($0) } guard !keys.isEmpty else { return } pendingPreparationKeys.formUnion(keys) @@ -198,6 +199,15 @@ extension PrivatePaykitService { pendingMessageDrainRetryGeneration += 1 } + func beginProfileDeletion() { + isDeletingProfile = true + invalidateContactPreparation() + } + + func endProfileDeletion() { + isDeletingProfile = false + } + @discardableResult func refreshSavedContactEndpointsReturningError( for publicKeys: [String], @@ -372,12 +382,14 @@ extension PrivatePaykitService { knownSavedContactKeys.remove(publicKey) unavailableLinkRetryAt[publicKey] = nil } + if isDeletingProfile { + await clearContactStates(publicKeys: normalizedKeys) + return + } Self.markDeletedContactCleanupPending(normalizedKeys) do { try await removePublishedEndpoints(for: normalizedKeys) - for publicKey in normalizedKeys { - await clearContactState(publicKey: publicKey) - } + await clearContactStates(publicKeys: normalizedKeys) } catch { Logger.warn("Failed to remove private Paykit endpoints for deleted contacts: \(error)", context: "PrivatePaykit") } @@ -393,17 +405,21 @@ extension PrivatePaykitService { let cleanupKeys = staleKeys.union(Self.pendingDeletedContactCleanupKeys().subtracting(savedKeys)) guard !cleanupKeys.isEmpty else { return } + if isDeletingProfile { + await clearContactStates(publicKeys: Array(staleKeys)) + return + } + do { try await removePublishedEndpoints(for: Array(cleanupKeys)) - for publicKey in staleKeys { - await clearContactState(publicKey: publicKey) - } + await clearContactStates(publicKeys: Array(staleKeys)) } catch { Logger.warn("Failed to prune private Paykit endpoints for unsaved contacts: \(error)", context: "PrivatePaykit") } } func retryPendingEndpointReconciliation(wallet: WalletViewModel, savedPublicKeys publicKeys: [String]) async { + guard !isDeletingProfile else { return } let savedKeys = Set(normalizedSavedContactKeys(publicKeys)) let isFullCleanupPending = UserDefaults.standard.bool(forKey: Self.cleanupPendingKey) if isFullCleanupPending, @@ -438,9 +454,7 @@ extension PrivatePaykitService { do { try await removePublishedEndpoints(for: isFullCleanupPending ? nil : Array(cleanupKeys)) - for publicKey in cleanupKeys where !savedKeys.contains(publicKey) { - await clearContactState(publicKey: publicKey) - } + await clearContactStates(publicKeys: Array(cleanupKeys.subtracting(savedKeys))) if isFullCleanupPending { Self.setContactSharingCleanupPending(false) } diff --git a/Bitkit/Services/PrivatePaykitService+State.swift b/Bitkit/Services/PrivatePaykitService+State.swift index 02ad26a12..1a2f32f90 100644 --- a/Bitkit/Services/PrivatePaykitService+State.swift +++ b/Bitkit/Services/PrivatePaykitService+State.swift @@ -20,18 +20,25 @@ extension PrivatePaykitService { } func clearContactState(publicKey: String) async { - guard let normalizedKey = PubkyPublicKeyFormat.normalized(publicKey) else { return } - unavailableLinkRetryAt[normalizedKey] = nil - privatePaymentListConsumptions = privatePaymentListConsumptions.filter { $0.key.publicKey != normalizedKey } - let consumedVersion = state.contacts[normalizedKey]?.consumedPrivatePaymentListVersion - if consumedVersion == nil { - state.contacts[normalizedKey] = nil - } else { - var contactState = ContactState() - contactState.consumedPrivatePaymentListVersion = consumedVersion - state.contacts[normalizedKey] = contactState + await clearContactStates(publicKeys: [publicKey]) + } + + func clearContactStates(publicKeys: [String]) async { + let keys = Set(publicKeys.compactMap(PubkyPublicKeyFormat.normalized)) + guard !keys.isEmpty else { return } + privatePaymentListConsumptions = privatePaymentListConsumptions.filter { !keys.contains($0.key.publicKey) } + for key in keys { + unavailableLinkRetryAt[key] = nil + let consumedVersion = state.contacts[key]?.consumedPrivatePaymentListVersion + if consumedVersion == nil { + state.contacts[key] = nil + } else { + var contactState = ContactState() + contactState.consumedPrivatePaymentListVersion = consumedVersion + state.contacts[key] = contactState + } } - await PrivatePaykitAddressReservationStore.shared.clearContactAssignment(publicKey: normalizedKey) + await PrivatePaykitAddressReservationStore.shared.clearContactAssignments(publicKeys: Array(keys)) persistState(markWalletBackup: true) } diff --git a/Bitkit/Services/PrivatePaykitService.swift b/Bitkit/Services/PrivatePaykitService.swift index 1f9962a84..e836ba37a 100644 --- a/Bitkit/Services/PrivatePaykitService.swift +++ b/Bitkit/Services/PrivatePaykitService.swift @@ -76,6 +76,7 @@ actor PrivatePaykitService { var preparationTask: Task? var pendingPreparationOperation: (([String], Bool) async -> Void)? var preparationGeneration = 0 + var isDeletingProfile = false var pendingForceRefreshLightning = false var unavailableLinkRetryAt: [String: Date] = [:] var pendingMessageDrainRetryTask: Task? diff --git a/Bitkit/Services/PubkyService.swift b/Bitkit/Services/PubkyService.swift index fb31c8c89..eecbe73ab 100644 --- a/Bitkit/Services/PubkyService.swift +++ b/Bitkit/Services/PubkyService.swift @@ -315,6 +315,10 @@ enum PubkyService { try await PaykitSdkService.shared.removeContact(publicKey: publicKey) } + static func removeContacts(publicKeys: [String]) async throws -> [Paykit.ContactRecord] { + try await PaykitSdkService.shared.removeContacts(publicKeys: publicKeys) + } + static func resolveContactProfile( publicKey: String, allowPubkyProfileFallback: Bool, @@ -830,6 +834,22 @@ actor PaykitSdkService { } } + func removeContacts(publicKeys: [String]) async throws -> [Paykit.ContactRecord] { + guard !publicKeys.isEmpty else { return [] } + return try await withStateRevisionTracking { sdk in + let now = Date() + let subscribedKeys = try await Set(sdk.paymentRequests().filter { + $0.state == .activeRecurring && + ($0.terms?.recurrence?.endsAt.flatMap(PaykitPaymentRequest.parseDate).map { $0 > now } ?? true) + }.compactMap { PubkyPublicKeyFormat.normalized($0.counterparty) }) + let removableKeys = publicKeys.filter { + PubkyPublicKeyFormat.normalized($0).map { !subscribedKeys.contains($0) } == true + } + guard !removableKeys.isEmpty else { return [] } + return try await sdk.removeContactsAndBlockPeers(publicKeys: removableKeys) + } + } + func resolveContactProfile( publicKey: String, allowPubkyProfileFallback: Bool, diff --git a/Bitkit/Views/Profile/EditProfileView.swift b/Bitkit/Views/Profile/EditProfileView.swift index b38021c5d..650c678a1 100644 --- a/Bitkit/Views/Profile/EditProfileView.swift +++ b/Bitkit/Views/Profile/EditProfileView.swift @@ -148,8 +148,13 @@ struct EditProfileView: View { guard !isDeleting else { return } isDeleting = true - defer { isDeleting = false } + await PrivatePaykitService.shared.beginProfileDeletion() + await deleteProfileWithSessionRetry() + await PrivatePaykitService.shared.endProfileDeletion() + isDeleting = false + } + private func deleteProfileWithSessionRetry() async { do { try await performDeleteProfile() } catch { @@ -169,8 +174,9 @@ struct EditProfileView: View { } private func performDeleteProfile() async throws { + await PubkyProfileManager.removePrivatePaykitEndpointsBestEffort(context: "EditProfileView.deleteProfile") await contactsManager.deleteAllContactsBestEffort() - try await pubkyProfile.deleteProfile() + try await pubkyProfile.deleteProfile(cleanPrivatePaykitEndpoints: false) navigation.path = [app.hasSeenProfileIntro ? .pubkyChoice : .profileIntro] } diff --git a/BitkitTests/ContactsManagerTests.swift b/BitkitTests/ContactsManagerTests.swift index 053d4e530..71d5e1962 100644 --- a/BitkitTests/ContactsManagerTests.swift +++ b/BitkitTests/ContactsManagerTests.swift @@ -1874,7 +1874,10 @@ final class ContactsManagerTests: XCTestCase { let blockingRead = try await holdTheOnlyReadSlot(slot) let lookups = HeldProfileLookups(profiles: [contactProfileKey: "Alice", unresolvedFollowKey: "Bob"]) let records = [unprofiledRecord(key: contactProfileKey, label: "First"), unprofiledRecord(key: unresolvedFollowKey, label: "Second")] - let manager = ContactsManager(contactRecords: { records }, removeContactRecord: { _ in }, forgetRemovedContacts: { _ in }) + let manager = ContactsManager(contactRecords: { records }, removeContactRecords: { keys in + XCTAssertEqual(Set(keys), Set(records.map(\.publicKey))) + return records + }, forgetRemovedContacts: { _ in }) try await manager.loadContacts( for: "owner", fetchContactRecords: { records }, diff --git a/BitkitTests/PaykitContactLifecycleTests.swift b/BitkitTests/PaykitContactLifecycleTests.swift index 98e273f40..1b116fb88 100644 --- a/BitkitTests/PaykitContactLifecycleTests.swift +++ b/BitkitTests/PaykitContactLifecycleTests.swift @@ -71,6 +71,8 @@ final class PaykitContactLifecycleTests: XCTestCase { } catch let PubkyServiceError.activeSubscription(endsAt) { XCTAssertEqual(endsAt, testCase.endsAt.flatMap(PaykitPaymentRequest.parseDate)) } + let skipped = try await service.removeContacts(publicKeys: [sdk.publicKey]) + XCTAssertTrue(skipped.isEmpty) XCTAssertNotNil(sdk.record) XCTAssertTrue(sdk.events.isEmpty) if testCase.endsAt != nil { @@ -80,6 +82,10 @@ final class PaykitContactLifecycleTests: XCTestCase { } _ = try await service.removeContact(publicKey: sdk.publicKey) XCTAssertNil(sdk.record) + _ = try await sdk.saveContact(update: ContactUpdate(publicKey: sdk.publicKey, label: "Contact")) + let removed = try await service.removeContacts(publicKeys: [sdk.publicKey]) + XCTAssertEqual(removed.map(\.publicKey), [sdk.publicKey]) + XCTAssertNil(sdk.record) } } @@ -419,6 +425,14 @@ private final class ContactLifecycleSdk: PaykitSdk, @unchecked Sendable { return record } + override func removeContactsAndBlockPeers(publicKeys: [String]) async throws -> [ContactRecord] { + guard publicKeys.contains(publicKey), let record else { return [] } + events.append("block-and-remove") + self.record = nil + peers[0].state = .blocked + return [record] + } + override func saveContact(update: ContactUpdate) async throws -> ContactRecord { if failSaveContact { throw PubkyServiceError.profileNotFound diff --git a/BitkitTests/PrivatePaykitServiceTests.swift b/BitkitTests/PrivatePaykitServiceTests.swift index aca8625f7..b8a99db04 100644 --- a/BitkitTests/PrivatePaykitServiceTests.swift +++ b/BitkitTests/PrivatePaykitServiceTests.swift @@ -1284,6 +1284,23 @@ final class PrivatePaykitServiceTests: XCTestCase { XCTAssertEqual(preparedKeys, [[publicKey]]) } + func testProfileDeletionDefersPreparationUntilDeletionEnds() async { + let service = PrivatePaykitService() + let publicKey = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" + _ = await service.rememberSavedContacts([publicKey], replacing: true) + await service.beginProfileDeletion() + await service.scheduleContactPreparation([publicKey]) { _, _ in + XCTFail("Profile deletion must not enqueue preparation") + } + let pausedTask = await service.preparationTask + XCTAssertNil(pausedTask) + + await service.endProfileDeletion() + let prepared = expectation(description: "Preparation resumes") + await service.scheduleContactPreparation([publicKey]) { _, _ in prepared.fulfill() } + await fulfillment(of: [prepared], timeout: 2) + } + func testResumingPublicationRetainsCleanupOnlyWhenRestorationFails() async { let service = PrivatePaykitService() for fails in [false, true] { diff --git a/journeys/profile/delete-profile.xml b/journeys/profile/delete-profile.xml index b5e948e6c..9244c247a 100644 --- a/journeys/profile/delete-profile.xml +++ b/journeys/profile/delete-profile.xml @@ -3,6 +3,8 @@ Precondition: an onboarded disposable test wallet with Paykit enabled and a profile created through Bitkit. The profile and its contacts will be deleted; do not use a personal identity. Record the sequence so progress can be checked even when deletion completes quickly. + Repeat with 62 imported contacts, once after preparation settles and once immediately after import. + Measure confirmation to onboarding and check logs for any uncertain-write safety wait. Open the existing profile from the Home header From f79cfcbce3d13c8e73a2b2746fefe53c7025500c Mon Sep 17 00:00:00 2001 From: benk10 Date: Tue, 6 Oct 2026 15:12:42 -0500 Subject: [PATCH 66/68] fix: restore contacts atomically with Paykit rc65 --- Bitkit.xcodeproj/project.pbxproj | 2 +- .../xcshareddata/swiftpm/Package.resolved | 4 +- Bitkit/Services/PubkyService.swift | 49 +----- BitkitTests/PaykitContactLifecycleTests.swift | 159 ++++++++++++++---- BitkitTests/PaykitSdkIdentityCheckTests.swift | 102 ++++++++--- changelog.d/next/856.changed.md | 2 +- journeys/contacts/README.md | 8 + journeys/contacts/import-all-contacts.xml | 3 + 8 files changed, 225 insertions(+), 104 deletions(-) diff --git a/Bitkit.xcodeproj/project.pbxproj b/Bitkit.xcodeproj/project.pbxproj index 031f21866..3f9ba01a0 100644 --- a/Bitkit.xcodeproj/project.pbxproj +++ b/Bitkit.xcodeproj/project.pbxproj @@ -1178,7 +1178,7 @@ repositoryURL = "https://github.com/pubky/paykit-rs"; requirement = { kind = exactVersion; - version = "0.1.0-rc64"; + version = "0.1.0-rc65"; }; }; 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 9bc402e84..8bbdf50bf 100644 --- a/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -33,8 +33,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pubky/paykit-rs", "state" : { - "revision" : "27fa6ed181d9fc428a6e2854f884c699a524dbc6", - "version" : "0.1.0-rc64" + "revision" : "7185ae7da9315028e5331442d71d739c27f1442c", + "version" : "0.1.0-rc65" } }, { diff --git a/Bitkit/Services/PubkyService.swift b/Bitkit/Services/PubkyService.swift index eecbe73ab..8f24dca47 100644 --- a/Bitkit/Services/PubkyService.swift +++ b/Bitkit/Services/PubkyService.swift @@ -741,31 +741,13 @@ actor PaykitSdkService { if let expectedIdentity { try await Self.requireSignedInIdentity(expectedIdentity, in: sdk) } - let existing = try await sdk.contactRecord(publicKey: publicKey) - guard restorePrivateConnection || existing != nil else { throw PubkyServiceError.profileNotFound } + let update = Paykit.ContactUpdate(publicKey: publicKey, label: label) if restorePrivateConnection { - let blockedPeers = try await sdk.linkedPeers().filter { - $0.state == .blocked && PubkyPublicKeyFormat.matches($0.counterparty, publicKey) - } - do { - for peer in blockedPeers { - _ = try await sdk.unblockPeer(counterparty: peer.counterparty) - } - return try await sdk.saveContact(update: Paykit.ContactUpdate(publicKey: publicKey, label: label)) - } catch { - let restorationError = error - for peer in blockedPeers { - do { - _ = try await sdk.blockPeer(counterparty: peer.counterparty) - } catch { - invalidatePaykitKeyIfNeeded(after: error) - Logger.error("Failed to restore peer block after contact save failed: \(error)", context: "PaykitSdkService") - } - } - throw restorationError - } + let saved = try await sdk.saveContactsAndUnblockPeers(updates: [update]) + return saved[0] } - return try await sdk.saveContact(update: Paykit.ContactUpdate(publicKey: publicKey, label: label)) + guard try await sdk.contactRecord(publicKey: publicKey) != nil else { throw PubkyServiceError.profileNotFound } + return try await sdk.saveContact(update: update) } } @@ -775,26 +757,7 @@ actor PaykitSdkService { if let expectedIdentity { try await Self.requireSignedInIdentity(expectedIdentity, in: sdk) } - let blockedPeers = try await sdk.linkedPeers().filter { peer in - peer.state == .blocked && updates.contains { PubkyPublicKeyFormat.matches($0.publicKey, peer.counterparty) } - } - do { - for peer in blockedPeers { - _ = try await sdk.unblockPeer(counterparty: peer.counterparty) - } - return try await sdk.saveContacts(updates: updates) - } catch { - let restorationError = error - for peer in blockedPeers { - do { - _ = try await sdk.blockPeer(counterparty: peer.counterparty) - } catch { - invalidatePaykitKeyIfNeeded(after: error) - Logger.error("Failed to restore peer block after contact save failed: \(error)", context: "PaykitSdkService") - } - } - throw restorationError - } + return try await sdk.saveContactsAndUnblockPeers(updates: updates) } } diff --git a/BitkitTests/PaykitContactLifecycleTests.swift b/BitkitTests/PaykitContactLifecycleTests.swift index 1b116fb88..8685017e4 100644 --- a/BitkitTests/PaykitContactLifecycleTests.swift +++ b/BitkitTests/PaykitContactLifecycleTests.swift @@ -172,23 +172,14 @@ final class PaykitContactLifecycleTests: XCTestCase { } func testFailedPrivateConnectionRestoreCanBeRetriedWithoutASavedContact() async throws { - let failures: [(peerLookup: Bool, unblock: Bool, saveContact: Bool, bulk: Bool)] = [ - (true, false, false, false), - (false, true, false, false), - (false, false, true, false), - (true, false, false, true), - (false, true, false, true), - (false, false, true, true), - ] - for failure in failures { + for bulk in [false, true] { let sdk = ContactLifecycleSdk(noPointer: .init()) let service = PaykitSdkService(sdkFactory: { sdk }) _ = try await service.removeContact(publicKey: sdk.publicKey) - sdk.failLinkedPeers = failure.peerLookup - sdk.failUnblock = failure.unblock - sdk.failSaveContact = failure.saveContact + sdk.restoreError = PubkyServiceError.profileNotFound + sdk.events.removeAll() let save = { - if failure.bulk { + if bulk { _ = try await service.saveContacts(updates: [ContactUpdate(publicKey: sdk.publicKey, label: "Contact")]) } else { _ = try await service.saveContact(publicKey: sdk.publicKey, label: "Contact", restorePrivateConnection: true) @@ -200,13 +191,102 @@ final class PaykitContactLifecycleTests: XCTestCase { } catch {} XCTAssertNil(sdk.record) XCTAssertTrue(sdk.peers.allSatisfy { $0.state == .blocked }) - sdk.failLinkedPeers = false - sdk.failUnblock = false - sdk.failSaveContact = false + XCTAssertEqual(sdk.events, ["save-and-unblock"]) + sdk.restoreError = nil try await save() XCTAssertNotNil(sdk.record) XCTAssertTrue(sdk.peers.allSatisfy { $0.state == .notLinked }) + XCTAssertEqual(sdk.events, ["save-and-unblock", "save-and-unblock"]) + } + } + + func testLabelUpdateDoesNotUnblockAnExistingContact() async throws { + let sdk = ContactLifecycleSdk(noPointer: .init()) + sdk.peers[0].state = .blocked + let service = PaykitSdkService(sdkFactory: { sdk }) + + let saved = try await service.saveContact(publicKey: sdk.publicKey, label: "Updated") + + XCTAssertEqual(saved.label, "Updated") + XCTAssertEqual(sdk.peers[0].state, .blocked) + XCTAssertEqual(sdk.events, ["save"]) + XCTAssertEqual(sdk.contactReads, 1) + XCTAssertEqual(sdk.peerReads, 0) + XCTAssertTrue(sdk.restoredBatches.isEmpty) + } + + func testExplicitReaddUsesAtomicSaveWithoutContactOrPeerPreflight() async throws { + let sdk = ContactLifecycleSdk(noPointer: .init()) + sdk.record = nil + sdk.peers[0].state = .blocked + let service = PaykitSdkService(sdkFactory: { sdk }) + + let saved = try await service.saveContact(publicKey: sdk.publicKey, label: "Readded", restorePrivateConnection: true) + + XCTAssertEqual(saved.publicKey, sdk.publicKey) + XCTAssertEqual(saved.label, "Readded") + XCTAssertEqual(sdk.peers[0].state, .notLinked) + XCTAssertEqual(sdk.events, ["save-and-unblock"]) + XCTAssertEqual(sdk.restoredBatches.map { $0.map(\.publicKey) }, [[sdk.publicKey]]) + XCTAssertEqual(sdk.contactReads, 0) + XCTAssertEqual(sdk.peerReads, 0) + } + + func testBulkReaddForwardsOneAtomicSaveWithoutPreflight() async throws { + let sdk = ContactLifecycleSdk(noPointer: .init()) + let updates = [ + ContactUpdate(publicKey: sdk.publicKey, label: "Alice"), + ContactUpdate(publicKey: "pubky5rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg", label: "Bob"), + ] + let service = PaykitSdkService(sdkFactory: { sdk }) + + let saved = try await service.saveContacts(updates: updates) + + XCTAssertEqual(saved.map(\.publicKey), updates.map(\.publicKey)) + XCTAssertEqual(saved.map(\.label), updates.map(\.label)) + XCTAssertEqual(sdk.restoredBatches.map { $0.map(\.publicKey) }, [updates.map(\.publicKey)]) + XCTAssertEqual(sdk.events, ["save-and-unblock"]) + XCTAssertEqual(sdk.contactReads, 0) + XCTAssertEqual(sdk.peerReads, 0) + } + + func testEmptyImportDoesNotAccessSdk() async throws { + let service = PaykitSdkService(sdkFactory: { + XCTFail("An empty import must not access the SDK") + return ContactLifecycleSdk(noPointer: .init()) + }) + + let saved = try await service.saveContacts(updates: []) + + XCTAssertTrue(saved.isEmpty) + } + + func testImportCancelledAfterAtomicCommitDoesNotPublishOrReblockContacts() async throws { + let sdk = ContactLifecycleSdk(noPointer: .init()) + sdk.record = nil + sdk.peers[0].state = .blocked + sdk.cancelAfterRestore = true + let service = PaykitSdkService(sdkFactory: { sdk }) + let manager = ContactsManager() + let contact = Bitkit.PubkyContact( + publicKey: sdk.publicKey, + profile: Bitkit.PubkyProfile(publicKey: sdk.publicKey, name: "Readded", bio: "", imageUrl: nil, links: [], status: nil) + ) + + let importTask = Task { + try await manager.importContacts(contacts: [contact]) { updates, identity in + _ = try await service.saveContacts(updates: updates, expectedIdentity: identity) + } } + do { + try await importTask.value + XCTFail("Expected cancellation to discard the import result") + } catch is CancellationError {} + + XCTAssertTrue(manager.contacts.isEmpty) + XCTAssertEqual(sdk.record?.label, "Readded") + XCTAssertEqual(sdk.peers[0].state, .notLinked) + XCTAssertEqual(sdk.events, ["save-and-unblock"]) } func testBlockedPeerCleanupDoesNotAttemptNetworkDelivery() async throws { @@ -299,10 +379,11 @@ private final class ContactLifecycleSdk: PaykitSdk, @unchecked Sendable { var requests: [PaymentRequestRecord] = [] var failWithdrawal = false var failBlock = false - var failLinkedPeers = false - var failUnblock = false - var failSaveContact = false var failRecovery = false + var restoreError: Error? + var cancelAfterRestore = false + var restoredBatches: [[ContactUpdate]] = [] + var contactReads = 0 var peerReads = 0 var identityReads = 0 var registryReads = 0 @@ -359,12 +440,12 @@ private final class ContactLifecycleSdk: PaykitSdk, @unchecked Sendable { } override func contactRecord(publicKey: String) async throws -> ContactRecord? { - record + contactReads += 1 + return record } override func linkedPeers() async throws -> [LinkedPeerRecord] { peerReads += 1 - if failLinkedPeers { throw PubkyServiceError.profileNotFound } return peers } @@ -413,7 +494,7 @@ private final class ContactLifecycleSdk: PaykitSdk, @unchecked Sendable { } override func unblockPeer(counterparty: String) async throws -> LinkedPeerRecord { - if failUnblock { throw PubkyServiceError.profileNotFound } + XCTFail("Explicit re-add must save and unblock atomically") let index = try XCTUnwrap(peers.firstIndex { PubkyPublicKeyFormat.matches($0.counterparty, counterparty) }) peers[index].state = .notLinked return peers[index] @@ -434,24 +515,38 @@ private final class ContactLifecycleSdk: PaykitSdk, @unchecked Sendable { } override func saveContact(update: ContactUpdate) async throws -> ContactRecord { - if failSaveContact { - throw PubkyServiceError.profileNotFound + events.append("save") + let saved = Self.contactRecord(update: update) + record = saved + return saved + } + + override func saveContactsAndUnblockPeers(updates: [ContactUpdate]) async throws -> [ContactRecord] { + events.append("save-and-unblock") + restoredBatches.append(updates) + if let restoreError { throw restoreError } + let saved = updates.map(Self.contactRecord) + for index in peers.indices where peers[index].state == .blocked { + if updates.contains(where: { PubkyPublicKeyFormat.matches($0.publicKey, peers[index].counterparty) }) { + peers[index].state = .notLinked + } } - let saved = ContactRecord( + record = saved.last ?? record + if cancelAfterRestore { withUnsafeCurrentTask { $0?.cancel() } } + return saved + } + + private static func contactRecord(update: ContactUpdate) -> ContactRecord { + ContactRecord( publicKey: update.publicKey, label: update.label, profile: nil, profileFetchedAt: nil, createdAt: "2026-09-29T00:00:00Z", updatedAt: "2026-09-29T00:00:00Z", publicContactMarkerStatus: .notPublished, publicContactPublishedAt: nil, publicContactRemovedAt: nil, publicContactLastError: nil ) - record = saved - return saved } override func saveContacts(updates: [ContactUpdate]) async throws -> [ContactRecord] { - var saved: [ContactRecord] = [] - for update in updates { - try await saved.append(saveContact(update: update)) - } - return saved + XCTFail("Explicit re-add must save and unblock atomically") + return updates.map(Self.contactRecord) } } diff --git a/BitkitTests/PaykitSdkIdentityCheckTests.swift b/BitkitTests/PaykitSdkIdentityCheckTests.swift index 715e547dd..d189ad238 100644 --- a/BitkitTests/PaykitSdkIdentityCheckTests.swift +++ b/BitkitTests/PaykitSdkIdentityCheckTests.swift @@ -42,10 +42,15 @@ final class PaykitSdkIdentityCheckTests: XCTestCase { updates: [ContactUpdate(publicKey: contactKey, label: "Imported")], expectedIdentity: bareIdentityA ) + let readded = try await service.saveContact( + publicKey: contactKey, label: "Readded", restorePrivateConnection: true, expectedIdentity: bareIdentityA + ) XCTAssertEqual(saved.label, "Alice", "The identity matches in either key form") XCTAssertEqual(imported.first?.label, "Imported") - XCTAssertEqual(sdk.writes, ["save:Alice", "save:Unbound", "batch:1"], "A save for no identity saves for whichever one is signed in") + XCTAssertEqual(readded.label, "Readded") + XCTAssertEqual(sdk.writes, ["save:Alice", "save:Unbound", "restore:1", "restore:1"]) + XCTAssertEqual(sdk.contactReads, 2, "Only ordinary updates look up the existing contact") } func testProfilePublicationForAnIdentityThatIsNoLongerSignedInWritesNothing() async throws { @@ -143,6 +148,14 @@ final class PaykitSdkIdentityCheckTests: XCTestCase { if case .identityChanged? = error as? PubkyServiceError { return true } return false }), + ("explicit contact re-add", { service in + _ = try await service.saveContact( + publicKey: contactKey, label: "Alice", restorePrivateConnection: true, expectedIdentity: identityA + ) + }, { error in + if case .identityChanged? = error as? PubkyServiceError { return true } + return false + }), ("profile avatar upload", { service in _ = try await service.uploadAvatar(bytes: Data([1]), contentType: "image/jpeg", expectedIdentity: identityA) }, { error in @@ -186,27 +199,66 @@ final class PaykitSdkIdentityCheckTests: XCTestCase { } } - func testContactBatchCancelledWhileWaitingForTheSdkLockWritesNothing() async throws { - let sdk = IdentitySwitchingSdk(noPointer: .init()) - sdk.identity = identityA - let service = PaykitSdkService(sdkFactory: { sdk }) - await sdk.contactRecordsGate.close() - let lockHolder = Task { try await service.contactRecords() } - try await sdk.contactRecordsStarted.waitForEntries(count: 1) + func testContactRestoreCancelledWhileWaitingForTheSdkLockWritesNothing() async throws { + for bulk in [false, true] { + let sdk = IdentitySwitchingSdk(noPointer: .init()) + sdk.identity = identityA + let service = PaykitSdkService(sdkFactory: { sdk }) + await sdk.contactRecordsGate.close() + let lockHolder = Task { try await service.contactRecords() } + try await sdk.contactRecordsStarted.waitForEntries(count: 1) - let save = Task { - try await service.saveContacts(updates: [ContactUpdate(publicKey: contactKey, label: "Alice")], expectedIdentity: identityA) + let save = Task { + if bulk { + _ = try await service.saveContacts(updates: [ContactUpdate(publicKey: contactKey, label: "Alice")], expectedIdentity: identityA) + } else { + _ = try await service.saveContact( + publicKey: contactKey, label: "Alice", restorePrivateConnection: true, expectedIdentity: identityA + ) + } + } + try await Task.sleep(for: .milliseconds(50)) + save.cancel() + await sdk.contactRecordsGate.open() + _ = try await lockHolder.value + + do { + try await save.value + XCTFail("Expected queued contact restore to be cancelled") + } catch is CancellationError {} + XCTAssertTrue(sdk.writes.isEmpty) } - try await Task.sleep(for: .milliseconds(50)) - save.cancel() - await sdk.contactRecordsGate.open() - _ = try await lockHolder.value + } - do { - _ = try await save.value - XCTFail("Expected queued contact import to be cancelled") - } catch is CancellationError {} - XCTAssertTrue(sdk.writes.isEmpty) + func testSignOutWaitsForAtomicContactRestoration() async throws { + for bulk in [false, true] { + let sdk = IdentitySwitchingSdk(noPointer: .init()) + sdk.identity = identityA + let service = PaykitSdkService(sdkFactory: { sdk }) + await sdk.restoreGate.close() + let restore = Task { + if bulk { + _ = try await service.saveContacts(updates: [ContactUpdate(publicKey: contactKey, label: "Alice")], expectedIdentity: identityA) + } else { + _ = try await service.saveContact( + publicKey: contactKey, label: "Alice", restorePrivateConnection: true, expectedIdentity: identityA + ) + } + } + try await sdk.restoreStarted.waitForEntries(count: 1) + + let signOut = Task { try await service.signOut() } + try await Task.sleep(for: .milliseconds(50)) + XCTAssertEqual(sdk.identity, identityA) + XCTAssertTrue(sdk.writes.isEmpty) + await sdk.restoreGate.open() + try await restore.value + try await signOut.value + + XCTAssertNil(sdk.identity) + XCTAssertEqual(sdk.writes, ["restore:1"]) + XCTAssertEqual(sdk.identitiesAtWrites, [identityA]) + } } /// A sign-out queues for the SDK lock right behind a contact save. The lock is handed over in order, so the save runs @@ -303,6 +355,8 @@ private final class IdentitySwitchingSdk: PaykitSdk, @unchecked Sendable { private var recordedContactReads = 0 let contactRecordsGate = IdentityCheckGate() let contactRecordsStarted = IdentityCheckCallLog() + let restoreGate = IdentityCheckGate() + let restoreStarted = IdentityCheckCallLog() var identity: String? { get { lock.withLock { signedInIdentity } } @@ -366,17 +420,15 @@ private final class IdentitySwitchingSdk: PaykitSdk, @unchecked Sendable { return Self.record(publicKey: publicKey, label: "Contact") } - override func linkedPeers() async throws -> [LinkedPeerRecord] { - [] - } - override func saveContact(update: ContactUpdate) async throws -> ContactRecord { recordWrite("save:\(update.label ?? "")") return Self.record(publicKey: update.publicKey, label: update.label) } - override func saveContacts(updates: [ContactUpdate]) async throws -> [ContactRecord] { - recordWrite("batch:\(updates.count)") + override func saveContactsAndUnblockPeers(updates: [ContactUpdate]) async throws -> [ContactRecord] { + await restoreStarted.record() + await restoreGate.wait() + recordWrite("restore:\(updates.count)") return updates.map { Self.record(publicKey: $0.publicKey, label: $0.label) } } diff --git a/changelog.d/next/856.changed.md b/changelog.d/next/856.changed.md index a8611abdb..5bec108fc 100644 --- a/changelog.d/next/856.changed.md +++ b/changelog.d/next/856.changed.md @@ -1 +1 @@ -Share Paykit contacts and payment state with authorized apps while keeping wallet keys private, prepare incoming payments in the payment sheet, preserve subscription reminders through retries, and label received payments with their Paykit contact. +Share Paykit contacts and payment state with authorized apps while keeping wallet keys private, restore contacts when re-importing an identity, prepare incoming payments in the payment sheet, preserve subscription reminders through retries, and label received payments with their Paykit contact. diff --git a/journeys/contacts/README.md b/journeys/contacts/README.md index c509a7c5c..c9d1b0f2f 100644 --- a/journeys/contacts/README.md +++ b/journeys/contacts/README.md @@ -21,6 +21,14 @@ success. Duplicate selections and contacts already saved are skipped. Payment sh separate step. On Android, a failed Continue on the payment-sharing screen should offer recovery guidance and leave saved contacts intact. +Repeat both import journeys after deleting the disposable profile and adopting the same identity +again, including the 62-contact case. Record Import All to the payment-sharing screen separately +from Continue; there is no guaranteed network completion deadline. Explicit re-import saves the +selected contacts and removes their blocks atomically, without restoring old private links. +Deselected contacts remain blocked. Ordinary label updates must not remove a block. SDK failure +and cancellation coverage verifies that the app does not issue compensating block writes after +an atomic save, including when cancellation hides a completed commit. + `ContactImportUITests.swift` checks pending imports using the DEBUG-only `-contact-import-ui-test` fixture. It holds the contact batch until the test taps Finish save, without network or SDK storage. The tests drive the real overview and selection views, attempt repeated import taps, and diff --git a/journeys/contacts/import-all-contacts.xml b/journeys/contacts/import-all-contacts.xml index 4d64a30bf..e30c6f9c1 100644 --- a/journeys/contacts/import-all-contacts.xml +++ b/journeys/contacts/import-all-contacts.xml @@ -3,6 +3,9 @@ Precondition: the import preview is open for a disposable Ring identity with known follows that are not yet saved in this Bitkit wallet. Include Pubky-only profiles without Paykit receivers. Repeat with a large list (for example 62 contacts). + Repeat after deleting this profile and selecting the same Ring identity again. + Measure Import All to Let Your Contacts Pay You and check logs for a terminal error + or an uncertain-write safety wait. Also repeat with an identity that follows itself. The preview count must exclude your own profile, and your own profile must be absent from the saved contacts. From f553d469975e59e031a485881f03c20709115c90 Mon Sep 17 00:00:00 2001 From: benk10 Date: Tue, 6 Oct 2026 17:16:31 -0500 Subject: [PATCH 67/68] Correct recovery accounting and cover partial contact removal --- Bitkit/Managers/PubkyProfileManager.swift | 6 ++-- BitkitTests/ContactsManagerTests.swift | 36 +++++++++++++++++++++ BitkitTests/PrivatePaykitServiceTests.swift | 10 +++--- 3 files changed, 45 insertions(+), 7 deletions(-) diff --git a/Bitkit/Managers/PubkyProfileManager.swift b/Bitkit/Managers/PubkyProfileManager.swift index 3979ccd82..7458fed92 100644 --- a/Bitkit/Managers/PubkyProfileManager.swift +++ b/Bitkit/Managers/PubkyProfileManager.swift @@ -187,14 +187,14 @@ class PubkyProfileManager: ObservableObject { let revision = Self.sessionRevision let task = Task { defer { - if case .automaticRecovery = mode, revision == Self.sessionRevision { - completedRecoveryVersion += 1 - } initializationTask = nil isRestoringSession = false } guard Self.sessionMutationCount == 0 else { return } await initializeSessionState(mode: mode, initializeSession: initializeSession) + if case .automaticRecovery = mode, revision == Self.sessionRevision { + completedRecoveryVersion += 1 + } } initializationTask = task await task.value diff --git a/BitkitTests/ContactsManagerTests.swift b/BitkitTests/ContactsManagerTests.swift index 71d5e1962..b2742d8a9 100644 --- a/BitkitTests/ContactsManagerTests.swift +++ b/BitkitTests/ContactsManagerTests.swift @@ -1897,6 +1897,42 @@ final class ContactsManagerTests: XCTestCase { XCTAssertTrue(manager.contacts.isEmpty) } + func testDeleteAllContactsPreservesContactsNotRemovedByTheBulkOperation() async throws { + snapshotAppDefaults("pubkyContactProfileOverrides") + let removed = contactRecord(key: contactProfileKey, name: "Removed") + let retained = ["Subscribed", "Busy", "Public marker"].enumerated().map { index, name in + contactRecord(key: "pubky" + String(repeating: ["y", "z", "r"][index], count: 52), name: name) + } + let records = [removed] + retained + let contacts = records.map { makeContact(publicKey: $0.publicKey) } + ContactsManager.restoreContactProfileOverrides(Dictionary(uniqueKeysWithValues: contacts.map { + ($0.publicKey, PubkyProfileData.from(profile: $0.profile)) + })) + let forgotten = expectation(description: "Only removed contacts are forgotten") + forgotten.assertForOverFulfill = true + let manager = ContactsManager(contactRecords: { records }, removeContactRecords: { keys in + XCTAssertEqual(Set(keys), Set(records.map(\.publicKey))) + return [removed] + }, forgetRemovedContacts: { keys in + XCTAssertEqual(keys, [removed.publicKey]) + forgotten.fulfill() + }) + manager.contacts = contacts + + do { + try await manager.deleteAllContacts() + XCTFail("Partial removal must report that contacts remain") + } catch PrivatePaykitError.privateUnavailable { + } catch { + XCTFail("Unexpected removal error: \(error)") + } + + await fulfillment(of: [forgotten], timeout: 2) + XCTAssertEqual(Set(manager.contacts.map(\.publicKey)), Set(retained.map(\.publicKey))) + let overrides = try XCTUnwrap(ContactsManager.backupContactProfileOverrides()) + XCTAssertEqual(Set(overrides.keys), Set(retained.map(\.publicKey))) + } + /// Takes `slot`'s only read slot with another read and holds it until the returned lookups are released. private func holdTheOnlyReadSlot(_ slot: PaykitSdkReadLimiter) async throws -> HeldProfileLookups { let otherRead = HeldProfileLookups(profiles: [:]) diff --git a/BitkitTests/PrivatePaykitServiceTests.swift b/BitkitTests/PrivatePaykitServiceTests.swift index b8a99db04..c24cdd026 100644 --- a/BitkitTests/PrivatePaykitServiceTests.swift +++ b/BitkitTests/PrivatePaykitServiceTests.swift @@ -1284,7 +1284,7 @@ final class PrivatePaykitServiceTests: XCTestCase { XCTAssertEqual(preparedKeys, [[publicKey]]) } - func testProfileDeletionDefersPreparationUntilDeletionEnds() async { + func testProfileDeletionDropsPreparationAndAllowsNewWorkAfterDeletionEnds() async { let service = PrivatePaykitService() let publicKey = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" _ = await service.rememberSavedContacts([publicKey], replacing: true) @@ -1292,11 +1292,13 @@ final class PrivatePaykitServiceTests: XCTestCase { await service.scheduleContactPreparation([publicKey]) { _, _ in XCTFail("Profile deletion must not enqueue preparation") } - let pausedTask = await service.preparationTask - XCTAssertNil(pausedTask) + let deletionTask = await service.preparationTask + XCTAssertNil(deletionTask) await service.endProfileDeletion() - let prepared = expectation(description: "Preparation resumes") + let resumedTask = await service.preparationTask + XCTAssertNil(resumedTask) + let prepared = expectation(description: "New preparation starts") await service.scheduleContactPreparation([publicKey]) { _, _ in prepared.fulfill() } await fulfillment(of: [prepared], timeout: 2) } From a223fad7c2748de229375e308bf913ff050835b3 Mon Sep 17 00:00:00 2001 From: benk10 Date: Tue, 6 Oct 2026 17:16:38 -0500 Subject: [PATCH 68/68] Enforce absolute Paykit deadlines before payment submission --- Bitkit/Managers/HwWalletManager.swift | 5 +- Bitkit/Services/LightningService.swift | 25 ++- Bitkit/Services/OnChainHwService.swift | 5 +- .../Services/PaykitPaymentProofService.swift | 5 +- .../PaykitPaymentRequestService.swift | 56 ++++- Bitkit/ViewModels/HwFundingSigner.swift | 26 ++- Bitkit/ViewModels/TransferViewModel.swift | 2 +- Bitkit/ViewModels/WalletViewModel.swift | 10 +- .../Views/Wallets/Send/HwSendSignView.swift | 1 + .../Views/Wallets/Send/LnurlPayConfirm.swift | 5 +- .../Wallets/Send/SendConfirmationView.swift | 6 +- BitkitTests/HwFundingSignerTests.swift | 50 +++++ BitkitTests/HwTransferMocks.swift | 4 +- BitkitTests/PaykitIssuerInteropTests.swift | 6 +- .../PaykitPaymentProofServiceTests.swift | 11 +- .../PaykitPaymentRequestServiceTests.swift | 211 +++++++++++++++++- changelog.d/next/856.changed.md | 2 +- journeys/payment-requests/README.md | 15 +- .../absolute-payment-deadline.xml | 26 +++ .../payment-deadline-history.xml | 8 +- 20 files changed, 432 insertions(+), 47 deletions(-) create mode 100644 journeys/payment-requests/absolute-payment-deadline.xml diff --git a/Bitkit/Managers/HwWalletManager.swift b/Bitkit/Managers/HwWalletManager.swift index 7d09b65f7..0066d4a7e 100644 --- a/Bitkit/Managers/HwWalletManager.swift +++ b/Bitkit/Managers/HwWalletManager.swift @@ -1541,10 +1541,11 @@ final class HwWalletManager { } /// Broadcast a signed funding transaction and return its txid. Does not require a connected device. - func broadcastFunding(serializedTx: String) async throws -> String { + func broadcastFunding(serializedTx: String, paymentDeadline: PaykitPreciseInstant? = nil) async throws -> String { try await OnChainHwService.shared.broadcastRawTx( serializedTx: serializedTx, - electrumUrl: electrumUrlProvider() + electrumUrl: electrumUrlProvider(), + paymentDeadline: paymentDeadline ) } diff --git a/Bitkit/Services/LightningService.swift b/Bitkit/Services/LightningService.swift index 2da2e870d..2e29ffefa 100644 --- a/Bitkit/Services/LightningService.swift +++ b/Bitkit/Services/LightningService.swift @@ -788,7 +788,8 @@ class LightningService { sats: UInt64, satsPerVbyte: UInt32, utxosToSpend: [SpendableUtxo]? = nil, - isMaxAmount: Bool = false + isMaxAmount: Bool = false, + beforeSubmission: @escaping () throws -> Void = {} ) async throws -> Txid { guard let node else { throw AppError(serviceError: .nodeNotSetup) @@ -797,7 +798,7 @@ class LightningService { Logger.info("Sending \(sats) sats to \(address) with fee rate \(satsPerVbyte) sats/vbyte (isMaxAmount: \(isMaxAmount))") do { - return try await ServiceQueue.background(.ldk) { + return try await Self.submitPayment(beforeSubmission: beforeSubmission) { if isMaxAmount { // For max amount sends, use sendAllToAddress to send all available funds try node.onchainPayment().sendAllToAddress( @@ -821,7 +822,12 @@ class LightningService { } } - func send(bolt11: String, sats: UInt64? = nil, params: RouteParametersConfig? = nil) async throws -> PaymentHash { + func send( + bolt11: String, + sats: UInt64? = nil, + params: RouteParametersConfig? = nil, + beforeSubmission: @escaping () throws -> Void = {} + ) async throws -> PaymentHash { guard let node else { throw AppError(serviceError: .nodeNotSetup) } @@ -829,7 +835,7 @@ class LightningService { Logger.info("Paying bolt11: \(bolt11)") do { - return try await ServiceQueue.background(.ldk) { + return try await Self.submitPayment(beforeSubmission: beforeSubmission) { if let sats { try node.bolt11Payment().sendUsingAmount( invoice: .fromStr(invoiceStr: bolt11), amountMsat: sats * 1000, routeParameters: params @@ -845,6 +851,17 @@ class LightningService { } } + static func submitPayment( + beforeSubmission: @escaping () throws -> Void, + send: @escaping () throws -> Result + ) async throws -> Result { + try await ServiceQueue.background(.ldk) { + // Authorization can precede a queue wait; only the actual submission time is relevant here. + try beforeSubmission() + return try send() + } + } + func closeChannel(userChannelId: ChannelId, counterpartyNodeId: PublicKey, force: Bool = false, forceCloseReason: String? = nil) async throws { guard let node else { throw AppError(serviceError: .nodeNotStarted) diff --git a/Bitkit/Services/OnChainHwService.swift b/Bitkit/Services/OnChainHwService.swift index 2cabcf081..52c1f6546 100644 --- a/Bitkit/Services/OnChainHwService.swift +++ b/Bitkit/Services/OnChainHwService.swift @@ -117,9 +117,10 @@ class OnChainHwService { /// Broadcast a signed raw transaction via Electrum. /// - Returns: The transaction ID (txid) - func broadcastRawTx(serializedTx: String, electrumUrl: String) async throws -> String { + func broadcastRawTx(serializedTx: String, electrumUrl: String, paymentDeadline: PaykitPreciseInstant? = nil) async throws -> String { try await ServiceQueue.background(.core) { - try await onchainBroadcastRawTx(serializedTx: serializedTx, electrumUrl: electrumUrl) + try PaykitPaymentRequest.checkPaymentDeadline(paymentDeadline) + return try await onchainBroadcastRawTx(serializedTx: serializedTx, electrumUrl: electrumUrl) } } diff --git a/Bitkit/Services/PaykitPaymentProofService.swift b/Bitkit/Services/PaykitPaymentProofService.swift index 7ec70dec6..63a5236fc 100644 --- a/Bitkit/Services/PaykitPaymentProofService.swift +++ b/Bitkit/Services/PaykitPaymentProofService.swift @@ -463,7 +463,9 @@ actor PaykitPaymentProofService { func failLightningPayment(paymentHash: String, submissionError: Error) async -> Bool { let underlyingError = (submissionError as? AppError)?.underlyingError ?? submissionError - if let serviceError = underlyingError as? CustomServiceError { + if underlyingError as? PaykitPaymentRequestError == .requestExpired { + // The local deadline gate runs before submitting to the node. + } else if let serviceError = underlyingError as? CustomServiceError { guard serviceError == .nodeNotSetup || serviceError == .nodeNotStarted else { return false } } else if let nodeError = underlyingError as? NodeError { switch nodeError { @@ -822,6 +824,7 @@ actor PaykitPaymentProofService { static func isDefiniteOnchainPreBroadcastFailure(_ error: Error) -> Bool { let underlyingError = (error as? AppError)?.underlyingError ?? error + if underlyingError as? PaykitPaymentRequestError == .requestExpired { return true } if let serviceError = underlyingError as? CustomServiceError { switch serviceError { case .nodeNotSetup, .nodeNotStarted: diff --git a/Bitkit/Services/PaykitPaymentRequestService.swift b/Bitkit/Services/PaykitPaymentRequestService.swift index c7ba58f70..b5015a122 100644 --- a/Bitkit/Services/PaykitPaymentRequestService.swift +++ b/Bitkit/Services/PaykitPaymentRequestService.swift @@ -15,6 +15,7 @@ struct PaykitPaymentRequest: Identifiable, Hashable { case recurringRequest = "recurring_request" case unsupportedAsset = "unsupported_asset" case unsupportedPaymentDeadline = "unsupported_payment_deadline" + case invalidPaymentDeadline = "invalid_payment_deadline" case invalidAmount = "invalid_amount" case amountOutOfRange = "amount_out_of_range" case noSupportedEndpoint = "no_supported_endpoint" @@ -59,6 +60,7 @@ struct PaykitPaymentRequest: Identifiable, Hashable { let note: String? let createdAt: Date? let expiresAt: Date? + let paymentDeadline: PaykitPreciseInstant? let acceptedPaymentEndpointIdentifiers: [String] let deliveryStatus: DeliveryStatus? let direction: Direction @@ -138,8 +140,18 @@ struct PaykitPaymentRequest: Identifiable, Hashable { guard record.state != .activeRecurring else { return .failure(.recurringRequest) } guard let terms = record.terms else { return .failure(.missingTerms) } guard terms.recurrence == nil else { return .failure(.recurringRequest) } - if requiresActionableRequest, terms.paymentDeadline != nil { - return .failure(.unsupportedPaymentDeadline) + let paymentDeadline: PaykitPreciseInstant? + if let deadline = terms.paymentDeadline { + guard case .at = deadline else { return .failure(.unsupportedPaymentDeadline) } + guard let timestamp = try? Paykit.paymentDeadlineAt(deadline: deadline, billingPeriod: nil), + let instant = PaykitPreciseInstant(timestamp: timestamp) + else { return .failure(.invalidPaymentDeadline) } + if requiresActionableRequest, instant < PaykitPreciseInstant(date: now) { + return .failure(.expired) + } + paymentDeadline = instant + } else { + paymentDeadline = nil } guard terms.amount.asset == PaykitIssuerInterop.bitcoinAsset else { return .failure(.unsupportedAsset) } guard let amountSats = Self.sats(fromBitcoinAmount: terms.amount.value) else { return .failure(.invalidAmount) } @@ -172,6 +184,7 @@ struct PaykitPaymentRequest: Identifiable, Hashable { note: Self.note(from: terms.metadata), createdAt: record.lastEventAt.flatMap(Self.parseDate), expiresAt: expiresAt, + paymentDeadline: paymentDeadline, acceptedPaymentEndpointIdentifiers: acceptedPaymentEndpointIdentifiers, deliveryStatus: expectedRole == .payee ? Self.deliveryStatus(from: record.proposalOutboundStatus) : nil, direction: expectedRole == .payer ? .incoming : .outgoing, @@ -216,6 +229,7 @@ struct PaykitPaymentRequest: Identifiable, Hashable { note = trimmedNote.isEmpty ? nil : trimmedNote createdAt = createdRecord.lastEventAt.flatMap(Self.parseDate) ?? fallbackCreatedAt expiresAt = draft.expiresAt + paymentDeadline = nil self.acceptedPaymentEndpointIdentifiers = acceptedPaymentEndpointIdentifiers self.deliveryStatus = deliveryStatus direction = .outgoing @@ -236,6 +250,7 @@ struct PaykitPaymentRequest: Identifiable, Hashable { note: note, createdAt: createdAt, expiresAt: expiresAt, + paymentDeadline: paymentDeadline, acceptedPaymentEndpointIdentifiers: acceptedPaymentEndpointIdentifiers, deliveryStatus: deliveryStatus, direction: direction, @@ -259,6 +274,7 @@ struct PaykitPaymentRequest: Identifiable, Hashable { note = subscription.note createdAt = billingPeriod.startsAt expiresAt = nil + paymentDeadline = nil acceptedPaymentEndpointIdentifiers = subscription.acceptedPaymentEndpointIdentifiers deliveryStatus = nil self.direction = direction @@ -275,6 +291,7 @@ struct PaykitPaymentRequest: Identifiable, Hashable { note: String?, createdAt: Date?, expiresAt: Date?, + paymentDeadline: PaykitPreciseInstant?, acceptedPaymentEndpointIdentifiers: [String], deliveryStatus: DeliveryStatus?, direction: Direction, @@ -289,6 +306,7 @@ struct PaykitPaymentRequest: Identifiable, Hashable { self.note = note self.createdAt = createdAt self.expiresAt = expiresAt + self.paymentDeadline = paymentDeadline self.acceptedPaymentEndpointIdentifiers = acceptedPaymentEndpointIdentifiers self.deliveryStatus = deliveryStatus self.direction = direction @@ -298,7 +316,17 @@ struct PaykitPaymentRequest: Identifiable, Hashable { } func isExpired(at date: Date) -> Bool { - lifecycleState == .proposed && (expiresAt.map { $0 <= date } ?? false) + (lifecycleState == .proposed && (expiresAt.map { $0 <= date } ?? false)) || isPaymentDeadlineExpired(at: date) + } + + func isPaymentDeadlineExpired(at date: Date) -> Bool { + paymentDeadline.map { $0 < PaykitPreciseInstant(date: date) } ?? false + } + + static func checkPaymentDeadline(_ deadline: PaykitPreciseInstant?, at date: Date = Date()) throws { + if let deadline, deadline < PaykitPreciseInstant(date: date) { + throw PaykitPaymentRequestError.requestExpired + } } func acceptsLightningInvoiceAmount(milliSatoshis: UInt64?) -> Bool { @@ -882,9 +910,15 @@ struct PaykitPaymentRequestService { } func ensurePaymentAllowed(_ request: PaykitPaymentRequest) async throws { + guard !request.isPaymentDeadlineExpired(at: now()) else { + throw PaykitPaymentRequestError.requestExpired + } guard try await !sdk.linkedPeers().contains(where: { $0.state == .blocked && PubkyPublicKeyFormat.matches($0.counterparty, request.counterparty) }) else { throw PaykitPaymentRequestError.requestUnavailable } + guard !request.isPaymentDeadlineExpired(at: now()) else { + throw PaykitPaymentRequestError.requestExpired + } } func accept(_ request: PaykitPaymentRequest) async throws { @@ -1685,6 +1719,9 @@ final class PaykitPaymentRequestManager { func ensurePaymentAllowed(_ request: PaykitPaymentRequest) async throws { let generation = stateGeneration do { + guard !request.isPaymentDeadlineExpired(at: now()) else { + throw PaykitPaymentRequestError.requestExpired + } guard let identity = activeIdentity, isApprovedForPayment(request) else { throw PaykitPaymentRequestError.requestUnavailable } @@ -1694,7 +1731,7 @@ final class PaykitPaymentRequestManager { isApprovedForPayment(request) else { throw PaykitPaymentRequestError.requestUnavailable } } catch { - if error as? PaykitPaymentRequestError == .requestUnavailable { + if error as? PaykitPaymentRequestError == .requestUnavailable || error as? PaykitPaymentRequestError == .requestExpired { approvedPaymentRequestIds.remove(request.id) } throw error @@ -1723,6 +1760,7 @@ final class PaykitPaymentRequestManager { } try await service.ensurePaymentAllowed($0) try await service.claimForPayment($0) + guard !$0.isExpired(at: now()) else { throw PaykitPaymentRequestError.requestExpired } try await consumePrivatePaymentList() if $0.requiresAcceptance { guard actionGeneration == stateGeneration, PubkyPublicKeyFormat.matches(activeIdentity, identity) else { @@ -2058,10 +2096,11 @@ final class PaykitPaymentRequestManager { } func isApprovedForPayment(_ request: PaykitPaymentRequest) -> Bool { - guard approvedPaymentRequestIds.contains(request.id) else { return false } + let date = now() + guard approvedPaymentRequestIds.contains(request.id), !request.isPaymentDeadlineExpired(at: date) else { return false } guard let billingPeriod = request.billingPeriod else { return historyRequests.contains { - $0.id == request.id && $0.direction == .incoming && $0.lifecycleState == .accepted + $0.id == request.id && $0.direction == .incoming && $0.lifecycleState == .accepted && !$0.isPaymentDeadlineExpired(at: date) } } return subscriptions.contains { @@ -2098,6 +2137,7 @@ final class PaykitPaymentRequestManager { } guard sdkLifecycleState == .accepted, actionGeneration == stateGeneration, + !acceptedRequest.isPaymentDeadlineExpired(at: now()), !pendingRequests.contains(where: { $0.id == request.id }) else { return } @@ -2129,7 +2169,8 @@ final class PaykitPaymentRequestManager { guard let sdkLifecycleState, payableStates.contains(sdkLifecycleState), sdkLifecycleState != .accepted || acceptedRequestIds.contains(id), - actionGeneration == stateGeneration + actionGeneration == stateGeneration, + !request.isPaymentDeadlineExpired(at: now()) else { return nil } if let pendingRequest = pendingRequests.first(where: { $0.id == id }) { return pendingRequest @@ -2433,6 +2474,7 @@ final class PaykitPaymentRequestManager { try await operation(request) guard actionGeneration == stateGeneration else { return } if markApprovedForPayment { + guard !request.isPaymentDeadlineExpired(at: now()) else { throw PaykitPaymentRequestError.requestExpired } approvedPaymentRequestIds.insert(request.id) } if preservePending { diff --git a/Bitkit/ViewModels/HwFundingSigner.swift b/Bitkit/ViewModels/HwFundingSigner.swift index e26ae938b..c5bffa209 100644 --- a/Bitkit/ViewModels/HwFundingSigner.swift +++ b/Bitkit/ViewModels/HwFundingSigner.swift @@ -108,8 +108,8 @@ struct HwFundingSigner { } /// Broadcasts a signed funding transaction without requiring the hardware device. - func broadcastSignedFunding(_ signed: HwFundingSignedTx) async throws -> HwFundingBroadcastResult { - let txId = try await broadcastStep(serializedTx: signed.serializedTx) + func broadcastSignedFunding(_ signed: HwFundingSignedTx, paymentDeadline: PaykitPreciseInstant? = nil) async throws -> HwFundingBroadcastResult { + let txId = try await broadcastStep(serializedTx: signed.serializedTx, paymentDeadline: paymentDeadline) return HwFundingBroadcastResult( txId: txId, miningFeeSats: signed.miningFeeSats, @@ -255,10 +255,10 @@ struct HwFundingSigner { /// already been handed to the network must never be reported as a signing timeout, so a timeout /// here surfaces `.broadcastUncertain` (the funding tx may still confirm) without tearing down the /// device session. - private func broadcastStep(serializedTx: String) async throws -> String { + private func broadcastStep(serializedTx: String, paymentDeadline: PaykitPreciseInstant?) async throws -> String { do { return try await withTimeout(timeouts.broadcast) { - try await funding.broadcastFunding(serializedTx: serializedTx) + try await funding.broadcastFunding(serializedTx: serializedTx, paymentDeadline: paymentDeadline) } } catch is CancellationError { throw CancellationError() @@ -505,6 +505,7 @@ final class HwSendCoordinator { address: String, sats: UInt64, satsPerVByte: UInt64, + paymentDeadline: PaykitPreciseInstant? = nil, beforeFirstBroadcast: @escaping () async throws -> Void = {}, beforeBroadcastAttempt: @escaping () async throws -> Void = {}, afterBroadcast: @escaping (HwFundingBroadcastResult) async -> Void = { _ in }, @@ -560,13 +561,17 @@ final class HwSendCoordinator { pendingPayment?.isPreparedForBroadcast = true } - var broadcastWasAttempted = pendingPayment?.hasBroadcastAttempted == true + let hadPriorBroadcastAttempt = pendingPayment?.hasBroadcastAttempted == true + var broadcastWasAttempted = hadPriorBroadcastAttempt do { do { try await beforeBroadcastAttempt() + try PaykitPaymentRequest.checkPaymentDeadline(paymentDeadline) } catch { if !broadcastWasAttempted { pendingPayment = nil + } else if ((error as? AppError)?.underlyingError ?? error) as? PaykitPaymentRequestError == .requestExpired { + isBroadcastUnresolved = true } throw error } @@ -575,11 +580,20 @@ final class HwSendCoordinator { broadcastWasAttempted = true pendingPayment?.hasBroadcastAttempted = true do { - let result = try await signer.broadcastSignedFunding(signed) + let result = try await signer.broadcastSignedFunding(signed, paymentDeadline: paymentDeadline) await afterBroadcast(result) return result } catch { isBroadcastUnresolved = false + let underlyingError = (error as? AppError)?.underlyingError ?? error + if underlyingError as? PaykitPaymentRequestError == .requestExpired { + // A queued retry can expire without changing the uncertainty of an earlier attempt. + isBroadcastUnresolved = hadPriorBroadcastAttempt + broadcastWasAttempted = hadPriorBroadcastAttempt + pendingPayment?.hasBroadcastAttempted = hadPriorBroadcastAttempt + if !hadPriorBroadcastAttempt { pendingPayment = nil } + throw error + } let outcomeIsUncertain = (error as? HwTransferError) == .broadcastUncertain if !outcomeIsUncertain, !error.isBroadcastConnectivityFailure() { pendingPayment = nil diff --git a/Bitkit/ViewModels/TransferViewModel.swift b/Bitkit/ViewModels/TransferViewModel.swift index 280cb38fc..4b898c2e0 100644 --- a/Bitkit/ViewModels/TransferViewModel.swift +++ b/Bitkit/ViewModels/TransferViewModel.swift @@ -103,7 +103,7 @@ protocol HwTransferFunding: Sendable { addressType: AddressScriptType ) async throws -> UInt64 func signFunding(walletId: String, funding: HwFundingTransaction) async throws -> HwFundingSignedTx - func broadcastFunding(serializedTx: String) async throws -> String + func broadcastFunding(serializedTx: String, paymentDeadline: PaykitPreciseInstant?) async throws -> String } /// The device-session capability the transfer flow needs for on-device signing, addressed by wallet diff --git a/Bitkit/ViewModels/WalletViewModel.swift b/Bitkit/ViewModels/WalletViewModel.swift index d5f941108..7aa4ceb9b 100644 --- a/Bitkit/ViewModels/WalletViewModel.swift +++ b/Bitkit/ViewModels/WalletViewModel.swift @@ -605,6 +605,7 @@ class WalletViewModel: ObservableObject { address: String, sats: UInt64, isMaxAmount: Bool = false, + paymentDeadline: PaykitPreciseInstant? = nil, beforeBroadcastAttempt: () async throws -> Void = {} ) async throws -> Txid { guard let selectedFeeRateSatsPerVByte else { @@ -623,7 +624,8 @@ class WalletViewModel: ObservableObject { sats: sats, satsPerVbyte: selectedFeeRateSatsPerVByte, utxosToSpend: selectedUtxos, - isMaxAmount: isMaxAmount + isMaxAmount: isMaxAmount, + beforeSubmission: { try PaykitPaymentRequest.checkPaymentDeadline(paymentDeadline) } ) Task { @@ -933,10 +935,14 @@ class WalletViewModel: ObservableObject { bolt11: String, sats: UInt64? = nil, timeoutSeconds: TimeInterval = 10, + paymentDeadline: PaykitPreciseInstant? = nil, afterListening: (@MainActor (String) -> Void)? = nil, onTimeout: (@MainActor (String) -> Void)? = nil ) async throws -> SettledLightningPayment { - let hash = try await lightningService.send(bolt11: bolt11, sats: sats) + let hash = try await lightningService.send( + bolt11: bolt11, sats: sats, + beforeSubmission: { try PaykitPaymentRequest.checkPaymentDeadline(paymentDeadline) } + ) let paymentHash = String(hash) afterListening?(paymentHash) return try await waitForLightningPayment( diff --git a/Bitkit/Views/Wallets/Send/HwSendSignView.swift b/Bitkit/Views/Wallets/Send/HwSendSignView.swift index 79fae2edf..d9815d76e 100644 --- a/Bitkit/Views/Wallets/Send/HwSendSignView.swift +++ b/Bitkit/Views/Wallets/Send/HwSendSignView.swift @@ -121,6 +121,7 @@ struct HwSendSignView: View { address: invoice.address, sats: amount, satsPerVByte: UInt64(feeRate), + paymentDeadline: contactPaymentContext?.incomingPaymentRequest?.paymentDeadline, beforeFirstBroadcast: { try await prepareContactPayment(contactPaymentContext) }, beforeBroadcastAttempt: { try await authorizeContactPayment(contactPaymentContext) }, afterBroadcast: { result in diff --git a/Bitkit/Views/Wallets/Send/LnurlPayConfirm.swift b/Bitkit/Views/Wallets/Send/LnurlPayConfirm.swift index b8182b7a1..1255a7707 100644 --- a/Bitkit/Views/Wallets/Send/LnurlPayConfirm.swift +++ b/Bitkit/Views/Wallets/Send/LnurlPayConfirm.swift @@ -325,6 +325,7 @@ struct LnurlPayConfirm: View { try await wallet.sendWithTimeout( bolt11: bolt11, sats: nil, + paymentDeadline: incomingPaymentRequest?.paymentDeadline, afterListening: { _ in lightningPaymentSubmitted = true }, onTimeout: { timedOutHash in app.addPendingPaymentHash(timedOutHash, contactPaymentContext: contactPaymentContext) @@ -390,7 +391,9 @@ struct LnurlPayConfirm: View { private func validateIncomingPaymentRequest(_ context: ContactPaymentContext?, amountMsats: UInt64) throws { guard let context, let request = context.incomingPaymentRequest else { return } - guard !request.isExpired(at: Date()) else { throw PaykitPaymentRequestError.requestExpired } + guard !request.isExpired(at: Date()) || paykitPaymentRequestManager.isApprovedForPayment(request) else { + throw PaykitPaymentRequestError.requestExpired + } guard app.ownsContactPaymentContext(context) else { throw PaykitPaymentRequestError.requestUnavailable } guard let amountSats = wallet.sendAmountSats, request.acceptsPaymentAmount(amountSats), diff --git a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift index 6c78821c6..1bbc7db4d 100644 --- a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift +++ b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift @@ -1013,6 +1013,7 @@ struct SendConfirmationView: View { try await wallet.sendWithTimeout( bolt11: invoice.bolt11, sats: paymentSats, + paymentDeadline: incomingPaymentRequest?.paymentDeadline, afterListening: { _ in lightningPaymentSubmitted = true }, onTimeout: { timedOutHash in app.addPendingPaymentHash(timedOutHash, contactPaymentContext: contactPaymentContext) @@ -1084,6 +1085,7 @@ struct SendConfirmationView: View { address: invoice.address, sats: amount, isMaxAmount: useMaxAmount, + paymentDeadline: incomingPaymentRequest?.paymentDeadline, beforeBroadcastAttempt: beforeBroadcastAttempt ) } @@ -1194,7 +1196,9 @@ struct SendConfirmationView: View { private func validateIncomingPaymentRequestContext(_ context: ContactPaymentContext?) throws { guard let context, let request = context.incomingPaymentRequest else { return } - guard !request.isExpired(at: Date()) else { throw PaykitPaymentRequestError.requestExpired } + guard !request.isExpired(at: Date()) || paykitPaymentRequestManager.isApprovedForPayment(request) else { + throw PaykitPaymentRequestError.requestExpired + } guard app.ownsContactPaymentContext(context) else { throw PaykitPaymentRequestError.requestUnavailable } } diff --git a/BitkitTests/HwFundingSignerTests.swift b/BitkitTests/HwFundingSignerTests.swift index a803b54d3..0f3646923 100644 --- a/BitkitTests/HwFundingSignerTests.swift +++ b/BitkitTests/HwFundingSignerTests.swift @@ -323,6 +323,56 @@ final class HwFundingSignerTests: XCTestCase { } } + func testPaymentDeadlineRejectionPreservesOnlyPriorBroadcastUncertainty() async throws { + for hadPriorAttempt in [false, true] { + for expiresInQueue in [false, true] { + let funding = MockHwFunding() + let manager = HwWalletManager() + let coordinator = HwSendCoordinator( + walletId: "trezor:wallet", + signerFactory: { [self] _, address, satsPerVByte in + makeSigner(funding: funding, connecting: MockHwConnecting(), feeRate: satsPerVByte, address: address) + } + ) + if hadPriorAttempt { + funding.broadcastError = HwTransferError.broadcastUncertain + await assertThrowsAsync { + _ = try await coordinator.signAndBroadcast(manager: manager, address: "bc1qtest", sats: 42000, satsPerVByte: 2) + } + funding.broadcastError = nil + } + let deadline = PaykitPreciseInstant(date: Date().addingTimeInterval(expiresInQueue ? 60 : -60)) + funding.broadcastNow = { deadline.date.addingTimeInterval(1) } + var outcomes: [PrivatePaymentListSendOutcome] = [] + do { + _ = try await coordinator.signAndBroadcast( + manager: manager, address: "bc1qtest", sats: 42000, satsPerVByte: 2, + paymentDeadline: deadline, afterFailure: { outcomes.append($0) } + ) + XCTFail("Expired payment must not broadcast") + } catch { + XCTAssertEqual(error as? PaykitPaymentRequestError, .requestExpired) + } + XCTAssertEqual(funding.broadcastCalls, hadPriorAttempt ? 1 : 0) + XCTAssertEqual(outcomes, [hadPriorAttempt ? .uncertain : .definitePreBroadcastFailure]) + XCTAssertEqual(coordinator.hasPendingBroadcast, hadPriorAttempt) + XCTAssertEqual(coordinator.isBroadcastUnresolved, hadPriorAttempt) + } + } + } + + func testHardwareSubmissionRejectsExpiredDeadlineBeforeCallingElectrum() async throws { + do { + _ = try await OnChainHwService.shared.broadcastRawTx( + serializedTx: "invalid", electrumUrl: "invalid", + paymentDeadline: PaykitPreciseInstant(date: Date().addingTimeInterval(-1)) + ) + XCTFail("Expired payment must not reach Electrum") + } catch { + XCTAssertEqual((error as? Bitkit.AppError)?.underlyingError as? PaykitPaymentRequestError, .requestExpired) + } + } + func testCoordinatorBroadcastFailureRemainsUncertainAfterPendingPaymentIsCleared() async { let funding = MockHwFunding() let manager = HwWalletManager() diff --git a/BitkitTests/HwTransferMocks.swift b/BitkitTests/HwTransferMocks.swift index 31a4f5ebc..6f8ab6305 100644 --- a/BitkitTests/HwTransferMocks.swift +++ b/BitkitTests/HwTransferMocks.swift @@ -23,6 +23,7 @@ final class MockHwFunding: HwTransferFunding { var signGate: AsyncGate? var broadcastError: Error? var broadcastDelay: Double = 0 + var broadcastNow: () -> Date = Date.init /// Holds every broadcast until opened, so a test can act while its outcome is unknown. var broadcastGate: AsyncGate? var funding = HwFundingTransaction(psbt: "psbt", miningFeeSats: 141, feeRate: 1, totalSpent: 43186, satsPerVByte: 1) @@ -114,7 +115,8 @@ final class MockHwFunding: HwTransferFunding { return signedTx } - func broadcastFunding(serializedTx: String) async throws -> String { + func broadcastFunding(serializedTx: String, paymentDeadline: PaykitPreciseInstant? = nil) async throws -> String { + try PaykitPaymentRequest.checkPaymentDeadline(paymentDeadline, at: broadcastNow()) broadcastCalls += 1 broadcastTransactions.append(serializedTx) if let broadcastGate { diff --git a/BitkitTests/PaykitIssuerInteropTests.swift b/BitkitTests/PaykitIssuerInteropTests.swift index afd6c7f99..0754b5bfc 100644 --- a/BitkitTests/PaykitIssuerInteropTests.swift +++ b/BitkitTests/PaykitIssuerInteropTests.swift @@ -10,7 +10,7 @@ final class PaykitIssuerInteropTests: XCTestCase { XCTAssertEqual(fixtures.schemaVersion, 1) for fixture in fixtures.requestFixtures { - let record = try paymentRequestRecord( + var record = try paymentRequestRecord( asset: fixture.asset, endpointIdentifiers: fixture.acceptedPaymentEndpointIdentifiers ) @@ -18,6 +18,10 @@ final class PaykitIssuerInteropTests: XCTestCase { XCTAssertEqual(request != nil, fixture.accepted, fixture.name) XCTAssertEqual(request?.acceptedPaymentEndpointIdentifiers ?? [], fixture.expectedIdentifiers, fixture.name) + record.terms?.paymentDeadline = .at(timestamp: "2099-01-01T00:00:00.123456789Z") + let deadlineRequest = PaykitPaymentRequest(record: record, now: Date(), network: fixture.network.ldkNetwork) + XCTAssertEqual(deadlineRequest != nil, fixture.accepted, fixture.name) + XCTAssertEqual(deadlineRequest?.acceptedPaymentEndpointIdentifiers ?? [], fixture.expectedIdentifiers, fixture.name) } } diff --git a/BitkitTests/PaykitPaymentProofServiceTests.swift b/BitkitTests/PaykitPaymentProofServiceTests.swift index 36a733969..ee523c267 100644 --- a/BitkitTests/PaykitPaymentProofServiceTests.swift +++ b/BitkitTests/PaykitPaymentProofServiceTests.swift @@ -53,9 +53,12 @@ final class PaykitPaymentProofServiceTests: XCTestCase { } } - func testCompletedLightningPaymentRetriesAfterRestart() async throws { - let record = try paymentRequestRecord() - let request = try XCTUnwrap(PaykitPaymentRequest(record: record, now: Date())) + func testCompletedLightningPaymentRetriesAfterRestartPastPaymentDeadline() async throws { + var record = try paymentRequestRecord() + let deadline = Date().addingTimeInterval(-60) + record.terms?.paymentDeadline = .at(timestamp: ISO8601DateFormatter().string(from: deadline)) + let request = try XCTUnwrap(PaykitPaymentRequest(record: record, now: deadline.addingTimeInterval(-1))) + XCTAssertTrue(request.isPaymentDeadlineExpired(at: Date())) let store = PaymentProofMemoryStore() let sdk = PaymentProofSdkMock(identity: identity, records: [record]) await sdk.setSubmissionFailure(true) @@ -239,6 +242,8 @@ final class PaykitPaymentProofServiceTests: XCTestCase { func testDefiniteLightningSubmissionFailureClearsProof() async throws { let errors: [Error] = [ + PaykitPaymentRequestError.requestExpired, + Bitkit.AppError(error: PaykitPaymentRequestError.requestExpired), Bitkit.CustomServiceError.nodeNotSetup, Bitkit.AppError(serviceError: .nodeNotStarted), NodeError.NotRunning(message: "stopped"), diff --git a/BitkitTests/PaykitPaymentRequestServiceTests.swift b/BitkitTests/PaykitPaymentRequestServiceTests.swift index d6620c4f8..d6a800b32 100644 --- a/BitkitTests/PaykitPaymentRequestServiceTests.swift +++ b/BitkitTests/PaykitPaymentRequestServiceTests.swift @@ -1388,9 +1388,10 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { (paymentRequestRecord(id: "missing-terms"), .missingTerms), (paymentRequestRecord(id: "wrong-asset", asset: "BTC"), .unsupportedAsset), ( - paymentRequestRecord(id: "payment-deadline", paymentDeadline: .at(timestamp: timestamp(now.addingTimeInterval(3600)))), + paymentRequestRecord(id: "payment-deadline", paymentDeadline: .periodStart(seconds: 3600)), .unsupportedPaymentDeadline ), + (paymentRequestRecord(id: "invalid-deadline", paymentDeadline: .at(timestamp: "invalid")), .invalidPaymentDeadline), (paymentRequestRecord(id: "invalid-amount", amount: "not-bitcoin"), .invalidAmount), (paymentRequestRecord(id: "amount-out-of-range", amount: "184467440737.09551615"), .amountOutOfRange), (paymentRequestRecord(id: "unsupported-endpoint", endpoints: ["btc-unsupported-method"]), .noSupportedEndpoint), @@ -1414,6 +1415,204 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { } } + func testAbsolutePaymentDeadlineIsInclusiveAndIndependentOfProposalExpiry() throws { + let now = try XCTUnwrap(PaykitPaymentRequest.parseDate("2027-01-15T08:00:00.500Z")) + let cases: [(String, Bool)] = [ + ("2027-01-15T08:00:01Z", true), + ("2027-01-15T08:00:00.500000001Z", true), + ("2027-01-15T08:00:00.500Z", true), + ("2027-01-15T08:00:00.499999999Z", false), + ("2027-01-15T08:00:00Z", false), + ("2027-01-15T08:00:01+00:00", false), + ("not-a-timestamp", false), + ] + for (deadline, isTimely) in cases { + for state in [PaymentRequestLifecycleState.proposed, .accepted] { + let record = try paymentRequestRecord( + state: state, + expiresAt: timestamp(now.addingTimeInterval(state == .accepted ? -60 : 60)), + paymentDeadline: .at(timestamp: deadline) + ) + let request = PaykitPaymentRequest(record: record, now: now) + XCTAssertEqual(request != nil, isTimely, "\(state), \(deadline)") + if let request { + XCTAssertFalse(request.isExpired(at: now)) + XCTAssertEqual(request.paymentDeadline, PaykitPreciseInstant(timestamp: deadline)) + XCTAssertEqual(request.updatingLifecycleState(.accepted).paymentDeadline, request.paymentDeadline) + } + } + } + } + + func testPaymentDeadlineCrossingDuringPreparationDoesNotApprovePayment() async throws { + for stage in ["before", "consume", "accept"] { + let now = Date(timeIntervalSince1970: 1_800_000_000) + let clock = PaymentRequestTestClock(now) + let record = try paymentRequestRecord(paymentDeadline: .at(timestamp: timestamp(now.addingTimeInterval(1)))) + let sdk = PaymentRequestSdkMock(records: [record]) + let manager = paymentRequestManager(sdk: sdk, clock: clock) + await manager.refresh() + let request = try XCTUnwrap(manager.pendingRequests.first) + if stage == "before" { clock.advance(by: 2) } + if stage == "accept" { await sdk.pauseNextAccept() } + + let preparation = Task { + try await manager.prepareForPayment(request) { + if stage == "consume" { clock.advance(by: 2) } + } + } + if stage == "accept" { + try await waitUntil { await sdk.acceptIsPaused() } + clock.advance(by: 2) + await sdk.resumeAccept() + } + do { + try await preparation.value + XCTFail("Expired preparation must not approve payment: \(stage)") + } catch { + XCTAssertEqual(error as? PaykitPaymentRequestError, .requestExpired, stage) + } + XCTAssertFalse(manager.isApprovedForPayment(request), stage) + let accepted = await sdk.snapshot().acceptedRequests + XCTAssertEqual(accepted.count, stage == "accept" ? 1 : 0, stage) + } + } + + func testPaymentDeadlineIsRecheckedAfterAuthorizationInEverySoftwareSendRoute() async throws { + for route in ["lightning", "lnurl", "onchain"] { + for expiryStage in ["timely", "preparation", "authorization"] { + let crossesDeadline = expiryStage != "timely" + let now = Date(timeIntervalSince1970: 1_800_000_000) + let clock = PaymentRequestTestClock(now) + let record = try paymentRequestRecord( + expiresAt: timestamp(now.addingTimeInterval(1)), + paymentDeadline: .at(timestamp: timestamp(now.addingTimeInterval(10))) + ) + let sdk = PaymentRequestSdkMock(records: [record]) + let manager = paymentRequestManager(sdk: sdk, clock: clock) + await manager.refresh() + let request = try XCTUnwrap(manager.pendingRequests.first) + try await manager.prepareForPayment(request) + clock.advance(by: 10) + XCTAssertTrue(manager.isApprovedForPayment(request), "Acceptance survives proposal expiry") + // Fee calculation, PIN confirmation and LNURL retrieval all finish before this authorization. + if expiryStage == "preparation" { clock.advance(by: 0.001) } + else { await sdk.pauseNextLinkedPeers() } + + var sent = false + var authorizationFailed = false + let authorize: (PaykitPaymentRequest) async throws -> Void = { try await manager.ensurePaymentAllowed($0) } + let onFailure: (Error) async -> Void = { _ in authorizationFailed = true } + let execution = Task { + switch route { + case "lightning": + try await SendConfirmationView.sendLightningPayment( + request: request, authorize: authorize, onAuthorizationFailure: onFailure + ) { sent = true } + case "lnurl": + try await LnurlPayConfirm.sendLightningPayment( + request: request, authorize: authorize, onAuthorizationFailure: onFailure + ) { sent = true } + default: + try await SendConfirmationView.sendOnchainPayment( + request: request, prepareBroadcast: { _ in }, authorize: authorize, + onAuthorizationFailure: onFailure, onAuthorized: { _ in }, + send: { beforeBroadcastAttempt in + try await beforeBroadcastAttempt() + sent = true + } + ) + } + } + if expiryStage != "preparation" { + try await waitUntil { await sdk.linkedPeersIsPaused() } + if expiryStage == "authorization" { clock.advance(by: 0.001) } + await sdk.resumeLinkedPeers() + } + do { + try await execution.value + XCTAssertFalse(crossesDeadline, route) + } catch { + XCTAssertTrue(crossesDeadline, route) + XCTAssertEqual(error as? PaykitPaymentRequestError, .requestExpired, route) + } + XCTAssertEqual(sent, !crossesDeadline, route) + XCTAssertEqual(authorizationFailed, crossesDeadline, route) + } + } + } + + func testPaymentDeadlineIsCheckedInsideTheLdkSubmissionQueue() async throws { + for crossesDeadline in [false, true] { + let clock = PaymentRequestTestClock(Date(timeIntervalSince1970: 1_800_000_000)) + let deadline = PaykitPreciseInstant(date: clock.now().addingTimeInterval(1)) + let blocked = expectation(description: "LDK queue blocked") + let submissionStarted = expectation(description: "submission queued") + let release = DispatchSemaphore(value: 0) + defer { release.signal() } + let blocker = Task { + try await Bitkit.ServiceQueue.background(.ldk) { + blocked.fulfill() + XCTAssertEqual(release.wait(timeout: .now() + 5), .success) + } + } + await fulfillment(of: [blocked], timeout: 2) + let submission = Task { + submissionStarted.fulfill() + return try await Bitkit.LightningService.submitPayment(beforeSubmission: { + try PaykitPaymentRequest.checkPaymentDeadline(deadline, at: clock.now()) + }) { "submitted" } + } + await fulfillment(of: [submissionStarted], timeout: 2) + clock.advance(by: crossesDeadline ? 1.001 : 1) + release.signal() + try await blocker.value + do { + let result = try await submission.value + XCTAssertFalse(crossesDeadline) + XCTAssertEqual(result, "submitted") + } catch { + XCTAssertTrue(crossesDeadline) + XCTAssertEqual((error as? Bitkit.AppError)?.underlyingError as? PaykitPaymentRequestError, .requestExpired) + XCTAssertTrue(PaykitPaymentProofService.isDefiniteOnchainPreBroadcastFailure(error)) + XCTAssertEqual(SendConfirmationView.privatePaymentListOutcomeAfterFailure( + currentOutcome: .uncertain, walletType: .onchain, onchainPaymentStarted: true, error: error + ), .definitePreBroadcastFailure) + } + } + } + + func testExpiredAcceptedRequestIsNotReinsertedAfterSdkLookup() async throws { + for isRetry in [false, true] { + let now = Date(timeIntervalSince1970: 1_800_000_000) + let clock = PaymentRequestTestClock(now) + var record = try paymentRequestRecord(paymentDeadline: .at(timestamp: timestamp(now.addingTimeInterval(1)))) + let sdk = PaymentRequestSdkMock(records: [record]) + let manager = paymentRequestManager(sdk: sdk, clock: clock) + await manager.refresh() + let request = try XCTUnwrap(manager.pendingRequests.first) + try await manager.prepareForPayment(request) + record.state = .accepted + await sdk.setRecords([record]) + XCTAssertTrue(manager.pendingRequests.isEmpty) + await sdk.pauseNextPaymentRequestList() + let lookup = Task { + if isRetry { + let retried = await manager.paymentRequestForRetry(request.id) + XCTAssertNil(retried) + } else { + await manager.finishPayment(request) + } + } + try await waitUntil { await sdk.paymentRequestListIsPaused() } + clock.advance(by: 2) + await sdk.resumePaymentRequestList() + await lookup.value + XCTAssertTrue(manager.pendingRequests.isEmpty) + XCTAssertFalse(manager.isApprovedForPayment(request)) + } + } + func testSynchronizeLogsRedactedParseFailuresWithoutRequestData() async throws { let counterparty = "pubky\(String(repeating: "y", count: 52))" let outgoingCounterparty = "pubky\(String(repeating: "p", count: 52))" @@ -1517,11 +1716,11 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { paymentRequestRecord(id: "outgoing", state: .proposed, role: .payee), paymentRequestRecord(id: "recurring", state: .activeRecurring), paymentRequestRecord(id: "unsupported", state: .canceled, endpoints: ["btc-unsupported-method"]), - paymentRequestRecord(id: "deadline-proposed", paymentDeadline: .at(timestamp: timestamp(now))), - paymentRequestRecord(id: "deadline-accepted", state: .accepted, paymentDeadline: .at(timestamp: timestamp(now))), - paymentRequestRecord(id: "deadline-paid", state: .proofSubmitted, paymentDeadline: .at(timestamp: timestamp(now))), - paymentRequestRecord(id: "deadline-canceled", state: .canceled, paymentDeadline: .at(timestamp: timestamp(now))), - paymentRequestRecord(id: "deadline-rejected", state: .rejected, paymentDeadline: .at(timestamp: timestamp(now))), + paymentRequestRecord(id: "deadline-proposed", paymentDeadline: .at(timestamp: timestamp(now.addingTimeInterval(-1)))), + paymentRequestRecord(id: "deadline-accepted", state: .accepted, paymentDeadline: .at(timestamp: timestamp(now.addingTimeInterval(-1)))), + paymentRequestRecord(id: "deadline-paid", state: .proofSubmitted, paymentDeadline: .at(timestamp: timestamp(now.addingTimeInterval(-1)))), + paymentRequestRecord(id: "deadline-canceled", state: .canceled, paymentDeadline: .at(timestamp: timestamp(now.addingTimeInterval(-1)))), + paymentRequestRecord(id: "deadline-rejected", state: .rejected, paymentDeadline: .at(timestamp: timestamp(now.addingTimeInterval(-1)))), ] let manager = paymentRequestManager( sdk: PaymentRequestSdkMock(records: records), diff --git a/changelog.d/next/856.changed.md b/changelog.d/next/856.changed.md index 5bec108fc..f119255a5 100644 --- a/changelog.d/next/856.changed.md +++ b/changelog.d/next/856.changed.md @@ -1 +1 @@ -Share Paykit contacts and payment state with authorized apps while keeping wallet keys private, restore contacts when re-importing an identity, prepare incoming payments in the payment sheet, preserve subscription reminders through retries, and label received payments with their Paykit contact. +Share Paykit contacts and payment state with authorized apps while keeping wallet keys private, restore contacts when re-importing an identity, prepare incoming payments in the payment sheet, support one-time payment deadlines, preserve subscription reminders through retries, and label received payments with their Paykit contact. diff --git a/journeys/payment-requests/README.md b/journeys/payment-requests/README.md index 0d50b6e26..0f24b26df 100644 --- a/journeys/payment-requests/README.md +++ b/journeys/payment-requests/README.md @@ -175,10 +175,17 @@ the SDK and can push the Pay step well past the budget. ## Payment deadline history -`payment-deadline-history.xml` covers rc56 requests with actual-payment deadlines. -Bitkit keeps their lifecycle and paid-period history, and subscription cancellation, -but does not accept them, offer payments, or schedule payment reminders. The journey -requires a controlled rc56 peer to prepare the accepted and paid records; repository +`absolute-payment-deadline.xml` verifies valid absolute deadlines, acceptance before proposal expiry, +retry after proposal expiry, expiry during invoice retrieval, and proof reconciliation after the deadline. +It matches Android's journey, using the iOS `Retry` accessibility identifier. + +`payment-deadline-history.xml` covers expired one-time actual-payment deadlines and +unsupported recurring deadlines. Bitkit keeps their lifecycle and paid-period history, +and subscription cancellation, but does not offer expired or unsupported payments. +Future one-time absolute deadlines are supported, including fractional UTC timestamps; +the deadline is inclusive and is checked again at payment submission. Proposal expiry +does not expire an already-accepted payment, and proof delivery may finish after the payment deadline. +The journey requires a controlled shared-runtime peer to prepare the accepted and paid records; repository tests cover these states without sending funds. On iOS, payment-history rows show notes or dates rather than lifecycle labels. On Android, unpaid history rows show lifecycle labels, while paid rows show subscription names, notes, or dates. Active subscriptions diff --git a/journeys/payment-requests/absolute-payment-deadline.xml b/journeys/payment-requests/absolute-payment-deadline.xml new file mode 100644 index 000000000..fcaa92946 --- /dev/null +++ b/journeys/payment-requests/absolute-payment-deadline.xml @@ -0,0 +1,26 @@ + + + Verifies one-time BTC requests with absolute At payment deadlines from a controlled Paykit issuer. + Use disposable linked regtest identities, a funded test wallet, and a controlled LNURL invoice callback. + Proposal expiry is the acceptance deadline, not the actual-payment deadline. Absolute payment deadlines + are inclusive UTC timestamps and may include fractional seconds. Recurring PeriodStart deadlines remain + unsupported. Record each request id and the issuer's UTC deadline without changing the device clock. + + + Have the issuer send a one-time request with a future absolute payment deadline containing fractional seconds and a shorter proposal expiry + Open the request and verify its amount and payment review are available before either deadline + Configure the invoice callback to fail, then swipe to pay before proposal expiry so acceptance completes but no payment is dispatched + Wait until proposal expiry has passed while the payment deadline is still in the future + Restore the callback, tap Try Again (id "Retry"), and verify the accepted request returns to payment review + Swipe to pay before the payment deadline and verify payment succeeds + Have the issuer send another request with a short future absolute payment deadline and open its payment review + Delay the invoice callback, swipe to pay before the deadline, and release the callback only after the deadline + Verify no new Lightning payment is dispatched and the expired request cannot be paid again + Repeat with a new on-chain request, waiting past its payment deadline during fee confirmation or PIN entry, and verify no transaction is submitted + Repeat with a hardware wallet, waiting past its payment deadline during signing, and verify an expired-payment error is shown without broadcasting + For a hardware payment whose earlier broadcast outcome is uncertain, let its deadline pass before retrying and verify the pending proof and signed transaction remain available for reconciliation + Have the issuer send a request whose absolute payment deadline is already past and verify no Pay action is offered + Keep proof delivery unavailable for a payment sent before its deadline, restore delivery after the deadline, and verify reconciliation still delivers its proof without sending another payment + Restart Bitkit and verify completed and expired request history remains visible + + diff --git a/journeys/payment-requests/payment-deadline-history.xml b/journeys/payment-requests/payment-deadline-history.xml index ad3022a4e..ab40324e7 100644 --- a/journeys/payment-requests/payment-deadline-history.xml +++ b/journeys/payment-requests/payment-deadline-history.xml @@ -1,11 +1,11 @@ - Requests with actual-payment deadlines are visible but cannot be paid by this version of Bitkit. - Use a disposable Paykit test identity linked to a controlled rc56 peer. Prepare one-time BTC - requests with deadlines in proposed, accepted, rejected, canceled and proof-submitted states, + One-time requests past their actual-payment deadlines and unsupported recurring deadlines remain visible in history. + Use a disposable Paykit test identity linked to a controlled shared-runtime peer. Prepare one-time BTC + requests with expired absolute deadlines in proposed, accepted, rejected, canceled and proof-submitted states, plus an incoming, accepted monthly BTC subscription with no end date, a period-start deadline, one paid period and one unpaid period. Acceptance and proof submission must be prepared through the controlled - client because Bitkit intentionally cannot accept these terms. Use valid regtest payment proofs. + client to retain these expired and unsupported fixture states. Use valid regtest payment proofs. Do not reset or replace a funded wallet to prepare this fixture. On iOS, history rows show notes or dates instead of status labels. On Android, unpaid history rows show status labels, while paid rows show subscription names, notes or dates. Record each payment request id.