diff --git a/Bitkit.xcodeproj/project.pbxproj b/Bitkit.xcodeproj/project.pbxproj index 8e20f751e..1831f6044 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-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 0d91d3e52..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" : "24162ebbcc703251d8038f2e176d1f4cfb117c6a", - "version" : "0.1.0-rc56" + "revision" : "f2c5f7127b1cb0f29052ce76e43f3c0ae468fd12", + "version" : "0.1.0-rc62" } }, { diff --git a/Bitkit/AppScene.swift b/Bitkit/AppScene.swift index 328c244d9..e70895c30 100644 --- a/Bitkit/AppScene.swift +++ b/Bitkit/AppScene.swift @@ -189,24 +189,25 @@ 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 } } struct AppScene: View { - private static let initialPaykitSyncRetryDelays = Array(repeating: Duration.seconds(2), count: 14) - @Environment(\.scenePhase) var scenePhase @EnvironmentObject private var session: SessionManager @@ -240,7 +241,9 @@ struct AppScene: View { @State private var hwWalletManager: HwWalletManager @State private var calculatorInputManager = CalculatorInputManager() @State private var paykitPaymentRequestManager = PaykitPaymentRequestManager() - @State private var initialPaykitSyncGeneration = 0 + @State private var receivedPaymentBackfillCache = PaykitReceivedPaymentBackfillCache( + activityChanges: CoreService.shared.activity.activitiesChangedPublisher + ) @State private var hideSplash = false @State private var removeSplash = false @@ -317,13 +320,27 @@ struct AppScene: View { // Held here because `HwWalletManager` keeps its vendor sessions weakly. _jadeManager = State(initialValue: jadeManager) _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) } ) } @@ -357,7 +374,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) } @@ -429,13 +445,14 @@ 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 { + await handlePendingPaykitSubscriptionNotification() try? await contactsManager.loadContacts(for: pk) - await refreshPrivateOnlyPaykitReceiverMarker() + await refreshPrivateOnlyPaykitApp() await refreshIncomingPaykitPaymentRequests(presentItems: false) - await handlePendingPaykitSubscriptionNotification() if PaykitSubscriptionNotificationTargetStore.load() == nil { await presentNextIncomingPaykitItem() } @@ -449,27 +466,19 @@ struct AppScene: View { } } .onReceive(contactsManager.savedContactsChangedPublisher) { contacts in + let publicKeys = contacts.map(\.publicKey) guard PaykitFeatureFlags.isUIEnabled, wallet.walletExists == true, pubkyProfile.authState == .authenticated else { return } - let publicKeys = contacts.map(\.publicKey) + paykitPaymentRequestManager.updateSavedPublicKeys(publicKeys) Task { await PrivatePaykitService.shared.prepareSavedContacts(publicKeys, wallet: wallet) - await PrivatePaykitService.shared.startInitialLinkBurst( - for: publicKeys, - savedPublicKeys: publicKeys, - wallet: wallet, - reason: "contact sync" - ) - await refreshIncomingPaykitPaymentRequests() + await refreshIncomingPaykitPaymentRequests(forceFresh: true) } } - .onReceive(PrivatePaykitService.initialLinkBurstStartedPublisher) { - initialPaykitSyncGeneration += 1 - } .onReceive(PaykitPaymentProofService.proofStateChangedPublisher) { - Task { await refreshIncomingPaykitPaymentRequests() } + Task { await refreshIncomingPaykitPaymentRequests(mode: .stored, forceFresh: true) } } .onReceive(PaykitPaymentProofService.onchainPaymentResolutionPublisher) { resolution in Task { await associateResolvedPaykitOnchainPayment(resolution) } @@ -853,6 +862,7 @@ struct AppScene: View { @Sendable private func setupTask() async { + configurePrivatePaykitContactResolvers() AppReset.hardwareWallets = hwWalletManager do { // Handle orphaned keychain before anything else @@ -1027,17 +1037,12 @@ 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), wallet: wallet ) - await PrivatePaykitService.shared.startInitialLinkBurst( - for: contactsManager.contacts.map(\.publicKey), - wallet: wallet, - reason: "wallet started" - ) await refreshIncomingPaykitPaymentRequests() } } else { @@ -1082,13 +1087,11 @@ 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, - savedPublicKeys: contactPublicKeys, - wallet: wallet, - reason: "foreground" + await PrivatePaykitService.shared.prepareSavedContacts( + contactPublicKeys, + wallet: wallet ) await refreshIncomingPaykitPaymentRequests() } @@ -1097,20 +1100,24 @@ 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") } } - private func refreshIncomingPaykitPaymentRequests(presentItems: Bool = true, refreshMaintenance: Bool = true) async { + private func refreshIncomingPaykitPaymentRequests( + presentItems: Bool = true, + mode: PaykitPaymentRequestRefreshMode = .full, + forceFresh: Bool = false + ) async { guard PaykitFeatureFlags.isUIEnabled, wallet.walletExists == true, pubkyProfile.authState == .authenticated @@ -1119,14 +1126,32 @@ struct AppScene: View { return } - if refreshMaintenance { + guard !Task.isCancelled else { return } + paykitPaymentRequestManager.updateSavedPublicKeys(contactsManager.contacts.map(\.publicKey)) + if mode == .full { await PaykitPaymentProofService.shared.reconcile() } - await paykitPaymentRequestManager.refresh() + guard let identity = pubkyProfile.publicKey else { return } + await paykitPaymentRequestManager.refresh(mode: mode, forceFresh: forceFresh) + guard pubkyProfile.authState == .authenticated, + PubkyPublicKeyFormat.matches(identity, pubkyProfile.publicKey) + else { return } + let contacts = paykitPaymentRequestManager.receivedPaymentContacts + do { + 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") + } if presentItems { await presentNextIncomingPaykitItem() } - if refreshMaintenance { + if mode == .full { await paykitPaymentRequestManager.refreshEligibleTargets(savedPublicKeys: contactsManager.contacts.map(\.publicKey)) } } @@ -1163,6 +1188,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 { @@ -1173,38 +1199,24 @@ 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 retryPendingPaykitEndpointRemoval() await PrivatePaykitService.shared.refreshKnownSavedContactEndpoints( wallet: wallet, reason: "payment request polling" ) } - await refreshIncomingPaykitPaymentRequests(refreshMaintenance: refreshMaintenance) - } - } - - 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() + await refreshIncomingPaykitPaymentRequests(mode: mode) } } @@ -1421,6 +1433,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 { @@ -1431,7 +1447,7 @@ struct AppScene: View { !sheets.isReplacingSheet, app.contactPaymentContext == nil else { return } - await refreshIncomingPaykitPaymentRequests(presentItems: 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() @@ -1440,7 +1456,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: SubscriptionClock.subscriptionNow()) }) { PaykitSubscriptionNotificationTargetStore.clear() @@ -1475,25 +1490,31 @@ struct AppScene: View { } private func retryPendingPaykitEndpointRemoval() async { - if PublicPaykitService.isCleanupPending { - do { - switch PublicPaykitService.pendingReconciliationMode() { - case .publishEndpoints: - try await PublicPaykitService.syncCurrentPublishedEndpoints(wallet: wallet) - case .removePublishedState: - try await PublicPaykitService.removePublishedEndpoints() - try await PublicPaykitService.syncLocalReceiverMarker() + 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() + } + 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") } } - - 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. @@ -1546,13 +1567,12 @@ 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.startInitialLinkBurst( - for: contactPublicKeys, - savedPublicKeys: contactPublicKeys, - wallet: wallet, - reason: "network restored" + await PrivatePaykitService.shared.prepareSavedContacts( + contactPublicKeys, + wallet: wallet ) } await refreshIncomingPaykitPaymentRequests() diff --git a/Bitkit/BitkitApp.swift b/Bitkit/BitkitApp.swift index 5fca54c24..a234cdafc 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 7588f92ae..96e0c7890 100644 --- a/Bitkit/MainNavView.swift +++ b/Bitkit/MainNavView.swift @@ -351,6 +351,20 @@ 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 { @@ -765,7 +779,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 6193484a7..6283c8164 100644 --- a/Bitkit/Managers/ContactsManager.swift +++ b/Bitkit/Managers/ContactsManager.swift @@ -587,11 +587,9 @@ class ContactsManager: ObservableObject { try await resolveContactProfile(publicKey: prefixedKey, includePlaceholder: true, retryTransient: true) } - let receiverPaths = try await Self.relevantReceiverPaths(for: prefixedKey) _ = try await PubkyService.saveContact( publicKey: prefixedKey, label: profile.name, - receiverPaths: receiverPaths, restorePrivateConnection: true ) @@ -603,25 +601,23 @@ class ContactsManager: ObservableObject { contacts.sort(by: Self.isOrderedByName) } - 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) - await PrivatePaykitService.shared.startInitialLinkBurst( + _ = try await PubkyService.saveContact(publicKey: prefixedKey, label: contact.profile.name) + await PrivatePaykitService.shared.refreshSavedContactEndpoints( for: [prefixedKey], savedPublicKeys: contacts.map(\.publicKey), - wallet: wallet, - reason: "contact receiver refresh" + wallet: wallet ) } 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" ) } @@ -649,7 +645,7 @@ class ContactsManager: ObservableObject { guard profilesGeneration == resolvedProfilesGeneration else { break } 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) @@ -1148,20 +1144,6 @@ class ContactsManager: ObservableObject { return true } - 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 isOrderedByName(_ lhs: PubkyContact, _ rhs: PubkyContact) -> Bool { diff --git a/Bitkit/Managers/PubkyProfileManager.swift b/Bitkit/Managers/PubkyProfileManager.swift index 48c2a2ffe..15bfd4649 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 @@ -1163,11 +1164,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) - UserDefaults.standard.removeObject(forKey: cachedProfileOwnerKey) + clearCachedIdentityMetadata() UserDefaults.standard.removeObject(forKey: profileSetupPendingKey) - ContactsManager.restoreContactProfileOverrides(nil) clearPublicPaykitSharingState() notifyAppStateBackupChanged() } @@ -1193,9 +1191,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 } @@ -1310,6 +1308,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 cachedProfileOwnerKey = "pubky_profile_owner" private static let profileSetupPendingKey = "pubky_profile_setup_pending" @@ -1341,10 +1340,20 @@ class PubkyProfileManager: ObservableObject { cachedProfileOwner = profile.publicKey UserDefaults.standard.set(profile.name, forKey: Self.cachedNameKey) UserDefaults.standard.set(profile.imageUrl, forKey: Self.cachedImageUriKey) + UserDefaults.standard.set(profile.publicKey, forKey: Self.cachedIdentityKey) UserDefaults.standard.set(profile.publicKey, forKey: Self.cachedProfileOwnerKey) } + 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) UserDefaults.standard.removeObject(forKey: cachedProfileOwnerKey) @@ -1364,6 +1373,7 @@ class PubkyProfileManager: ObservableObject { cachedProfileOwner = nil UserDefaults.standard.removeObject(forKey: Self.cachedNameKey) UserDefaults.standard.removeObject(forKey: Self.cachedImageUriKey) + UserDefaults.standard.removeObject(forKey: Self.cachedIdentityKey) UserDefaults.standard.removeObject(forKey: Self.cachedProfileOwnerKey) } @@ -1653,6 +1663,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/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/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/Models/PaykitPaymentStateBackup.swift b/Bitkit/Models/PaykitPaymentStateBackup.swift index d2897bf50..e262098c8 100644 --- a/Bitkit/Models/PaykitPaymentStateBackup.swift +++ b/Bitkit/Models/PaykitPaymentStateBackup.swift @@ -4,17 +4,16 @@ import Paykit struct PaykitPaymentStateBackup: Codable { let subscriptions: [String: Subscription] let pendingProofs: [Proof] + var acceptedOneTimeRequests: [String: [RequestID]]? = nil 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 +21,6 @@ struct PaykitPaymentStateBackup: Codable { PaykitPaymentRequest.ID( paymentRequestId: paymentRequestId, counterparty: counterparty, - counterpartyReceiverPath: counterpartyReceiverPath, billingPeriodStartsAt: billingPeriod?.startsAt ) } @@ -62,6 +60,7 @@ struct PaykitPaymentStateBackup: Codable { let identity: String let requestId: RequestID + let paymentAppId: String let paymentEndpointIdentifier: String let kind: PaykitPaymentProofKind let paymentStarted: Bool @@ -76,6 +75,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 +98,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 a1b6b41d7..64067dcc7 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 9cfaf62ff..03e5c931e 100644 --- a/Bitkit/Resources/Localization/en.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/en.lproj/Localizable.strings @@ -725,6 +725,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/BackupService.swift b/Bitkit/Services/BackupService.swift index c4d18607f..e6127b39e 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/ContactPaymentsService.swift b/Bitkit/Services/ContactPaymentsService.swift index 312c41d6e..10f61db4a 100644 --- a/Bitkit/Services/ContactPaymentsService.swift +++ b/Bitkit/Services/ContactPaymentsService.swift @@ -3,6 +3,8 @@ import Foundation enum ContactPaymentsService { static let confirmedPreferenceKey = "hasConfirmedPublicPaykitEndpoints" + @MainActor private static var isOperationActive = false + @MainActor private static var operationWaiters: [CheckedContinuation] = [] /// True while a contact payments change is still current: no newer change has started, and the Pubky session it /// started for is still the current one. Once false, it never turns true again. typealias ChangeCheck = @MainActor () -> Bool @@ -62,13 +64,8 @@ enum ContactPaymentsService { defaults.set(true, forKey: PublicPaykitService.onchainPaymentOptionEnabledKey) } - /// Turns contact payments on or off for the signed-in Pubky session. Private endpoints are prepared for the saved - /// contacts, so it first waits for the first contacts load. The latest change wins: a change from General Settings or - /// Pay Contacts can outlive its screen, so a newer one can start while it still runs. Once a newer change starts, or - /// that session ends or changes, the change stops and returns quietly: it writes no preference, publishing flag or - /// cleanup mark and restores nothing, and its endpoint publications and removals check that it is still current under - /// the locks that the newer change's endpoint writes and sign-out's endpoint removal also take, so they never land - /// after those. Returns true once the change was applied, and false when it stopped. + /// Applies the latest sharing preference for the current session. Enabling waits for saved contacts; + /// disabling starts cleanup immediately. Returns false if superseded by another change or session. @discardableResult @MainActor static func setEnabled( @@ -83,7 +80,7 @@ enum ContactPaymentsService { guard let session = pubkyProfile.currentSession else { return false } let isChangeCurrent: ChangeCheck = { Self.latestChange == change && pubkyProfile.currentSession == session } let canUsePrivatePayments = pubkyProfile.hasLocalSecretKeyForCurrentProfile - if canUsePrivatePayments { + if enabled, canUsePrivatePayments { do { try await contactsManager.loadContactsIfNeeded(for: session.publicKey) } catch { @@ -106,14 +103,10 @@ enum ContactPaymentsService { guard isChangeCurrent() else { return false } throw error } - // The change above stops quietly once it is no longer current, and it never becomes current again, so this also - // reports that stop. return isChangeCurrent() } - /// Once `isChangeCurrent` is false, the preference, flags and cleanup marks belong to the newer change or the session - /// change that started, so it is checked right before each write here, with nothing suspending in between, and the - /// endpoint publications and removals check it again under their locks. + /// Serializes sharing changes. Endpoint operations recheck ownership under their publication locks. @MainActor static func setEnabled( _ enabled: Bool, @@ -123,8 +116,20 @@ enum ContactPaymentsService { defaults: UserDefaults = .standard, isChangeCurrent: @escaping ChangeCheck = { true } ) async throws { + await acquireOperation() + defer { releaseOperation() } + try Task.checkCancellation() + guard isChangeCurrent() else { return } + enableAllPaymentOptions(defaults: defaults) + if !enabled { + if let error = await disable(operations: operations, defaults: defaults, isChangeCurrent: isChangeCurrent) { + throw error + } + return + } + let previousState = StoredState( sharesPublicEndpoints: defaults.bool(forKey: PublicPaykitService.publishingEnabledKey), sharesPrivateEndpoints: defaults.bool(forKey: PrivatePaykitService.publishingEnabledKey), @@ -134,17 +139,13 @@ enum ContactPaymentsService { ) do { - if enabled { - try await enable( - contactPublicKeys: contactPublicKeys, - canUsePrivatePayments: canUsePrivatePayments, - operations: operations, - defaults: defaults, - isChangeCurrent: isChangeCurrent - ) - } else { - try await disable(operations: operations, defaults: defaults, isChangeCurrent: isChangeCurrent) - } + try await enable( + contactPublicKeys: contactPublicKeys, + canUsePrivatePayments: canUsePrivatePayments, + operations: operations, + defaults: defaults, + isChangeCurrent: isChangeCurrent + ) } catch { await restore( previousState, @@ -158,6 +159,98 @@ 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 } + 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], @@ -174,27 +267,34 @@ enum ContactPaymentsService { defaults.set(canUsePrivatePayments, forKey: PrivatePaykitService.publishingEnabledKey) defaults.set(true, forKey: confirmedPreferenceKey) + try await operations.syncPublicEndpoints(true, isChangeCurrent) + + guard isChangeCurrent() else { return } + operations.setPublicCleanupPending(false) + if canUsePrivatePayments { + operations.setPrivateCleanupPending(false) + } if canUsePrivatePayments, let error = await operations.preparePrivateEndpoints( contactPublicKeys, - true, + false, isChangeCurrent ) { throw error } - - try await operations.syncPublicEndpoints(true, isChangeCurrent) - - guard isChangeCurrent() else { return } - operations.setPublicCleanupPending(false) - if canUsePrivatePayments { - operations.setPrivateCleanupPending(false) - } } @MainActor - private static func disable(operations: Operations, defaults: UserDefaults, isChangeCurrent: @escaping ChangeCheck) async throws { + private static func disable(operations: Operations, defaults: UserDefaults, isChangeCurrent: @escaping ChangeCheck) async -> Error? { + guard isChangeCurrent() else { return nil } + 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(isChangeCurrent) if isChangeCurrent() { @@ -203,10 +303,7 @@ enum ContactPaymentsService { } catch { if isChangeCurrent() { operations.setPrivateCleanupPending(true) - Logger.warn( - "Deferred private Paykit endpoint cleanup after disable failed: \(error)", - context: "ContactPaymentsService" - ) + cleanupError = error } } @@ -218,14 +315,10 @@ enum ContactPaymentsService { } catch { if isChangeCurrent() { operations.setPublicCleanupPending(true) + cleanupError = cleanupError ?? error } - throw error } - - guard isChangeCurrent() else { return } - defaults.set(false, forKey: PublicPaykitService.publishingEnabledKey) - defaults.set(false, forKey: PrivatePaykitService.publishingEnabledKey) - defaults.set(true, forKey: confirmedPreferenceKey) + return isChangeCurrent() ? cleanupError : nil } @MainActor @@ -243,6 +336,20 @@ enum ContactPaymentsService { defaults.set(restoresPrivateEndpoints, forKey: PrivatePaykitService.publishingEnabledKey) defaults.set(state.hasConfirmedPreference, forKey: confirmedPreferenceKey) + if !restoresPrivateEndpoints { + do { + try await operations.removePrivateEndpoints(isChangeCurrent) + if isChangeCurrent() { + operations.setPrivateCleanupPending(state.privateCleanupPending) + } + } catch { + if isChangeCurrent() { + operations.setPrivateCleanupPending(true) + Logger.warn("Failed to clean up private contact payments: \(error)", context: "ContactPaymentsService") + } + } + } + do { try await operations.syncPublicEndpoints(state.sharesPublicEndpoints, isChangeCurrent) if isChangeCurrent() { @@ -258,7 +365,7 @@ enum ContactPaymentsService { if restoresPrivateEndpoints { if let error = await operations.preparePrivateEndpoints( contactPublicKeys, - true, + false, isChangeCurrent ) { if isChangeCurrent() { @@ -268,18 +375,6 @@ enum ContactPaymentsService { } else if isChangeCurrent() { operations.setPrivateCleanupPending(state.privateCleanupPending) } - } else { - do { - try await operations.removePrivateEndpoints(isChangeCurrent) - if isChangeCurrent() { - operations.setPrivateCleanupPending(state.privateCleanupPending) - } - } catch { - if isChangeCurrent() { - operations.setPrivateCleanupPending(true) - Logger.warn("Failed to clean up private contact payments: \(error)", context: "ContactPaymentsService") - } - } } } } diff --git a/Bitkit/Services/CoreService.swift b/Bitkit/Services/CoreService.swift index 831b7ba9d..a3cf80b79 100644 --- a/Bitkit/Services/CoreService.swift +++ b/Bitkit/Services/CoreService.swift @@ -36,6 +36,39 @@ 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] ?? []) + 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 { + guard let separator = scopedId.lastIndex(of: ":") else { continue } + let walletId = String(scopedId[..() @@ -56,14 +89,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 @@ -638,8 +671,15 @@ class ActivityService { Logger.error("Failed to find address for txid \(txid): \(error)", context: "CoreService.processOnchainPayment") } - if contact == nil { - contact = await privatePaykitContactPublicKey(forReservedAddress: address) + if contact == nil, !isContactDetached(activityId: payment.id, walletId: WalletScope.default) { + let details: BitkitCore.TransactionDetails? = if let transactionDetails { + transactionDetails + } else { + await fetchTransactionDetails(txid: txid) + } + if let details { + contact = await privateOnchainAddressContactResolver?(address, details.outputs.compactMap(\.scriptpubkeyAddress)) + } } } @@ -680,7 +720,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)) } @@ -922,21 +973,27 @@ 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) } - 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. @@ -1140,16 +1197,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? { @@ -1404,6 +1457,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 } @@ -1770,9 +1824,31 @@ 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 + -> [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 + -> [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 + self.deriveAddresses = deriveAddresses + } /// Runs the batch address search at most one at a time. Enqueues if a search is already in progress. func runAddressSearch( @@ -1811,13 +1887,13 @@ private 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) { @@ -1830,47 +1906,66 @@ 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) + } + // 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 count = min(batchSize, endIndex - index) + let addresses: [LightningService.AddressDerivationInfo] + do { + addresses = try await deriveAddresses(account, keychain, index, count) + } 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(where: { $0.address == currentWalletAddress }) { + currentAddressBatch = index + } + if let match = findMatch(in: addresses) { + defaults.set(Int(match.index), forKey: key) + return match.address + } + 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 += count } } 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/PaykitPaymentProofService.swift b/Bitkit/Services/PaykitPaymentProofService.swift index a2ec54aa9..7ec70dec6 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,23 @@ actor PaykitPaymentProofService { func completeOnchainPayment( _ request: PaykitPaymentRequest, txid: String, + 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, + paymentAppId: paymentAppId, paymentEndpointIdentifier: paymentEndpointIdentifier, kind: .onchain ) @@ -485,7 +503,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 +562,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 +587,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 +644,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 +678,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 +699,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 f5df8f0e5..bb7a03602 100644 --- a/Bitkit/Services/PaykitPaymentRequestService.swift +++ b/Bitkit/Services/PaykitPaymentRequestService.swift @@ -1,3 +1,4 @@ +import Combine import Foundation import ImageIO import LDKNode @@ -38,25 +39,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 +74,6 @@ struct PaykitPaymentRequest: Identifiable, Hashable { ID( paymentRequestId: paymentRequestId, counterparty: counterparty, - counterpartyReceiverPath: counterpartyReceiverPath, billingPeriodStartsAt: billingPeriod?.startsAt ) } @@ -169,7 +165,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 +208,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 +229,6 @@ struct PaykitPaymentRequest: Identifiable, Hashable { PaykitPaymentRequest( paymentRequestId: paymentRequestId, counterparty: counterparty, - counterpartyReceiverPath: counterpartyReceiverPath, amountValue: amountValue, amountSats: amountSats, note: note, @@ -259,7 +252,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 +268,6 @@ struct PaykitPaymentRequest: Identifiable, Hashable { private init( paymentRequestId: String, counterparty: String, - counterpartyReceiverPath: String, amountValue: String, amountSats: UInt64, note: String?, @@ -291,7 +282,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 +316,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,16 +372,16 @@ struct PaykitPaymentRequest: Identifiable, Hashable { struct PaykitPaymentRequestTarget: Identifiable, Equatable, Hashable { let publicKey: String - let receiverPath: String var id: String { - "\(publicKey)|\(receiverPath)" + publicKey } } struct PaykitPaymentRequestTargetDiscovery: Equatable { let targets: [PaykitPaymentRequestTarget] let isComplete: Bool + let capabilityCheckedPublicKeys: Set } struct PaykitPaymentRequestDraft: Hashable { @@ -425,15 +414,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 } } @@ -499,33 +491,33 @@ 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] + 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 paymentRequestReceiverPaths(publicKey: String, priority: PaykitPublicReadPriority) async throws -> [String] + func canReceivePaymentRequests(publicKey: String, priority: PaykitPublicReadPriority) 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 @@ -533,6 +525,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 @@ -559,16 +557,21 @@ struct PaykitPaymentRequestService { self.logWarning = logWarning } - func synchronize() async throws -> PaykitPaymentRequestSnapshot { - try await processPendingMessages() - let intakeReports = try await sdk.receivePrivateMessagesFromLinkedPeers() - logIntakeFailures(intakeReports) + 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) + } 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] = [] @@ -602,7 +605,8 @@ struct PaykitPaymentRequestService { return PaykitPaymentRequestSnapshot( incoming: incoming, history: history, - subscriptions: subscriptions + subscriptions: subscriptions, + receivedPaymentContacts: PaykitReceivedPaymentContacts(records: sharedRecords) ) } @@ -610,7 +614,6 @@ struct PaykitPaymentRequestService { try await sdk.paymentRequests().first { $0.paymentRequestId == request.paymentRequestId && $0.counterparty == request.counterparty && - $0.counterpartyReceiverPath == request.counterpartyReceiverPath && $0.localRole == .payer }?.state } @@ -620,18 +623,17 @@ struct PaykitPaymentRequestService { } /// Keeps a previously eligible target when its capability lookup fails, so a transient transport error does not hide it. - /// Checks run one at a time on the interactive read lane, for a user waiting on them; the background refresh of every - /// saved contact passes `.bulk`. + /// Foreground lookups use the interactive read lane; background refreshes pass `.bulk`. func discoverEligibleTargets( savedPublicKeys: [String], expectedIdentity: String, previousTargets: [PaykitPaymentRequestTarget] = [], priority: PaykitPublicReadPriority = .interactive ) 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.liveSessionAvailable, + identityStatus.capability == .privateLinkCapable, PubkyPublicKeyFormat.matches(identityStatus.publicKey, expectedIdentity) else { return unavailable } var seenSavedKeys = Set() @@ -639,42 +641,61 @@ 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) - } - - var targets: [PaykitPaymentRequestTarget] = [] - var isComplete = true - for publicKey in savedKeys { - guard let linkedPaths = linkedPathsByPublicKey[publicKey] else { continue } - let capablePaths: [String] + 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 do { try Task.checkCancellation() - capablePaths = try await sdk.paymentRequestReceiverPaths(publicKey: publicKey, priority: priority) + let canReceive = try await sdk.canReceivePaymentRequests(publicKey: publicKey, priority: priority) + 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: { - 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)) } - return PaykitPaymentRequestTargetDiscovery(targets: targets, isComplete: isComplete) + return PaykitPaymentRequestTargetDiscovery( + targets: targets, isComplete: isComplete, capabilityCheckedPublicKeys: capabilityCheckedPublicKeys + ) } func propose( @@ -691,7 +712,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]) @@ -705,18 +728,19 @@ 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 ) - let reports = await (try? processPendingMessages()) ?? [] - 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, @@ -747,7 +771,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 } @@ -766,7 +792,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() @@ -786,12 +812,10 @@ struct PaykitPaymentRequestService { try PaykitSubscriptionProposal.validate(terms) let record = try await sdk.proposePaymentRequest( counterparty: target.publicKey, - counterpartyReceiverPath: target.receiverPath, terms: terms, expectedIdentity: expectedIdentity ) - let reports = await (try? processPendingMessages()) ?? [] - 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 { @@ -836,6 +860,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) @@ -844,8 +870,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 } } @@ -856,12 +881,20 @@ struct PaykitPaymentRequestService { _ = try await sdk.acceptPaymentRequest( counterparty: request.counterparty, - counterpartyReceiverPath: request.counterpartyReceiverPath, paymentRequestId: request.paymentRequestId ) _ = try? await processPendingMessages() } + func claimForPayment(_ request: PaykitPaymentRequest) async throws { + 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 { guard !request.isExpired(at: now()) else { throw PaykitPaymentRequestError.requestExpired @@ -869,7 +902,6 @@ struct PaykitPaymentRequestService { _ = try await sdk.rejectPaymentRequest( counterparty: request.counterparty, - counterpartyReceiverPath: request.counterpartyReceiverPath, paymentRequestId: request.paymentRequestId, reason: nil ) @@ -879,7 +911,6 @@ struct PaykitPaymentRequestService { func cancel(_ request: PaykitPaymentRequest) async throws { _ = try await sdk.cancelPaymentRequest( counterparty: request.counterparty, - counterpartyReceiverPath: request.counterpartyReceiverPath, paymentRequestId: request.paymentRequestId, reason: nil ) @@ -893,7 +924,6 @@ struct PaykitPaymentRequestService { let record = try await sdk.acceptPaymentRequest( counterparty: subscription.counterparty, - counterpartyReceiverPath: subscription.counterpartyReceiverPath, paymentRequestId: subscription.paymentRequestId ) _ = try? await processPendingMessages() @@ -910,7 +940,6 @@ struct PaykitPaymentRequestService { let record = try await sdk.cancelPaymentRequest( counterparty: subscription.counterparty, - counterpartyReceiverPath: subscription.counterpartyReceiverPath, paymentRequestId: subscription.paymentRequestId, reason: nil ) @@ -956,9 +985,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( @@ -983,16 +1022,47 @@ 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.counterpartyReceiverPath == record.counterpartyReceiverPath && report.report?.sent.contains(messageId) == true + }) { + 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) + 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 } } @@ -1006,7 +1076,7 @@ struct PaykitPaymentRequestService { } } -protocol PaykitPaymentRequestPresentationStoring { +protocol PaykitPaymentRequestIdStoring { func load(identity: String) throws -> Set func save(_ ids: Set, identity: String) throws } @@ -1063,13 +1133,20 @@ enum PaykitPaymentRequestPresentationCoordinator { } } -struct PaykitPaymentRequestPresentationStore: PaykitPaymentRequestPresentationStoring { +struct PaykitPaymentRequestIdStore: PaykitPaymentRequestIdStoring { + private static let backupChanged = PassthroughSubject() + static var walletBackupDataChangedPublisher: AnyPublisher { + backupChanged.eraseToAnyPublisher() + } + + 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] ?? []) @@ -1077,13 +1154,28 @@ 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)) + 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)) } } @@ -1097,6 +1189,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? @@ -1107,7 +1200,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] @@ -1123,6 +1217,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] = [:] @@ -1131,7 +1226,7 @@ final class PaykitPaymentRequestManager { private var expiredRequestedPresentations: [PaykitPaymentRequest] = [] private var unavailableRequestedPresentations: [PaykitPaymentRequest] = [] private var isPresentingRequests = false - private var refreshTask: Task? + private var refreshTask: (mode: PaykitPaymentRequestRefreshMode, task: Task)? private var expirationTask: Task? private var presentationRetryTask: Task? private var refreshGeneration = 0 @@ -1151,6 +1246,7 @@ final class PaykitPaymentRequestManager { private var subscriptionClockOffset: TimeInterval { subscriptionNow().timeIntervalSince(now()) } + private var presentedSubscriptionProposalIds: Set = [] private var dismissedSubscriptionPaymentIds: Set = [] private var persistedSubscriptionState = PaykitSubscriptionState() @@ -1165,7 +1261,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 @@ -1194,6 +1291,7 @@ final class PaykitPaymentRequestManager { let subscriptionNow = subscriptionNow ?? { SubscriptionClock.subscriptionDate(from: now()) } self.service = service ?? PaykitPaymentRequestService(now: now, subscriptionNow: subscriptionNow, logWarning: logWarning) self.presentationStore = presentationStore + self.acceptanceStore = acceptanceStore self.subscriptionStateStore = subscriptionStateStore self.subscriptionNotificationScheduler = subscriptionNotificationScheduler self.completedPaymentProofKinds = completedPaymentProofKinds @@ -1212,6 +1310,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 @@ -1238,11 +1343,21 @@ final class PaykitPaymentRequestManager { activeIdentity = normalizedIdentity } + func updateSavedPublicKeys(_ publicKeys: [String]) { + 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) } + } + } + 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 @@ -1257,7 +1372,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 } @@ -1270,7 +1385,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 { @@ -1308,7 +1424,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) @@ -1346,8 +1463,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 reads linked peers under the SDK lock and the contact's capabilities on the interactive read lane, 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 @@ -1421,7 +1537,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 } @@ -1481,16 +1597,13 @@ final class PaykitPaymentRequestManager { return subscription } - func refresh() async { - await refresh(excludingProtectedRequestId: nil) + 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.value - } - await refresh() + await refresh(forceFresh: true) } func synchronizeSubscriptionNotifications(enabled: Bool) async { @@ -1506,9 +1619,17 @@ final class PaykitPaymentRequestManager { ) } - private func refresh(excludingProtectedRequestId: PaykitPaymentRequest.ID?) async { + private func refresh( + excludingProtectedRequestId: PaykitPaymentRequest.ID?, + mode: PaykitPaymentRequestRefreshMode = .full, + forceFresh: Bool = false + ) async { if let refreshTask { - await refreshTask.value + let generation = stateGeneration + await refreshTask.task.value + if forceFresh || mode > refreshTask.mode, generation == stateGeneration, !Task.isCancelled { + await refresh(excludingProtectedRequestId: excludingProtectedRequestId, mode: mode) + } return } @@ -1516,20 +1637,33 @@ 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, + mode: mode + ) + guard generation == refreshGeneration else { return } + refreshTask = nil } - refreshTask = task + refreshTask = (mode, task) await task.value - - guard generation == refreshGeneration else { return } - refreshTask = nil } 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) + if error as? PaykitPaymentRequestError == .requestUnavailable { + approvedPaymentRequestIds.remove(request.id) + } throw error } } @@ -1538,6 +1672,8 @@ final class PaykitPaymentRequestManager { _ request: PaykitPaymentRequest, 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) @@ -1549,10 +1685,38 @@ 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) + 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) + 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, PaykitError.ConcurrentUpdate, PaykitError.SharedStateBusy: + break + default: + 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 + } } } } catch is CancellationError { @@ -1755,9 +1919,11 @@ final class PaykitPaymentRequestManager { pendingRequests = [] historyRequests = [] subscriptions = [] + receivedPaymentContacts = PaykitReceivedPaymentContacts() eligibleTargets = [] processingRequestIds = [] approvedPaymentRequestIds = [] + acceptedRequestIds = [] activeIdentity = nil savedPublicKeys = [] presentedRequestIds = [] @@ -1829,12 +1995,17 @@ 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.lifecycleState == .activeRecurring && !$0.paidPeriods.contains(billingPeriod) + } } 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 @@ -1890,6 +2061,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 }) { @@ -1997,10 +2169,11 @@ final class PaykitPaymentRequestManager { private func performRefresh( generation: Int, - excludingProtectedRequestId: PaykitPaymentRequest.ID? + excludingProtectedRequestId: PaykitPaymentRequest.ID?, + mode: PaykitPaymentRequestRefreshMode ) async { do { - let snapshot = try await service.synchronize() + 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) @@ -2009,10 +2182,12 @@ final class PaykitPaymentRequestManager { guard generation == refreshGeneration, PubkyPublicKeyFormat.matches(self.activeIdentity, activeIdentity) else { return } + pruneAcceptedRequestIds(snapshot.history, identity: activeIdentity) let refreshDate = now() let subscriptionDate = subscriptionNow() let handledRequestedExpirationId = recordRequestedPresentationExpiration(at: refreshDate) let previousPending = pendingRequests + receivedPaymentContacts = snapshot.receivedPaymentContacts subscriptions = snapshot.subscriptions.map { $0.withExpiredLifecycle(at: subscriptionDate) } let visibleProposalIds = Set(subscriptions.filter { $0.isPayer && $0.isProposalVisible(at: subscriptionDate) @@ -2064,7 +2239,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) } @@ -2219,10 +2395,30 @@ final class PaykitPaymentRequestManager { private func invalidateRefresh() { refreshGeneration += 1 - refreshTask?.cancel() + refreshTask?.task.cancel() 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 new file mode 100644 index 000000000..059738f0c --- /dev/null +++ b/Bitkit/Services/PaykitReceivedPaymentContacts.swift @@ -0,0 +1,216 @@ +import BitkitCore +import Combine +import Foundation +import LDKNode +import os +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 { + 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[address.address, 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 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, + let contact = contact(receivingAddress: payment.address, outputAddresses: 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 + } + } +} + +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, + identity: String, + cache: PaykitReceivedPaymentBackfillCache, + reservations: PrivatePaykitAddressReservationStore = .shared, + isCurrent: @Sendable () async -> Bool + ) async throws { + 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 + var complete = true + 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 { + case let .onchain(payment): + guard payment.contact == nil 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 !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 + } + 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/PaykitSubscription.swift b/Bitkit/Services/PaykitSubscription.swift index c35a9d010..1a310de1e 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 ) } @@ -576,7 +573,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)) } @@ -844,7 +840,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), ] let trigger = UNCalendarNotificationTrigger(dateMatching: components, repeats: false) @@ -882,14 +877,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/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/PrivatePaykitAddressReservationStore.swift b/Bitkit/Services/PrivatePaykitAddressReservationStore.swift index 6a60c9732..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 Self.publicKey(fromAssignmentKey: 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 } @@ -101,14 +99,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 +154,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 +180,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 +299,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 +312,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 +382,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..604ce0e46 100644 --- a/Bitkit/Services/PrivatePaykitService+Backup.swift +++ b/Bitkit/Services/PrivatePaykitService+Backup.swift @@ -4,15 +4,13 @@ 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( 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,10 +21,13 @@ extension PrivatePaykitService { } func restoreBackup(_ backup: String?) async throws { - initialLinkBurstTask?.cancel() - initialLinkBurstTask = nil - initialLinkBurstPublicKeys.removeAll() - initialLinkBurstGeneration += 1 + 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. + try Keychain.upsert(key: .paykitRecoveryBackup, data: Data(decoded.sdkState.utf8)) + } pendingMessageDrainRetryTask?.cancel() pendingMessageDrainRetryTask = nil pendingMessageDrainRetryKeys.removeAll() @@ -34,11 +35,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 053f5d6b2..42880add0 100644 --- a/Bitkit/Services/PrivatePaykitService+Contacts.swift +++ b/Bitkit/Services/PrivatePaykitService+Contacts.swift @@ -15,12 +15,35 @@ 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 -> LinkedPeerState + 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: (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) } + ) + } + + struct EndpointCleanupOperations { + let linkedPeers: () async throws -> [LinkedPeerRecord] + let clearPaymentLists: (_ publicKeys: [String]) async throws -> PrivatePaymentListDeliveryReport? + let drainMessages: (_ publicKeys: [String]) async -> Void + let pendingDrainKeys: (_ publicKeys: [String]) async -> Set + let syncApp: () async throws -> Void } @discardableResult @@ -30,7 +53,13 @@ extension PrivatePaykitService { requireImmediatePublication: Bool = false, isSessionCurrent: (@MainActor () -> Bool)? = nil ) async -> Error? { - await prepareSavedContacts( + if let isSessionCurrent, await !isSessionCurrent() { return nil } + if !requireImmediatePublication { + let keys = rememberSavedContacts(publicKeys, replacing: true) + scheduleContactPreparation(keys, wallet: wallet, isSessionCurrent: isSessionCurrent) + return nil + } + return await prepareSavedContacts( publicKeys, publicationUnavailableReason: privateEndpointPublicationUnavailabilityReason(wallet: wallet), prepareLinks: { await self.prepareRelevantPrivateLinksIfAvailable($0, reason: "prepare") }, @@ -81,141 +110,204 @@ 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 - ) + 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() } - func startInitialLinkBurst( - for publicKeys: [String], - savedPublicKeys: [String]? = nil, + private func scheduleContactPreparation( + _ publicKeys: [String], wallet: WalletViewModel, - reason: String + forceRefreshLightning: Bool = false, + isSessionCurrent: (@MainActor () -> Bool)? = nil ) { - if let savedPublicKeys { - _ = rememberSavedContacts(savedPublicKeys + publicKeys, replacing: false) + scheduleContactPreparation( + publicKeys, + forceRefreshLightning: forceRefreshLightning, + requeueActive: isSessionCurrent != nil + ) { 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", + isSessionCurrent: isSessionCurrent + ) } + } - 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 - 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" - ) + func scheduleContactPreparation( + _ publicKeys: [String], + forceRefreshLightning: Bool = false, + requeueActive: Bool = false, + operation: @escaping ([String], Bool) async -> Void + ) { + let keys = publicKeys.filter { requeueActive || forceRefreshLightning || !activePreparationKeys.contains($0) } + guard !keys.isEmpty else { return } + pendingPreparationKeys.formUnion(keys) + pendingPreparationOperation = operation + pendingForceRefreshLightning = pendingForceRefreshLightning || forceRefreshLightning + guard preparationTask == nil, !pendingPreparationKeys.isEmpty else { return } + preparationTask = Task { + defer { + activePreparationKeys.removeAll() + pendingPreparationOperation = nil + preparationTask = nil + } + while !pendingPreparationKeys.isEmpty, let operation = pendingPreparationOperation { + let keys = pendingPreparationKeys.intersection(knownSavedContactKeys) + let forceRefresh = pendingForceRefreshLightning + pendingPreparationKeys.removeAll() + pendingPreparationOperation = nil + pendingForceRefreshLightning = false + activePreparationKeys = keys + await operation(Array(keys).sorted(), forceRefresh) + activePreparationKeys.removeAll() } - - guard generation == initialLinkBurstGeneration else { return } - initialLinkBurstTask = nil - initialLinkBurstPublicKeys.removeAll() } } + func invalidateContactPreparation() { + preparationGeneration += 1 + pendingPreparationKeys.removeAll() + pendingPreparationOperation = nil + activePreparationKeys.removeAll() + pendingForceRefreshLightning = false + pendingMessageDrainRetryTask?.cancel() + pendingMessageDrainRetryTask = nil + pendingMessageDrainRetryKeys.removeAll() + pendingMessageDrainRetryGeneration += 1 + } + @discardableResult func refreshSavedContactEndpointsReturningError( for publicKeys: [String], wallet: WalletViewModel, forceRefreshLightning: Bool, requireImmediatePublication: Bool, - reason: String = "refresh" + reason: String = "refresh", + isSessionCurrent: (@MainActor () -> Bool)? = nil ) async -> Error? { - guard await canPublishPrivateEndpoints(wallet: wallet) else { + if let isSessionCurrent, await !isSessionCurrent() { return nil } + let generation = preparationGeneration + let operations = endpointPublicationOperations(wallet: wallet, forceRefreshLightning: forceRefreshLightning) + guard await operations.canPublish() 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, - wallet: wallet, reason: reason, - forceRefreshLightning: forceRefreshLightning, - requireImmediatePublication: requireImmediatePublication + requireImmediatePublication: requireImmediatePublication, + isSessionCurrent: isSessionCurrent, + operations: operations ) } - func removePublishedEndpoints(isSessionCurrent: (@MainActor () -> Bool)? = nil) async throws { - let publicKeys = Set(knownSavedContactKeys) - .union(state.contacts.keys) - .union(Self.pendingDeletedContactCleanupKeys()) - try await removePublishedEndpoints(for: Array(publicKeys), isSessionCurrent: isSessionCurrent) + func removePublishedEndpoints(for publicKeys: [String]? = nil, isSessionCurrent: (@MainActor () -> Bool)? = nil) async throws { + try await removePublishedEndpoints(for: publicKeys, isSessionCurrent: isSessionCurrent, operations: EndpointCleanupOperations( + linkedPeers: { try await PaykitSdkService.shared.linkedPeers() }, + 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() } + )) } - /// A newer contact payments change starts before it publishes endpoints under the publication lock, so checking - /// `isSessionCurrent` once the lock is held stops an older change's removal from clearing what that change published. - func removePublishedEndpoints(for publicKeys: [String], isSessionCurrent: (@MainActor () -> Bool)? = nil) async throws { - let publicKeys = normalizedSavedContactKeys(publicKeys) - guard !publicKeys.isEmpty else { return } + func removePublishedEndpoints( + for publicKeys: [String]? = nil, + isSessionCurrent: (@MainActor () -> Bool)? = nil, + operations: EndpointCleanupOperations + ) async throws { + if let isSessionCurrent, await !isSessionCurrent() { throw PubkyServiceError.sessionNotActive } + let publicKeys = publicKeys.map { normalizedSavedContactKeys($0) } + guard publicKeys?.isEmpty != true else { return } + if publicKeys == nil { invalidateContactPreparation() } - try await withPublicationLock { - if let isSessionCurrent, await !isSessionCurrent() { - throw PubkyServiceError.sessionNotActive + do { + try await withPublicationLock { + if let isSessionCurrent, await !isSessionCurrent() { throw PubkyServiceError.sessionNotActive } + try await removePublishedEndpointsLocked(for: publicKeys, operations: operations) } - try await removePublishedEndpointsLocked(for: publicKeys) - } - } - - private func removePublishedEndpointsLocked(for publicKeys: [String]) async throws { + } catch { + if await isSessionCurrent?() != false { PublicPaykitService.setCleanupPending(true) } + throw error + } + } + + private func removePublishedEndpointsLocked(for publicKeys: [String]?, operations: EndpointCleanupOperations) async throws { + let peers = try await operations.linkedPeers() + let linkedPublicKeys = Set(peers + .filter { $0.state == .linked || $0.state == .linking || $0.state == .recoveryRequired } + .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 (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]() + if !cleanupKeys.isEmpty { + do { + 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 } } + } catch { + Logger.warn( + "Failed to clear private Paykit endpoints: \(PaykitResolutionFailureDiagnostics.reason(for: error))", + context: "PrivatePaykit" + ) + failedPublicKeys.formUnion(cleanupKeys) + firstError = error } } if !clearedRetryKeys.isEmpty { - await drainPendingPrivateMessages(reason: "cleanup", advancing: clearedRetryKeys) - let pendingRetryKeys = await pendingPrivateMessageDrainKeys(clearedRetryKeys) + var pendingRetryKeys = await operations.pendingDrainKeys(clearedRetryKeys) + if !pendingRetryKeys.isEmpty { + await operations.drainMessages(Array(pendingRetryKeys)) + pendingRetryKeys = await operations.pendingDrainKeys(clearedRetryKeys) + } if !pendingRetryKeys.isEmpty { - failedPublicKeys.formUnion(pendingRetryKeys.map(\.publicKey)) + Logger.warn( + "Private Paykit endpoint withdrawal remains pending for \(pendingRetryKeys.map(PubkyPublicKeyFormat.redacted))", + context: "PrivatePaykit" + ) + failedPublicKeys.formUnion(pendingRetryKeys) firstError = firstError ?? PrivatePaykitError.privateUnavailable } } @@ -243,11 +335,19 @@ extension PrivatePaykitService { if let firstError { throw firstError } + try await operations.syncApp() + } + + 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) + unavailableLinkRetryAt[normalizedKey] = nil Self.markDeletedContactCleanupPending([normalizedKey]) do { try await removePublishedEndpoints(for: [normalizedKey]) @@ -264,6 +364,7 @@ extension PrivatePaykitService { let normalizedKeys = normalizedSavedContactKeys(publicKeys) for publicKey in normalizedKeys { knownSavedContactKeys.remove(publicKey) + unavailableLinkRetryAt[publicKey] = nil } Self.markDeletedContactCleanupPending(normalizedKeys) do { @@ -277,8 +378,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 } @@ -309,17 +409,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 } @@ -328,15 +428,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) } @@ -353,6 +448,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, @@ -374,60 +479,21 @@ extension PrivatePaykitService { ) } - /// Sign-out changes the current Pubky session, and a newer contact payments change starts, before either removes - /// private endpoints under the publication lock, so checking `isSessionCurrent` once the lock is held stops a publish - /// that would otherwise write them back after that removal. - func syncLocalEndpointPublication( - for publicKeys: [String], - reason: String, - requireImmediatePublication: Bool, - isSessionCurrent: (@MainActor () -> Bool)? = nil, - operations: EndpointPublicationOperations - ) async -> Error? { - do { - return try await withPublicationLock { - if let isSessionCurrent, await !isSessionCurrent() { - throw PubkyServiceError.sessionNotActive - } - return await syncLocalEndpointPublicationLocked( - for: publicKeys, - reason: reason, - requireImmediatePublication: requireImmediatePublication, - operations: operations - ) - } - } catch { - return requireImmediatePublication ? error : nil - } - } - private func endpointPublicationOperations( wallet: WalletViewModel, forceRefreshLightning: Bool ) -> EndpointPublicationOperations { - EndpointPublicationOperations( + if let publicationOperations { return publicationOperations } + return EndpointPublicationOperations( 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).state }, - buildEndpoints: { publicKey, receiverPath in + buildEndpoints: { publicKey in try await self.buildLocalEndpoints( for: publicKey, - receiverPath: receiverPath, wallet: wallet, forceRefreshLightning: forceRefreshLightning ) @@ -437,116 +503,108 @@ extension PrivatePaykitService { updates, clearUnlistedLinkedPeers: false ) - } + }, + linkedPeers: { try await PaykitSdkService.shared.linkedPeers() }, + canPublish: { await self.canPublishPrivateEndpoints(wallet: wallet) } ) } - private func syncLocalEndpointPublicationLocked( + func syncLocalEndpointPublication( for publicKeys: [String], reason: String, requireImmediatePublication: Bool, + isSessionCurrent: (@MainActor () -> Bool)? = nil, operations: EndpointPublicationOperations ) async -> Error? { 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 } - let linkedReceiverPathsSnapshot = await operations.linkedReceiverPaths(reason) - var firstError = linkedReceiverPathsSnapshot.error - var updates = [PrivatePaymentListReservationUpdateInput]() - var linkRetryKeys = [PrivateMessageDrainRetryKey]() + var firstError: Error? + 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 { - let receiverPaths: [String] + 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 { - receiverPaths = try await operations.receiverPaths(publicKey) - } catch { - firstError = firstError ?? error - Logger.warn( - "Failed to read saved Paykit receivers for \(PubkyPublicKeyFormat.redacted(publicKey)) during \(reason): \(error)", - context: "PrivatePaykit" - ) + 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 - } - let receiverPathSelection: PrivateReceiverPathSelection - do { - receiverPathSelection = try await operations.receiverPathSelection(publicKey, receiverPaths) } catch { - firstError = firstError ?? error + if case PaykitError.Transport = error { + 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 select private Paykit receiver paths for \(PubkyPublicKeyFormat.redacted(publicKey)) during \(reason): \(error)", + "Failed to prepare private Paykit link for \(PubkyPublicKeyFormat.redacted(publicKey)) during \(reason): \(error)", context: "PrivatePaykit" ) + linkRetryKeys.append(publicKey) 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: []] - ) + preparedKeys.append(publicKey) + } - 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" - ) + do { + try await withPublicationLock { + if let isSessionCurrent, await !isSessionCurrent() { throw PubkyServiceError.sessionNotActive } + 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.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 + 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) } - } - - guard !updates.isEmpty else { - await drainAndSchedulePrivateLinkRetries(reason: reason, retryKeys: linkRetryKeys) - return requireImmediatePublication ? firstError : nil - } - - 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) } catch { Logger.warn("Failed to sync private Paykit endpoint publications during \(reason): \(error)", context: "PrivatePaykit") firstError = firstError ?? error @@ -556,36 +614,9 @@ 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 } - 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 { @@ -596,55 +627,89 @@ extension PrivatePaykitService { return PubkyProfileManager.hasLocalSecretKey(for: ownPublicKey) } - private func drainAndSchedulePrivateLinkRetries(reason: String, retryKeys: [PrivateMessageDrainRetryKey]) async { - let retryKeys = Array(Set(retryKeys)) + private func drainAndSchedulePrivateLinkRetries(reason: String, retryKeys: [String]) async { + let retryKeys = Array(Set(retryKeys).subtracting(pendingMessageDrainRetryKeys)) 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)) } } - 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 { + func drainPendingPrivateMessages( + reason: String, + 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 { - for retryKey in Set(retryKeys) { + 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) + } catch { + Logger.warn( + "Failed to advance private Paykit link for \(PubkyPublicKeyFormat.redacted(retryKey)) during \(reason): \(error)", + context: "PrivatePaykit" + ) + } + } + 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" + ) + } + } + 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 PaykitSdkService.shared.ensureLinkWithPeer(retryKey.publicKey, receiverPath: retryKey.receiverPath) + try await operations.receive(publicKey) } catch { Logger.warn( - "Failed to advance private Paykit link for \(PubkyPublicKeyFormat.redacted(retryKey.publicKey)) during \(reason): \(error)", + "Failed to receive private Paykit messages during \(reason): \(PaykitResolutionFailureDiagnostics.reason(for: error))", context: "PrivatePaykit" ) } } - try await PaykitSdkService.shared.processPendingPrivateMessages() - try await PaykitSdkService.shared.receivePrivateMessagesFromLinkedPeers() - try await PaykitSdkService.shared.processPendingPrivateMessages() - try await PaykitSdkService.shared.receivePrivateMessagesFromLinkedPeers() } catch { Logger.warn("Failed to process pending private Paykit messages during \(reason): \(error)", context: "PrivatePaykit") } } - private func schedulePendingPrivateMessageDrainRetries(reason: String, retryKeys: [PrivateMessageDrainRetryKey]) { + private func schedulePendingPrivateMessageDrainRetries(reason: String, retryKeys: [String]) { 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() @@ -656,28 +721,32 @@ 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) } } - 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] ) } 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) } @@ -691,22 +760,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], retryMissingPeers: Bool = false) 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 { @@ -714,21 +783,32 @@ 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 } + 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) @@ -741,48 +821,44 @@ 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 { 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 + } + + 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( - "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 } 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] { @@ -800,6 +876,7 @@ extension PrivatePaykitService { let normalizedKeys = normalizedSavedContactKeys(publicKeys) if replacing { knownSavedContactKeys = Set(normalizedKeys) + unavailableLinkRetryAt = unavailableLinkRetryAt.filter { knownSavedContactKeys.contains($0.key) } } else { knownSavedContactKeys.formUnion(normalizedKeys) } @@ -819,95 +896,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, priority: .bulk) - 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 ) } @@ -920,11 +914,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 @@ -936,33 +930,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..c3843766e 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,26 @@ 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: { endpoints, allowUsedOnchainAddress in + await self.privatePayableEndpoints( + from: endpoints, + publicKey: publicKey, + allowUsedOnchainAddress: allowUsedOnchainAddress ) - ) - } + } + ) + if case .opened = result { return result } if resolution.state == .recoveryPending { - schedulePrivatePaymentRecovery(for: publicKey, receiverPath: receiverPath) + schedulePrivatePaymentRecovery(for: publicKey) } if resolution.status == .waitingForUpdatedPaymentList { @@ -159,11 +149,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 +166,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 +191,44 @@ extension PrivatePaykitService { } } + func privatePaymentResult( + publicKey: String, + paymentRequest: PaykitPaymentRequest?, + resolution: PrivateContactPaymentResolution, + validateEndpoints: ([PublicPaykitService.Endpoint], Bool) 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 + } + // 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 + } + 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 +237,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 +260,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 +281,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 +297,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 +313,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 +327,7 @@ extension PrivatePaykitService { } return try await PaykitSdkService.shared.linkedPeers().first { - PubkyPublicKeyFormat.matches($0.counterparty, publicKey) && $0.counterpartyReceiverPath == receiverPath + PubkyPublicKeyFormat.matches($0.counterparty, publicKey) }?.state } @@ -338,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() @@ -360,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) } @@ -414,7 +446,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..02ad26a12 100644 --- a/Bitkit/Services/PrivatePaykitService+State.swift +++ b/Bitkit/Services/PrivatePaykitService+State.swift @@ -4,10 +4,8 @@ import Foundation extension PrivatePaykitService { func closeAndClear() async { - initialLinkBurstTask?.cancel() - initialLinkBurstTask = nil - initialLinkBurstPublicKeys.removeAll() - initialLinkBurstGeneration += 1 + invalidateContactPreparation() + unavailableLinkRetryAt.removeAll() pendingMessageDrainRetryTask?.cancel() pendingMessageDrainRetryTask = nil pendingMessageDrainRetryKeys.removeAll() @@ -23,13 +21,14 @@ 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 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..1f9962a84 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 { @@ -54,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" @@ -79,22 +68,27 @@ 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 = [] + var pendingPreparationKeys: Set = [] + var activePreparationKeys: Set = [] + var preparationTask: Task? + var pendingPreparationOperation: (([String], Bool) async -> Void)? + var preparationGeneration = 0 + var pendingForceRefreshLightning = false + var unavailableLinkRetryAt: [String: Date] = [:] 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] = [:] - var initialLinkBurstTask: Task? - var initialLinkBurstPublicKeys: Set = [] - var initialLinkBurstGeneration = 0 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/Services/PubkyService.swift b/Bitkit/Services/PubkyService.swift index 0d2f4cf1e..1cdf8ad06 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 @@ -31,20 +30,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 @@ -121,21 +106,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.republishIdentityBeforeApproval(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 ) ) } @@ -150,7 +140,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( @@ -167,18 +157,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) } @@ -193,7 +187,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( @@ -205,6 +199,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) } @@ -300,13 +296,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, expectedIdentity: String? = nil) async throws -> Paykit.ContactRecord { try await PaykitSdkService.shared.saveContact( publicKey: publicKey, label: label, - receiverPaths: receiverPaths, restorePrivateConnection: restorePrivateConnection, expectedIdentity: expectedIdentity ) @@ -320,7 +315,7 @@ enum PubkyService { publicKey: String, allowPubkyProfileFallback: Bool, priority: PaykitPublicReadPriority = .interactive - ) async throws -> Paykit.ContactProfileResolution? { + ) async throws -> Paykit.ProfileResolution? { try await PaykitSdkService.shared.resolveContactProfile( publicKey: publicKey, allowPubkyProfileFallback: allowPubkyProfileFallback, @@ -328,10 +323,6 @@ enum PubkyService { ) } - static func discoverRelevantReceiverPaths(publicKey: String, priority: PaykitPublicReadPriority = .interactive) async throws -> [String] { - try await PaykitSdkService.shared.discoverRelevantReceiverPaths(publicKey: publicKey, priority: priority) - } - // MARK: - Sign Out static func signOut() async throws { @@ -348,6 +339,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() @@ -355,7 +351,6 @@ actor PaykitSdkService { walletBackupDataChangedSubject.eraseToAnyPublisher() } - private let stateStore = PaykitSdkStateBlobStore() private let sessionProvider = PaykitSdkSessionProvider() private let paymentAdapter = PaykitSdkPaymentAdapter() private let operationLock = PaykitSdkOperationLock() @@ -372,6 +367,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, @@ -389,10 +386,13 @@ 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 } Logger.warn("Deferring stale Paykit session restoration until SDK setup completes", context: "PaykitSdkService") @@ -400,24 +400,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 publishReceiverMarkerIfLiveSessionAvailable(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 } ) @@ -517,12 +521,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 } @@ -531,8 +535,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() } } @@ -543,13 +547,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) @@ -559,11 +561,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() @@ -583,7 +583,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() @@ -594,7 +593,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() } @@ -607,11 +606,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) @@ -661,7 +658,9 @@ actor PaykitSdkService { if let expectedIdentity { try await Self.requireSignedInIdentity(expectedIdentity, in: sdk) } - return 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) } } @@ -671,7 +670,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 } } @@ -696,23 +695,25 @@ 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 withPublicRead { try await $0.fetchPubkyFollows(publicKey: publicKey) } + try await withPublicRead { try await $0.fetchPubkyFollows(publicKey: publicKey, maxEntries: 10000) } } 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) } } @@ -721,7 +722,6 @@ actor PaykitSdkService { func saveContact( publicKey: String, label: String?, - receiverPaths: [String]? = nil, restorePrivateConnection: Bool = false, expectedIdentity: String? = nil ) async throws -> Paykit.ContactRecord { @@ -731,38 +731,35 @@ actor PaykitSdkService { } 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 { + invalidatePaykitKeyIfNeeded(after: error) Logger.error("Failed to restore peer block after contact save failed: \(error)", context: "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) && @@ -776,11 +773,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") @@ -788,12 +784,11 @@ actor PaykitSdkService { } catch is CancellationError { throw CancellationError() } catch { + invalidatePaykitKeyIfNeeded(after: error) 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) } } @@ -802,126 +797,34 @@ actor PaykitSdkService { publicKey: String, allowPubkyProfileFallback: Bool, priority: PaykitPublicReadPriority = .interactive - ) async throws -> Paykit.ContactProfileResolution? { + ) async throws -> Paykit.ProfileResolution? { try await withPublicRead(priority: priority) { - try await $0.resolveContactProfile( + try await $0.resolveProfile( publicKey: publicKey, - receiverPath: PaykitReceiverPath.wallet, allowPubkyProfileFallback: allowPubkyProfileFallback ) } } - /// Reads only the contact's public receiver paths and markers, so it runs on the public read lane and never holds - /// `operationLock` across the network. - func discoverRelevantReceiverPaths(publicKey: String, priority: PaykitPublicReadPriority = .interactive) async throws -> [String] { + func canReceivePaymentRequests(publicKey: String, priority: PaykitPublicReadPriority = .interactive) async throws -> Bool { + // The public registry read does not touch the session or shared state. try await withPublicRead(priority: priority) { sdk in - 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)) - } - } - - /// Public reads on the read lane, so it never holds `operationLock`. An abandoned eligibility check leaves the read - /// queue at once and stops before its next read. - func paymentRequestReceiverPaths(publicKey: String, priority: PaykitPublicReadPriority) async throws -> [String] { - try Task.checkCancellation() - return try await withPublicRead(priority: priority) { sdk in - let paths = try await sdk.paykitReceiverPaths(publicKey: publicKey) - var capablePaths = Set() - - for path in paths where PaykitReceiverPath.supported.contains(path) { - try Task.checkCancellation() - let marker = try await sdk.paykitReceiverMarker(publicKey: publicKey, receiverPath: path) - if marker?.capabilities.paymentRequests == true { - capablePaths.insert(path) - } - } - - return PaykitReceiverPath.supported.filter { capablePaths.contains($0) } - } - } - - /// Reads only the saved paths' public markers, so the reads run on the bulk lane rather than under `operationLock`. - /// The caller reads the saved paths from the contact record under the lock. When no SDK can be built it reports - /// `sessionNotActive` and protects every saved path from cleanup. - func privateReceiverPathSelection(publicKey: String, savedReceiverPaths: [String]) async throws -> PrivateReceiverPathSelection { - let paths = Self.mergedReceiverPaths(savedReceiverPaths) - return try await operationLock.withoutLock { - let instance: PaykitSdk? = if let sdk { - sdk - } else { - try await operationLock.withCancellableLock { try? handle() } - } - guard let instance else { - return PrivateReceiverPathSelection( - linkableReceiverPaths: [], - publishableReceiverPaths: [], - cleanupProtectedReceiverPaths: paths, - error: PubkyServiceError.sessionNotActive - ) - } - - return try await publicReadSlots.withSlot(.bulk) { - var linkable: [String] = [] - var publishable: [String] = [] - var cleanupProtected: [String] = [] - var firstError: Error? - - for path in paths { - do { - let marker = try await instance.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 - ) - } + try await sdk.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(for: sdk.identityStatus()) + capabilities.privatePayments = capabilities.privatePayments && privatePaymentsEnabled + _ = try await sdk.publishPaykitApp(displayName: "Bitkit", capabilities: capabilities) } } 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)) } } @@ -930,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 @@ -940,25 +844,48 @@ actor PaykitSdkService { func ensureLinkWithPeer( _ counterparty: String, - receiverPath: String, - maxAdvanceSteps: UInt32 = 8 + maxAdvanceSteps: UInt32 = 1 ) 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 + 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) && $0.counterpartyReceiverPath == receiverPath - }) { + guard !counterparties.isEmpty else { return nil } + return try await withStateRevisionTracking { sdk in + 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: []) } + 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, counterpartyReceiverPath: receiverPath) + 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 + ) } } @@ -969,6 +896,20 @@ 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 + try await sdk.processOutboundPrivateMessages(counterparty: counterparty) + } + } + @discardableResult func processPendingPrivateMessages() async throws -> [OutboundPrivateCounterpartySendReport] { try await withStateRevisionTracking { sdk in @@ -977,21 +918,48 @@ 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() + } + } + + func sharedPaymentRequests(expectedIdentity: String) async throws -> [Paykit.PaymentRequestRecord] { + try await withSdk(priority: .interactive) { 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: + 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 ) @@ -1000,20 +968,18 @@ actor PaykitSdkService { func proposePaymentRequest( counterparty: String, - counterpartyReceiverPath: String, 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.liveSessionAvailable, + identityStatus.capability == .privateLinkCapable, PubkyPublicKeyFormat.matches(identityStatus.publicKey, expectedIdentity) else { throw PaykitPaymentRequestError.requestUnavailable } return try await sdk.proposePaymentRequest( counterparty: counterparty, - counterpartyReceiverPath: counterpartyReceiverPath, terms: terms ) } @@ -1021,28 +987,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 ) @@ -1051,14 +1020,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 ) @@ -1066,54 +1033,59 @@ 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 withStateRevisionTracking(priority: .interactive) { sdk in try await sdk.prepareAndResolvePrivateContactPayment( counterparty: counterparty, - counterpartyReceiverPath: receiverPath, amount: amount, afterPrivatePaymentListVersion: afterPrivatePaymentListVersion, - maxAdvanceSteps: 8 + maxAdvanceSteps: 1 ) } } - 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(priority: .interactive) { sdk in + try await sdk.prepareAndResolvePrivatePaymentRequest( + counterparty: counterparty, + paymentRequestId: paymentRequestId, + afterPrivatePaymentListVersion: afterPrivatePaymentListVersion, + maxAdvanceSteps: 1 + ) } } - func exportBackupState() async throws -> String { - try await operationLock.withLock { - try await handle().exportBackupString() + func resolvePublicContactPayment(counterparty: String) async throws -> PublicContactPaymentResolution { + 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 } } - 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 { @@ -1124,9 +1096,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() } } @@ -1144,19 +1117,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 { @@ -1174,7 +1142,7 @@ actor PaykitSdkService { } nonisolated static func requiredCapabilities() throws -> String { - try Paykit.requiredSessionCapabilities(config: config()) + Paykit.paykitAuthorizerSessionCapabilities() } private func handle() throws -> PaykitSdk { @@ -1182,8 +1150,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(), @@ -1193,13 +1160,6 @@ actor PaykitSdkService { return created } - /// Public-only read lane: skips `operationLock` and runs up to the read slots' cap at once. It is sound only for reads - /// that fetch unauthenticated public Pubky data; in paykit rc56 those use the SDK's public client and never load - /// session access, the local secret or the state blob. Session, secret or state-blob operations must never use it. - /// Without an SDK it takes `operationLock` only to build one, cancellably, and releases it before the read, so a - /// read never builds an SDK outside the lock and never holds the lock across the network call. Like a locked call, a - /// read that starts during a wallet wipe is rejected, and one that a wipe overtakes fails instead of returning its - /// result across the wipe. private func withPublicRead( priority: PaykitPublicReadPriority = .interactive, _ read: (PaykitSdk) async throws -> T @@ -1214,11 +1174,44 @@ actor PaykitSdkService { } } - private func withStateRevisionTracking(_ operation: (PaykitSdk) async throws -> T) async throws -> T { - try await operationLock.withLock { - let sdk = try handle() + 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()) + } + } + } + + 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( + 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 sdk.backupStateRevision() }, + readRevision: { try await self.withSdkErrorHandling { try await sdk.backupStateRevision() } }, + readStateRevision: { try sdk.stateRevision() }, + cachedSnapshot: self.cachedBackupState, + onSnapshot: { self.cachedBackupState = $0 }, onChange: { self.markWalletBackupDataChanged() }, operation: { try await operation(sdk) } ) @@ -1227,21 +1220,47 @@ 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 result: Result + 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: 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 let previousStateRevision, + previousStateRevision == nextStateRevision, + let previousRevision + { + 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)) + } else { + onSnapshot(nil) + } if previousRevision == nil || nextRevision == nil || previousRevision != nextRevision { await onChange() } - return try result.get() + return result } private func markWalletBackupDataChanged() { @@ -1250,6 +1269,51 @@ actor PaykitSdkService { private func resetRuntime() { sdk = nil + cachedPaykitKey = nil + cachedBackupState = nil + } + + 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 { + try await operationLock.withLock { + try await withSdkErrorHandling { + 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 { @@ -1257,7 +1321,6 @@ actor PaykitSdkService { 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) @@ -1274,21 +1337,21 @@ 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 + let status: IdentityStatus 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() + status = try await sdk.initialize() + if result.sessionAccess.exportLocalSecretKey() != nil { + _ = try await sdk.publishPaykitNoiseKeyAuthorization() + } } 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 { @@ -1305,46 +1368,45 @@ 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() } - await publishReceiverMarkerIfLiveSessionAvailable(using: sdk) + await publishAppIfLiveSessionAvailable(using: sdk, status: status) startIdentityRepublish(publicKey: result.publicKey) } - private func publishReceiverMarkerIfLiveSessionAvailable(using sdk: PaykitSdk) async { + private func publishAppIfLiveSessionAvailable(using sdk: PaykitSdk, status: IdentityStatus) async { do { - let capabilities = try await receiverCapabilities(using: sdk) + var capabilities = appCapabilities(for: status) guard capabilities.privatePayments else { return } - _ = try await sdk.publishPaykitReceiverMarker(capabilities: capabilities) + capabilities.privatePayments = UserDefaults.standard.bool(forKey: PrivatePaykitService.publishingEnabledKey) + _ = try await sdk.publishPaykitApp(displayName: "Bitkit", capabilities: capabilities) } catch { - Logger.warn("Failed to publish Paykit receiver marker: \(error)", context: "PaykitSdkService") + invalidatePaykitKeyIfNeeded(after: error) + Logger.warn("Failed to publish Paykit app: \(error)", context: "PaykitSdkService") } } - private func receiverCapabilities(using sdk: PaykitSdk) async throws -> Paykit.PaykitReceiverCapabilities { - let status = try await sdk.identityStatus() - return Paykit.PaykitReceiverCapabilities( - privatePayments: status?.liveSessionAvailable == true, - paymentRequests: status?.liveSessionAvailable == true, + private func appCapabilities(for status: IdentityStatus?) -> Paykit.PaykitAppCapabilities { + 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 } /// 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 { @@ -1352,27 +1414,6 @@ actor PaykitSdkService { } } - private nonisolated static func publicKeysMatch(_ lhs: String?, _ rhs: String) -> Bool { - guard let lhs, - let normalizedLhs = try? Paykit.normalizePubkyPublicKey(value: lhs), - let normalizedRhs = try? Paykit.normalizePubkyPublicKey(value: rhs) - else { - return false - } - return normalizedLhs == normalizedRhs - } - - private 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 @@ -1383,10 +1424,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 @@ -1414,11 +1451,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 } @@ -1431,23 +1465,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() @@ -1464,15 +1516,17 @@ final class PaykitSdkOperationLock: @unchecked Sendable { func withoutLock(_ operation: () async throws -> T) async throws -> T { if ownsWipe() { return try await operation() } 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() } - await acquire() + await acquire(priority: .ordered) defer { release() } try Task.checkCancellation() return try await Self.$walletWipeOwner.withValue(wipeID) { @@ -1533,11 +1587,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 @@ -1556,7 +1610,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 @@ -1573,10 +1627,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 } @@ -1586,20 +1640,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 @@ -1717,59 +1784,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( @@ -1783,12 +1803,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() } @@ -1811,12 +1833,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 } } @@ -1857,12 +1881,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() != key.keyGeneration() { + liveSessionAccess = nil + } + paykitIdentitySecretKey = key } } @@ -1882,127 +1907,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 7a86859a9..fb8983199 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( @@ -320,55 +320,67 @@ enum PublicPaykitService { publish: Bool, isSessionCurrent: (@MainActor () -> Bool)? = nil ) async throws { - guard publish else { - // The marker removal shares the lock with the endpoint removal, so a newer publish that checks under the lock - // lands wholly before or after both. - try await withEndpointLock(unlessSessionEnded: isSessionCurrent) { + try await syncPublishedEndpoints( + publish: publish, + isSessionCurrent: isSessionCurrent, + buildEndpoints: { try await buildWalletEndpoints(wallet: wallet, refreshIfNeeded: true, requireEndpoint: true) }, + syncApp: { try await syncPaykitApp() }, + applyEndpoints: applyPublishedEndpointsLocked + ) + } + + @MainActor + static func syncPublishedEndpoints( + publish: Bool, + isSessionCurrent: (@MainActor () -> Bool)? = nil, + buildEndpoints: () async throws -> [Endpoint], + syncApp: () async throws -> Void, + applyEndpoints: ([Endpoint]) async throws -> Void + ) async throws { + let desiredEndpoints: Result<[Endpoint], Error> + do { + desiredEndpoints = try await .success(publish ? buildEndpoints() : []) + } catch { + desiredEndpoints = .failure(error) + } + try await withEndpointLock(unlessSessionEnded: isSessionCurrent) { + guard publish else { var firstError: Error? do { - try await applyPublishedEndpointsLocked([]) + try await applyEndpoints([]) } catch { - firstError = error + firstError = firstError ?? error } do { - try await syncLocalReceiverMarker(publicSharingEnabled: false) + try await syncApp() } catch { firstError = firstError ?? error } if let firstError { throw firstError } + return } - return - } - let desiredEndpoints = try await buildWalletEndpoints(wallet: wallet, refreshIfNeeded: true, requireEndpoint: true) - // Under the lock, so sign-out's marker removal, which follows its locked endpoint removal, comes after it. - try await applyPublishedEndpoints(desiredEndpoints, unlessSessionEnded: isSessionCurrent) { - try await syncLocalReceiverMarker(publicSharingEnabled: true) + try await syncApp() + // Keep the App Registry current even when wallet endpoints are unavailable. + try await applyEndpoints(desiredEndpoints.get()) } } @MainActor static func syncCurrentPublishedEndpoints(wallet: WalletViewModel) async throws { let desiredEndpoints = try await buildWalletEndpoints(wallet: wallet, refreshIfNeeded: false, requireEndpoint: false) - try await syncLocalReceiverMarker(publicSharingEnabled: true) - try await applyPublishedEndpoints(desiredEndpoints) + try await applyPublishedEndpoints(desiredEndpoints, beforeApplying: { try await syncPaykitApp() }) } static func removePublishedEndpoints() async throws { 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..054abe1d6 100644 --- a/Bitkit/Utilities/Keychain.swift +++ b/Bitkit/Utilities/Keychain.swift @@ -9,9 +9,10 @@ enum KeychainEntryType { case paykitSession case paykitPendingPaymentProofs case paykitPresentedPaymentRequests + case paykitAcceptedPaymentRequests case paykitSubscriptionState - case paykitReceiverNoiseSecretKey - case paykitSdkState + case paykitKeyGeneration(publicKey: String) + case paykitRecoveryBackup case paykitPendingBackupRestore case pubkySecretKey @@ -24,9 +25,10 @@ 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 .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/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/Bitkit/ViewModels/WalletViewModel.swift b/Bitkit/ViewModels/WalletViewModel.swift index 3108a5a04..d5f941108 100644 --- a/Bitkit/ViewModels/WalletViewModel.swift +++ b/Bitkit/ViewModels/WalletViewModel.swift @@ -1365,7 +1365,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 } @@ -1429,7 +1429,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] = [] @@ -1483,7 +1483,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/Bitkit/Views/Contacts/AddContactView.swift b/Bitkit/Views/Contacts/AddContactView.swift index 174fc957a..feed3029e 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/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/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/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/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/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 83644194f..60eeb492d 100644 --- a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift +++ b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift @@ -934,8 +934,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 ) @@ -1064,9 +1068,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 a9d77823b..c181fe82d 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/AddressSearchCoordinatorTests.swift b/BitkitTests/AddressSearchCoordinatorTests.swift new file mode 100644 index 000000000..9e2cccfaa --- /dev/null +++ b/BitkitTests/AddressSearchCoordinatorTests.swift @@ -0,0 +1,161 @@ +@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 [LightningService.AddressDerivationInfo(address: "server-receive", index: 1200)] + } + 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 + ? [LightningService.AddressDerivationInfo(address: "owned-change", index: 203)] : [] + } + ) + 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"), 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 { + 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 [LightningService.AddressDerivationInfo(address: "unrelated-owned-address", index: start)] + } + ) + 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/ContactPaymentsServiceTests.swift b/BitkitTests/ContactPaymentsServiceTests.swift index 961004fe7..6dd3aa913 100644 --- a/BitkitTests/ContactPaymentsServiceTests.swift +++ b/BitkitTests/ContactPaymentsServiceTests.swift @@ -59,8 +59,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, ["private:publish", "public:true"]) + XCTAssertFalse(operations.privatePublications[0].requiresImmediatePublication) + XCTAssertEqual(operations.calls, ["public:true", "private:publish"]) XCTAssertEqual(operations.privateRemovalCount, 0) XCTAssertEqual(operations.publicCleanupValues, [false]) XCTAssertEqual(operations.privateCleanupValues, [false]) @@ -136,15 +136,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)) @@ -156,6 +158,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, @@ -172,7 +179,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() @@ -191,52 +198,280 @@ 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)) } } - func testFailedPrivateDisableKeepsContactPaymentsDisabled() async throws { + 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), + 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, [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)) + XCTAssertFalse(ContactPaymentsService.isEnabled(defaults: defaults)) + + try await ContactPaymentsService.setEnabled( + false, + contactPublicKeys: ["contact-a"], + canUsePrivatePayments: true, + operations: operations.makeOperations(defaults: defaults), + defaults: defaults + ) + + XCTAssertEqual(operations.publicPublicationValues, [false, false]) + XCTAssertEqual(operations.privateRemovalCount, 2) + XCTAssertTrue(operations.privatePublications.isEmpty) + 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)) + } + } + } + + func testForegroundReconciliationSkipsBothPhasesOfActiveDisable() 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] + 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 {} + } - try await ContactPaymentsService.setEnabled( - false, - contactPublicKeys: ["contact-a"], - canUsePrivatePayments: true, - operations: operations.makeOperations(), - defaults: defaults - ) + 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") + } - XCTAssertEqual(operations.publicPublicationValues, [false]) - XCTAssertEqual(operations.privateRemovalCount, 1) - XCTAssertTrue(operations.privatePublications.isEmpty) + 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"]) - 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(defaults.bool(forKey: PublicPaykitService.cleanupPendingKey)) + var reconciled = false + await ContactPaymentsService.reconcilePendingEndpoints { reconciled = true } + XCTAssertTrue(reconciled) + } + } + + func testEnablingWaitsForSuccessfulOrFailedDisable() async throws { + 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") + 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 { + 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 + ) + } + await fulfillment(of: [enableRequested], timeout: 2) + XCTAssertFalse(ContactPaymentsService.isEnabled(defaults: defaults)) + XCTAssertEqual(operations.calls, disableUI ? ["public:false", "private:remove"] : ["private:remove"]) + + finishRemoval.finish() + switch await disable.result { + 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 + 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)) + } + } + } + + 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) } } - /// General Settings turns contact payments on in a task that outlives the screen, after the first contacts load. A - /// Pubky sign-out during that load, finished or still running, stops the change before it writes the preference or - /// publishes anything, and a load that then fails reports nothing. The change returns whether it was applied. func testContactPaymentsChangeStopsWhenPubkySignsOutDuringTheContactsLoad() async throws { enum SignOut { case never, finished, running @@ -305,7 +540,7 @@ final class ContactPaymentsServiceTests: XCTestCase { try await runningSignOut.task.value } - XCTAssertEqual(operations.calls, enabled ? ["private:publish", "public:true"] : [], testCase.name) + XCTAssertEqual(operations.calls, enabled ? ["public:true", "private:publish"] : [], testCase.name) XCTAssertEqual(operations.privatePublications.map(\.contactPublicKeys), enabled ? [[contactKey]] : [], testCase.name) XCTAssertEqual(defaults.bool(forKey: ContactPaymentsService.confirmedPreferenceKey), enabled, testCase.name) XCTAssertEqual(defaults.bool(forKey: PublicPaykitService.publishingEnabledKey), enabled, testCase.name) @@ -314,11 +549,7 @@ final class ContactPaymentsServiceTests: XCTestCase { } } - /// General Settings turns contact payments on in a task that outlives the screen. A Pubky sign-out that starts while - /// an endpoint publication is in flight stops that enable: a publication that gets its lock after sign-out's removal - /// writes nothing, and the enable writes no preference or flag and restores nothing. A publication that already held - /// its lock still finishes, but the enable then clears no cleanup mark the sign-out left for a removal that failed. - /// Either way the enable returns that it stopped, so Pay Contacts does not open Profile. + /// A publication already holding its lock can finish; sign-out must remove it afterward and retain failed cleanup. func testContactPaymentsEnableStopsWhenPubkySignsOutDuringPublication() async throws { enum HeldPublication { case publicBeforeLock, privateBeforeLock, publicHoldingLock @@ -326,15 +557,15 @@ final class ContactPaymentsServiceTests: XCTestCase { let cases: [(name: String, held: HeldPublication, calls: [String], writes: [String], keepsFlags: Bool)] = [ ( "public publication waiting for its lock", .publicBeforeLock, - ["private:publish", "public:true"], ["private:published", "private:removed", "public:removal failed"], false + ["public:true"], ["private:removed", "public:removal failed"], false ), ( "private publication waiting for its lock", .privateBeforeLock, - ["private:publish"], ["private:removed", "public:removal failed"], false + ["public:true", "private:publish"], ["public:published", "private:removed", "public:removal failed"], false ), ( "public publication holding its lock while sign-out fails", .publicHoldingLock, - ["private:publish", "public:true"], ["private:published", "private:removal failed", "public:published"], true + ["public:true"], ["private:removal failed", "public:published"], true ), ] let ownerKey = try useLocalPubkySecretKey() @@ -358,16 +589,8 @@ final class ContactPaymentsServiceTests: XCTestCase { let writes = WriteLog() let publicationOperations = PrivatePaykitService.EndpointPublicationOperations( currentPublicKey: { ownerKey }, - linkedReceiverPaths: { _ in ([:], nil) }, - receiverPaths: { _ in [PaykitReceiverPath.wallet] }, - receiverPathSelection: { _, _ in - PrivateReceiverPathSelection( - linkableReceiverPaths: [], publishableReceiverPaths: [PaykitReceiverPath.wallet], - cleanupProtectedReceiverPaths: [], error: nil - ) - }, - ensureLink: { _, _ in }, - buildEndpoints: { _, _ in [endpoint] }, + ensureLink: { _ in .linked }, + buildEndpoints: { _ in [endpoint] }, syncPaymentLists: { _ in writes.append("private:published") return PrivatePaymentListDeliveryReport(queued: [], cleared: [], failedToQueue: [], failedToDeliver: []) @@ -382,6 +605,7 @@ final class ContactPaymentsServiceTests: XCTestCase { reachedHeldPoint.fulfill() for await _ in gate {} } + _ = await privatePaykit.rememberSavedContacts(contactPublicKeys, replacing: true) return await privatePaykit.syncLocalEndpointPublication( for: contactPublicKeys, reason: "test", @@ -447,18 +671,16 @@ final class ContactPaymentsServiceTests: XCTestCase { XCTAssertEqual(defaults.bool(forKey: ContactPaymentsService.confirmedPreferenceKey), testCase.keepsFlags, testCase.name) XCTAssertEqual(defaults.bool(forKey: PublicPaykitService.publishingEnabledKey), testCase.keepsFlags, testCase.name) XCTAssertEqual(defaults.bool(forKey: PrivatePaykitService.publishingEnabledKey), testCase.keepsFlags, testCase.name) - XCTAssertEqual(operations.publicCleanupValues, [], testCase.name) - XCTAssertEqual(operations.privateCleanupValues, [], testCase.name) + XCTAssertEqual(operations.publicCleanupValues, held == .privateBeforeLock ? [false] : [], testCase.name) + XCTAssertEqual(operations.privateCleanupValues, held == .privateBeforeLock ? [false] : [], testCase.name) XCTAssertTrue(PublicPaykitService.isCleanupPending, testCase.name) } } - /// A sign-out that lands after the last session check before a change's writes still stops it: the change checks the - /// session again right before it writes the preference, the flags or a cleared cleanup mark. func testContactPaymentsChangeWritesNothingOnceThePubkySessionChanged() async throws { let cases: [(name: String, enabled: Bool, calls: [String])] = [ ("enable", true, []), - ("disable", false, ["private:remove", "public:false"]), + ("disable", false, []), ] for testCase in cases { @@ -488,21 +710,16 @@ final class ContactPaymentsServiceTests: XCTestCase { } } - /// The latest contact payments change wins. General Settings' first automatic enable outlives the screen, so the user - /// can turn contact payments off in a General Settings opened again while that enable still publishes endpoints. A - /// publication of the enable that gets its lock after the disable started writes nothing, and one that already held - /// its lock finishes before the disable's removal. Either way, once the disable started the enable writes no flag or - /// cleanup mark and restores nothing, it returns that it stopped, and contact payments stay off. func testContactPaymentsDisableWinsOverAnOlderEnableStillPublishing() async throws { enum HeldPublication { case privateBeforeLock, publicBeforeLock, publicHoldingLock } let cases: [(name: String, held: HeldPublication, writes: [String])] = [ - ("private publication waiting for its lock", .privateBeforeLock, ["private:removed", "public:removed"]), - ("public publication waiting for its lock", .publicBeforeLock, ["private:published", "private:removed", "public:removed"]), + ("private publication waiting for its lock", .privateBeforeLock, ["public:published", "private:removed", "public:removed"]), + ("public publication waiting for its lock", .publicBeforeLock, ["private:removed", "public:removed"]), ( "public publication holding its lock", .publicHoldingLock, - ["private:published", "private:removed", "public:published", "public:removed"] + ["public:published", "private:removed", "public:removed"] ), ] let ownerKey = try useLocalPubkySecretKey() @@ -514,7 +731,7 @@ final class ContactPaymentsServiceTests: XCTestCase { let contactsManager = ContactsManager(contactRecords: { [record] }) let endpoints = LockedEndpointWrites() let reachedHeldPoint = expectation(description: "\(testCase.name): publication reached its held point") - let disableRemovedPrivateEndpoints = expectation(description: "\(testCase.name): private endpoints removed") + let disableRequested = expectation(description: "\(testCase.name): disable requested") let (gate, release) = AsyncStream.makeStream() let held = testCase.held let operations = OperationsSpy() @@ -527,7 +744,6 @@ final class ContactPaymentsServiceTests: XCTestCase { } operations.removePrivateEndpoints = { isChangeCurrent in try await endpoints.writePrivate("private:removed", isChangeCurrent: isChangeCurrent) - disableRemovedPrivateEndpoints.fulfill() } operations.syncPublicEndpoints = { publish, isChangeCurrent in if publish, held == .publicBeforeLock { @@ -542,7 +758,7 @@ final class ContactPaymentsServiceTests: XCTestCase { } } let enable = Task { - try await ContactPaymentsService.setEnabled( + return try await ContactPaymentsService.setEnabled( true, pubkyProfile: pubkyProfile, contactsManager: contactsManager, operations: operations.makeOperations(), defaults: defaults ) @@ -550,18 +766,13 @@ final class ContactPaymentsServiceTests: XCTestCase { await fulfillment(of: [reachedHeldPoint], timeout: 2) let disable = Task { - try await ContactPaymentsService.setEnabled( + disableRequested.fulfill() + return try await ContactPaymentsService.setEnabled( false, pubkyProfile: pubkyProfile, contactsManager: contactsManager, operations: operations.makeOperations(), defaults: defaults ) } - await fulfillment(of: [disableRemovedPrivateEndpoints], timeout: 2) - // Holding its lock, the enable's publication keeps the disable's public removal waiting; otherwise the - // disable finishes while the enable is held. - if held != .publicHoldingLock { - let isDisableApplied = try await disable.value - XCTAssertTrue(isDisableApplied, testCase.name) - } + await fulfillment(of: [disableRequested], timeout: 2) release.finish() let enableResult = await enable.result let disableResult = await disable.result @@ -569,8 +780,8 @@ final class ContactPaymentsServiceTests: XCTestCase { XCTAssertFalse(try enableResult.get(), testCase.name) XCTAssertTrue(try disableResult.get(), testCase.name) XCTAssertEqual(endpoints.entries, testCase.writes, testCase.name) - XCTAssertEqual(operations.publicCleanupValues, [false], testCase.name) - XCTAssertEqual(operations.privateCleanupValues, [false], testCase.name) + XCTAssertEqual(operations.publicCleanupValues.last, false, testCase.name) + XCTAssertEqual(operations.privateCleanupValues.last, false, testCase.name) XCTAssertFalse(defaults.bool(forKey: PublicPaykitService.publishingEnabledKey), testCase.name) XCTAssertFalse(defaults.bool(forKey: PrivatePaykitService.publishingEnabledKey), testCase.name) XCTAssertTrue(defaults.bool(forKey: ContactPaymentsService.confirmedPreferenceKey), testCase.name) @@ -579,23 +790,19 @@ final class ContactPaymentsServiceTests: XCTestCase { } } - /// The latest contact payments change wins the other way round too. An enable that starts while an older disable still - /// removes endpoints keeps contact payments on: a removal of the disable that gets its lock after the enable started - /// removes nothing, and one that already held its lock finishes before the enable's publication. The disable writes - /// no flag or cleanup mark once the enable started, and returns that it stopped. func testContactPaymentsEnableWinsOverAnOlderDisableStillRemoving() async throws { enum HeldRemoval { case privateBeforeLock, publicBeforeLock, publicHoldingLock } - let cases: [(name: String, held: HeldRemoval, writes: [String], privateCleanupValues: [Bool])] = [ - ("private removal waiting for its lock", .privateBeforeLock, ["private:published", "public:published"], [false]), + let cases: [(name: String, held: HeldRemoval, writes: [String])] = [ + ("private removal waiting for its lock", .privateBeforeLock, ["public:published", "private:published"]), ( "public removal waiting for its lock", .publicBeforeLock, - ["private:removed", "private:published", "public:published"], [false, false] + ["private:removed", "public:published", "private:published"] ), ( "public removal holding its lock", .publicHoldingLock, - ["private:removed", "private:published", "public:removed", "public:published"], [false, false] + ["private:removed", "public:removed", "public:published", "private:published"] ), ] let ownerKey = try useLocalPubkySecretKey() @@ -610,7 +817,7 @@ final class ContactPaymentsServiceTests: XCTestCase { let contactsManager = ContactsManager(contactRecords: { [record] }) let endpoints = LockedEndpointWrites() let reachedHeldPoint = expectation(description: "\(testCase.name): removal reached its held point") - let enablePublishedPrivateEndpoints = expectation(description: "\(testCase.name): private endpoints published") + let enableRequested = expectation(description: "\(testCase.name): enable requested") let (gate, release) = AsyncStream.makeStream() let held = testCase.held let operations = OperationsSpy() @@ -622,9 +829,7 @@ final class ContactPaymentsServiceTests: XCTestCase { try await endpoints.writePrivate("private:removed", isChangeCurrent: isChangeCurrent) } operations.preparePrivateEndpoints = { _, _, isChangeCurrent in - let error = await endpoints.writePrivateReturningError("private:published", isChangeCurrent: isChangeCurrent) - enablePublishedPrivateEndpoints.fulfill() - return error + return await endpoints.writePrivateReturningError("private:published", isChangeCurrent: isChangeCurrent) } operations.syncPublicEndpoints = { publish, isChangeCurrent in if !publish, held == .publicBeforeLock { @@ -647,18 +852,13 @@ final class ContactPaymentsServiceTests: XCTestCase { await fulfillment(of: [reachedHeldPoint], timeout: 2) let enable = Task { - try await ContactPaymentsService.setEnabled( + enableRequested.fulfill() + return try await ContactPaymentsService.setEnabled( true, pubkyProfile: pubkyProfile, contactsManager: contactsManager, operations: operations.makeOperations(), defaults: defaults ) } - await fulfillment(of: [enablePublishedPrivateEndpoints], timeout: 2) - // Holding its lock, the disable's removal keeps the enable's public publication waiting; otherwise the - // enable finishes while the disable is held. - if held != .publicHoldingLock { - let isEnableApplied = try await enable.value - XCTAssertTrue(isEnableApplied, testCase.name) - } + await fulfillment(of: [enableRequested], timeout: 2) release.finish() let disableResult = await disable.result let enableResult = await enable.result @@ -666,8 +866,8 @@ 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, [false], testCase.name) - XCTAssertEqual(operations.privateCleanupValues, testCase.privateCleanupValues, testCase.name) + XCTAssertEqual(operations.publicCleanupValues.last, 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) XCTAssertTrue(defaults.bool(forKey: ContactPaymentsService.confirmedPreferenceKey), testCase.name) @@ -675,9 +875,6 @@ final class ContactPaymentsServiceTests: XCTestCase { } } - /// Opening General Settings again before its first automatic enable applied starts a second automatic enable. The older - /// one then publishes nothing, writes nothing more and returns that it stopped, and the newer one turns contact - /// payments on. func testContactPaymentsEnableSupersedesAnOlderEnableStillPublishing() async throws { let ownerKey = try useLocalPubkySecretKey() let record = contactRecord(publicKey: "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg") @@ -709,24 +906,28 @@ final class ContactPaymentsServiceTests: XCTestCase { } await fulfillment(of: [olderReachedPublication], timeout: 2) - let isNewerEnableApplied = try await ContactPaymentsService.setEnabled( - true, pubkyProfile: pubkyProfile, contactsManager: contactsManager, operations: operations.makeOperations(), - defaults: defaults - ) + let newerRequested = expectation(description: "newer enable requested") + let newerEnable = Task { + newerRequested.fulfill() + return try await ContactPaymentsService.setEnabled( + true, pubkyProfile: pubkyProfile, contactsManager: contactsManager, operations: operations.makeOperations(), + defaults: defaults + ) + } + await fulfillment(of: [newerRequested], timeout: 2) release.finish() + let isNewerEnableApplied = try await newerEnable.value let isOlderEnableApplied = try await olderEnable.value XCTAssertTrue(isNewerEnableApplied) XCTAssertFalse(isOlderEnableApplied) - XCTAssertEqual(endpoints.entries, ["private:published", "public:published"]) - XCTAssertEqual(operations.publicCleanupValues, [false]) - XCTAssertEqual(operations.privateCleanupValues, [false]) + XCTAssertEqual(endpoints.entries, ["public:published", "public:published", "private:published"]) + XCTAssertEqual(operations.publicCleanupValues.last, false) + XCTAssertEqual(operations.privateCleanupValues.last, false) XCTAssertTrue(ContactPaymentsService.isEnabled(defaults: defaults)) } } - /// The endpoint removals of a change that is no longer current refuse once they hold their lock, so an older disable - /// cannot clear what a newer change published under that lock. func testEndpointRemovalsRefuseOnceTheirChangeIsNoLongerCurrent() async { do { try await PrivatePaykitService().removePublishedEndpoints( @@ -798,10 +999,10 @@ final class ContactPaymentsServiceTests: XCTestCase { private func contactRecord(publicKey: String) -> ContactRecord { ContactRecord( - publicKey: publicKey, receiverPaths: [PaykitReceiverPath.wallet], label: "Contact", + publicKey: publicKey, label: "Contact", profile: PaykitProfile(displayName: "Contact", 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 ) } @@ -893,12 +1094,15 @@ final class ContactPaymentsServiceTests: XCTestCase { var preparePrivateEndpoints: (([String], Bool, @escaping ContactPaymentsService.ChangeCheck) async -> Error?)? var syncPublicEndpoints: ((Bool, @escaping ContactPaymentsService.ChangeCheck) async throws -> Void)? var removePrivateEndpoints: ((@escaping ContactPaymentsService.ChangeCheck) async throws -> Void)? + var onRemovePrivateEndpoints: (() async -> Void)? + var onSyncPublicEndpoints: ((Bool) async -> Void)? - func makeOperations() -> ContactPaymentsService.Operations { + func makeOperations(defaults: UserDefaults? = nil) -> ContactPaymentsService.Operations { ContactPaymentsService.Operations( syncPublicEndpoints: { publish, isChangeCurrent in self.calls.append("public:\(publish)") self.publicPublicationValues.append(publish) + await self.onSyncPublicEndpoints?(publish) try await self.syncPublicEndpoints?(publish, isChangeCurrent) if self.publicPublicationFailures.contains(self.publicPublicationValues.count) { throw TestError.operationFailed @@ -921,13 +1125,20 @@ final class ContactPaymentsServiceTests: XCTestCase { removePrivateEndpoints: { isChangeCurrent in self.calls.append("private:remove") self.privateRemovalCount += 1 + await self.onRemovePrivateEndpoints?() if self.privateRemovalFailures.contains(self.privateRemovalCount) { throw TestError.operationFailed } try await self.removePrivateEndpoints?(isChangeCurrent) }, - 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/ContactsManagerTests.swift b/BitkitTests/ContactsManagerTests.swift index ba576f81a..271046cb5 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)) } @@ -242,19 +222,19 @@ 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 ) } private func unprofiledRecord(key: String, label: String?) -> ContactRecord { ContactRecord( - publicKey: key, receiverPaths: [PaykitReceiverPath.wallet], label: label, profile: nil, + publicKey: key, label: 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 ) } diff --git a/BitkitTests/PaykitBackupStateTrackingTests.swift b/BitkitTests/PaykitBackupStateTrackingTests.swift index 17c096bcb..81cc26ff8 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 testFailedWriteInvalidatesBackupWithoutAnotherRemoteRead() 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, 0) + XCTAssertEqual(changes, 1) + XCTAssertNil(snapshot) + } } diff --git a/BitkitTests/PaykitContactLifecycleTests.swift b/BitkitTests/PaykitContactLifecycleTests.swift index 474f9ee26..1a3bbebba 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,83 @@ 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.clearPrivatePaymentLists(to: [sdk.publicKey]) + 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.clearPrivatePaymentLists(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) + } + + 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) + }) + } + + 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) } } @@ -182,25 +280,56 @@ 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 + var failRecovery = 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, 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? { + registryReads += 1 + return 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? { + identityReads += 1 + return 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 { "revision" } + override func stateRevision() throws -> String? { + nil + } + override func paymentRequests() async throws -> [PaymentRequestRecord] { requests } @@ -210,30 +339,58 @@ private final class ContactLifecycleSdk: PaykitSdk, @unchecked Sendable { } override func linkedPeers() async throws -> [LinkedPeerRecord] { + peerReads += 1 if failLinkedPeers { throw PubkyServiceError.profileNotFound } 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.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: [] + ) + } + 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 +406,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..36a733969 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() } @@ -416,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]) @@ -423,7 +453,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 +488,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 +520,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 +539,7 @@ final class PaykitPaymentProofServiceTests: XCTestCase { let proof = PendingPaykitPaymentProof( identity: identity, requestId: request.id, + paymentAppId: "bitkit", paymentEndpointIdentifier: endpoint, kind: .onchain, paymentStarted: true, @@ -532,12 +563,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 +597,7 @@ final class PaykitPaymentProofServiceTests: XCTestCase { let proof = PendingPaykitPaymentProof( identity: identity, requestId: request.id, + paymentAppId: "bitkit", paymentEndpointIdentifier: endpoint, kind: .onchain, paymentStarted: true, @@ -608,6 +641,7 @@ final class PaykitPaymentProofServiceTests: XCTestCase { let proof = PendingPaykitPaymentProof( identity: identity, requestId: request.id, + paymentAppId: "bitkit", paymentEndpointIdentifier: endpoint, kind: .onchain, paymentStarted: true, @@ -659,6 +693,7 @@ final class PaykitPaymentProofServiceTests: XCTestCase { let proof = PendingPaykitPaymentProof( identity: identity, requestId: request.id, + paymentAppId: "bitkit", paymentEndpointIdentifier: endpoint, kind: .onchain, billingPeriod: request.billingPeriod, @@ -694,6 +729,7 @@ final class PaykitPaymentProofServiceTests: XCTestCase { let originalProof = PendingPaykitPaymentProof( identity: identity, requestId: request.id, + paymentAppId: "bitkit", paymentEndpointIdentifier: endpoint, kind: .onchain, paymentStarted: true, @@ -704,6 +740,7 @@ final class PaykitPaymentProofServiceTests: XCTestCase { let replacementProof = PendingPaykitPaymentProof( identity: identity, requestId: request.id, + paymentAppId: "bitkit", paymentEndpointIdentifier: endpoint, kind: .onchain, paymentStarted: true, @@ -735,6 +772,7 @@ final class PaykitPaymentProofServiceTests: XCTestCase { let proof = PendingPaykitPaymentProof( identity: identity, requestId: request.id, + paymentAppId: "bitkit", paymentEndpointIdentifier: endpoint, kind: .onchain, paymentStarted: true, @@ -776,6 +814,7 @@ final class PaykitPaymentProofServiceTests: XCTestCase { let proof = PendingPaykitPaymentProof( identity: identity, requestId: request.id, + paymentAppId: "bitkit", paymentEndpointIdentifier: endpoint, kind: .onchain, billingPeriod: request.billingPeriod, @@ -809,6 +848,7 @@ final class PaykitPaymentProofServiceTests: XCTestCase { let foreignProof = PendingPaykitPaymentProof( identity: identity, requestId: request.id, + paymentAppId: "bitkit", paymentEndpointIdentifier: endpoint, kind: .onchain, paymentStarted: false, @@ -826,11 +866,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 +897,7 @@ final class PaykitPaymentProofServiceTests: XCTestCase { let onchainProof = PendingPaykitPaymentProof( identity: identity, requestId: onchainRequest.id, + paymentAppId: "bitkit", paymentEndpointIdentifier: onchainEndpoint, kind: .onchain, paymentStarted: true, @@ -868,6 +910,7 @@ final class PaykitPaymentProofServiceTests: XCTestCase { let lightningProof = PendingPaykitPaymentProof( identity: identity, requestId: lightningRequest.id, + paymentAppId: "bitkit", paymentEndpointIdentifier: lightningEndpoint, kind: .lightning, paymentStarted: true, @@ -910,7 +953,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 +968,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 +985,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 +1014,7 @@ final class PaykitPaymentProofServiceTests: XCTestCase { try await service.prepare( request: request, + paymentAppId: "bitkit", paymentEndpointIdentifier: PublicPaykitService.MethodId.bitcoinLightningBolt11.rawValue, kind: .lightning ) @@ -1011,6 +1055,7 @@ final class PaykitPaymentProofServiceTests: XCTestCase { try await service.prepare( request: request, + paymentAppId: "bitkit", paymentEndpointIdentifier: PublicPaykitService.MethodId.bitcoinLightningBolt11.rawValue, kind: .lightning ) @@ -1032,6 +1077,7 @@ final class PaykitPaymentProofServiceTests: XCTestCase { try await service.prepare( request: request, + paymentAppId: "bitkit", paymentEndpointIdentifier: PublicPaykitService.MethodId.bitcoinLightningBolt11.rawValue, kind: .lightning ) @@ -1039,6 +1085,7 @@ final class PaykitPaymentProofServiceTests: XCTestCase { do { try await service.prepare( request: request, + paymentAppId: "bitkit", paymentEndpointIdentifier: PublicPaykitService.MethodId.bitcoinLightningBolt11.rawValue, kind: .lightning ) @@ -1054,6 +1101,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 +1121,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 +1151,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 +1178,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 +1206,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 +1266,6 @@ final class PaykitPaymentProofServiceTests: XCTestCase { ) throws -> PaymentRequestRecord { try PaymentRequestRecord( counterparty: counterparty, - counterpartyReceiverPath: PaykitReceiverPath.wallet, paymentRequestId: paymentRequestId, localRole: .payer, state: state, @@ -1223,12 +1273,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 +1317,7 @@ final class PaykitPaymentProofServiceTests: XCTestCase { streamItemId: 2, paymentReference: PaymentReference(text: "invoice-123"), billingPeriod: billingPeriod, + paymentAppId: "bitkit", paymentEndpointIdentifier: endpoint, allowanceId: nil, conversionQuoteId: nil, @@ -1373,7 +1429,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 +1472,6 @@ private actor PaymentProofSdkMock: PaykitPaymentProofSdkHandling { func submitPaymentProof( counterparty: String, - counterpartyReceiverPath: String, paymentRequestId: String, proof: PaymentProofSubmission ) async throws -> PaymentRequestRecord { @@ -1434,7 +1489,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 +1500,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/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 d6715cd61..111a513dc 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,626 @@ import XCTest @MainActor final class PaykitPaymentRequestServiceTests: XCTestCase { + 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(mode: mode) + + 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 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) + } + } + } + + 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 + + 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(mode: .stored, 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) + 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 testCanceledRefreshDoesNotUpgradeInFlightStoredRefresh() async throws { + let sdk = try PaymentRequestSdkMock(records: [paymentRequestRecord()]) + let manager = paymentRequestManager(sdk: sdk) + await sdk.pauseNextPaymentRequestList() + let storedRefresh = Task { await manager.refresh(mode: .stored) } + 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) + + fullRefresh.cancel() + await sdk.resumePaymentRequestList() + await storedRefresh.value + await fullRefresh.value + + XCTAssertEqual(manager.pendingRequests.count, 1) + let snapshot = await sdk.snapshot() + XCTAssertEqual(snapshot.processCallCount, 0) + XCTAssertEqual(snapshot.receiveCallCount, 0) + 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) + 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 = "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)] + 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 = 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") + } + 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 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" + 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, allowUsedOnchainAddress in + XCTAssertFalse(allowUsedOnchainAddress) + 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: { endpoints, _ in endpoints } + ) + 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: { 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( + 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 +651,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 +689,6 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { let requestId = PaykitPaymentRequest.ID( paymentRequestId: "subscription", counterparty: "pubkypayee", - counterpartyReceiverPath: PaykitReceiverPath.server, billingPeriodStartsAt: startsAt ) @@ -234,7 +853,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], @@ -273,7 +892,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], @@ -345,7 +964,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], @@ -379,7 +998,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( @@ -437,8 +1056,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) @@ -473,8 +1092,8 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { XCTAssertTrue(subscription.runsUntilPaidThrough(at: now)) 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.subscriptions.isEmpty) @@ -490,12 +1109,12 @@ 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( - peers: [linkedPeer(counterparty: record.counterparty, path: record.counterpartyReceiverPath, state: .blocked)], - receiverPathsByPublicKey: [:] + peers: [linkedPeer(counterparty: record.counterparty, state: .blocked)], + requestCapabilitiesByPublicKey: [:] ) var consumed = false do { @@ -521,8 +1140,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 @@ -754,9 +1373,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)) @@ -822,7 +1441,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([ @@ -840,7 +1459,7 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { ) } - func testRefreshMapsActiveRecurringRequestAndCurrentUnpaidPeriod() async throws { + func testStoredRefreshMapsActiveRecurringRequestAndCurrentUnpaidPeriod() async throws { let now = try XCTUnwrap(ISO8601DateFormatter().date(from: "2027-01-15T08:00:00Z")) let recurrence = PaymentRequestRecurrence( every: 1, @@ -855,13 +1474,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(mode: .stored) + 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") @@ -873,6 +1493,128 @@ 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) + } + + func clear() { + ids = [] + } + } + + 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) + 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() + + 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 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(mode: .stored) + 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( @@ -1026,13 +1768,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)) @@ -1078,8 +1819,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)) @@ -1129,8 +1870,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) @@ -1150,11 +1891,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() @@ -1181,8 +1922,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]) @@ -1351,13 +2092,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( @@ -1467,7 +2207,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() @@ -1491,7 +2231,6 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { let second = try paymentRequestRecord( id: "shared", counterparty: "second", - counterpartyReceiverPath: PaykitReceiverPath.wallet, recurrence: recurrence ) let manager = paymentRequestManager( @@ -1505,7 +2244,6 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { let dueRequest = try XCTUnwrap(acceptedRequest) XCTAssertEqual(dueRequest.counterparty, "second") - XCTAssertEqual(dueRequest.counterpartyReceiverPath, PaykitReceiverPath.wallet) } func testAcceptingSubscriptionRejectsTermsChangedAfterReview() async throws { @@ -1603,6 +2341,7 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { logWarning: { _ in } ), presentationStore: PaymentRequestPresentationMemoryStore(), + acceptanceStore: PaymentRequestPresentationMemoryStore(), subscriptionStateStore: subscriptionStore, isAvailable: { true }, logWarning: { _ in } @@ -1637,6 +2376,7 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { let manager = PaykitPaymentRequestManager( service: PaykitPaymentRequestService(sdk: sdk, logWarning: { _ in }), presentationStore: PaymentRequestPresentationMemoryStore(), + acceptanceStore: PaymentRequestPresentationMemoryStore(), subscriptionStateStore: PaymentRequestSubscriptionStateMemoryStore(), isAvailable: { true }, logWarning: { _ in } @@ -1662,22 +2402,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 { @@ -1834,6 +2579,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 }, @@ -2614,7 +3360,6 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { await sdk.setReceiveReports([ PrivateStreamCounterpartyIntakeReport( counterparty: record.counterparty, - counterpartyReceiverPath: record.counterpartyReceiverPath, report: nil, error: PaymentRequestIntakeError(noPointer: .init()) ), @@ -2632,6 +3377,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, @@ -2694,12 +3440,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 } ) @@ -2721,6 +3474,7 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { let restoredManager = PaykitPaymentRequestManager( service: PaykitPaymentRequestService(sdk: sdk, logWarning: { _ in }), presentationStore: presentationStore, + acceptanceStore: acceptanceStore, subscriptionStateStore: subscriptionStore, logWarning: { _ in } ) @@ -2737,18 +3491,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) } @@ -2805,6 +3564,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 @@ -2827,6 +3590,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 { @@ -3345,12 +4114,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) @@ -3438,7 +4209,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, @@ -3454,7 +4225,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) } @@ -3478,6 +4249,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) @@ -3494,7 +4266,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) @@ -3506,7 +4278,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, 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 { @@ -3520,7 +4297,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, 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 @@ -3807,8 +4589,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) @@ -3821,8 +4604,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) @@ -3833,6 +4617,253 @@ 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 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()]) + 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)) + 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() + 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 testInterruptedAcceptanceCanResumeAfterRefresh() async throws { + 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]) + 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]) + 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 { + 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) @@ -3924,26 +4955,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) @@ -3958,7 +4983,6 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { snapshot.acceptedRequests, [PaymentRequestInvocation( counterparty: request.counterparty, - counterpartyReceiverPath: request.counterpartyReceiverPath, paymentRequestId: request.paymentRequestId )] ) @@ -4057,6 +5081,7 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { let firstManager = PaykitPaymentRequestManager( service: PaykitPaymentRequestService(sdk: sdk, logWarning: { _ in }), presentationStore: store, + acceptanceStore: PaymentRequestPresentationMemoryStore(), subscriptionStateStore: subscriptionStore, logWarning: { _ in } ) @@ -4068,6 +5093,7 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { let restoredManager = PaykitPaymentRequestManager( service: PaykitPaymentRequestService(sdk: sdk, logWarning: { _ in }), presentationStore: store, + acceptanceStore: PaymentRequestPresentationMemoryStore(), subscriptionStateStore: subscriptionStore, logWarning: { _ in } ) @@ -4095,6 +5121,7 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { let firstManager = PaykitPaymentRequestManager( service: PaykitPaymentRequestService(sdk: sdk, logWarning: { _ in }), presentationStore: presentationStore, + acceptanceStore: PaymentRequestPresentationMemoryStore(), subscriptionStateStore: subscriptionStore, logWarning: { _ in } ) @@ -4106,6 +5133,7 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { let restoredManager = PaykitPaymentRequestManager( service: PaykitPaymentRequestService(sdk: sdk, logWarning: { _ in }), presentationStore: presentationStore, + acceptanceStore: PaymentRequestPresentationMemoryStore(), subscriptionStateStore: subscriptionStore, logWarning: { _ in } ) @@ -4137,19 +5165,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) @@ -4158,16 +5186,87 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { XCTAssertEqual( manager.eligibleTargets, - [PaykitPaymentRequestTarget(publicKey: savedKey, receiverPath: PaykitReceiverPath.server)] + [PaykitPaymentRequestTarget(publicKey: savedKey)] ) } + 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: []) 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) @@ -4181,8 +5280,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) @@ -4196,8 +5295,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) @@ -4208,16 +5307,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]) @@ -4225,11 +5324,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]) @@ -4245,30 +5344,67 @@ 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] + ) + } + + 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 testOnlyTheRefreshOfEverySavedContactReadsCapabilitiesInBulk() async throws { @@ -4276,13 +5412,12 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { let expiresAt = Date(timeIntervalSince1970: 1_900_000_000) 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] ) try await sdk.setProposalResult(paymentRequestRecord( id: "outgoing", counterparty: savedKey, - counterpartyReceiverPath: PaykitReceiverPath.wallet, role: .payee, expiresAt: timestamp(expiresAt) )) @@ -4295,7 +5430,7 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { to: XCTUnwrap(target) ) - let priorities = await sdk.receiverPathReadPriorities() + let priorities = await sdk.capabilityReadPriorities() XCTAssertEqual( priorities, [.bulk, .interactive, .interactive], @@ -4307,12 +5442,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) @@ -4324,8 +5459,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) @@ -4339,8 +5474,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) @@ -4356,26 +5491,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) @@ -4390,12 +5525,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) @@ -4406,16 +5541,68 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { XCTAssertEqual( manager.eligibleTargets, - [PaykitPaymentRequestTarget(publicKey: keptKey, receiverPath: PaykitReceiverPath.wallet)] + [PaykitPaymentRequestTarget(publicKey: keptKey)] ) } + 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 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)], + requestCapabilitiesByPublicKey: [savedKey: true] + ) + await sdk.pauseNextLinkedPeers() + let manager = paymentRequestManager(sdk: sdk) + + let fullRefresh = Task { + await manager.refreshEligibleTargets(savedPublicKeys: [savedKey, otherKey]) + } + try await waitUntil { await sdk.linkedPeersIsPaused() } + manager.updateSavedPublicKeys([otherKey, 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: []) 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]) @@ -4425,7 +5612,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 @@ -4438,8 +5625,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]) @@ -4449,7 +5636,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 @@ -4463,14 +5650,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() @@ -4480,10 +5667,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() @@ -4492,8 +5679,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), ] ) } @@ -4502,8 +5689,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]) @@ -4513,17 +5700,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) } @@ -4532,8 +5719,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]) @@ -4546,25 +5733,53 @@ 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()) 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 { @@ -4572,8 +5787,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) @@ -4583,8 +5798,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() @@ -4592,7 +5807,7 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { XCTAssertEqual( manager.eligibleTargets, - [PaykitPaymentRequestTarget(publicKey: secondKey, receiverPath: PaykitReceiverPath.wallet)] + [PaykitPaymentRequestTarget(publicKey: secondKey)] ) } @@ -4601,13 +5816,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) )) @@ -4625,7 +5839,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) @@ -4636,37 +5849,311 @@ 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 testProposeRemainsCreatedWhenImmediateDeliveryFails() 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))" + 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, 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 } + ) + + 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) + 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?, 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, 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 } + ) + 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, failedMessageId == 7 ? 0 : 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, 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) - )) - await sdk.failNextProcess() + 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() - _ = try await manager.propose( - PaykitPaymentRequestDraft(amountSats: 1, note: "", expiresAt: expiresAt), - to: XCTUnwrap(manager.eligibleTargets.first) - ) - - XCTAssertEqual(manager.outgoingRequests.map(\.paymentRequestId), ["outgoing"]) + 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.processCallCount, 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))" + 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) + 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]) + + 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) + XCTAssertEqual(snapshot.processCallCount, 0) + XCTAssertEqual(snapshot.processedCounterparties, [publicKey]) + XCTAssertEqual(snapshot.paymentRequestListCallCount, cancelled ? 0 : 1) + } } func testProposeRevalidatesTargetBeforeEnqueueing() async throws { @@ -4674,13 +6161,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) )) @@ -4688,7 +6174,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( @@ -4708,8 +6194,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]) @@ -4733,13 +6219,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) )) @@ -4769,13 +6254,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) )) @@ -4811,13 +6295,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 @@ -4825,7 +6308,6 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { await sdk.setProcessReports([ OutboundPrivateCounterpartySendReport( counterparty: publicKey, - counterpartyReceiverPath: PaykitReceiverPath.wallet, report: OutboundPrivateSendReport( attempted: [messageId], sent: [messageId], @@ -4867,6 +6349,7 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { let manager = PaykitPaymentRequestManager( service: PaykitPaymentRequestService(sdk: sdk, logWarning: { _ in }), presentationStore: store, + acceptanceStore: PaymentRequestPresentationMemoryStore(), subscriptionStateStore: PaymentRequestSubscriptionStateMemoryStore(), isAvailable: { true }, logWarning: { _ in } @@ -4897,9 +6380,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 subscriptionNow: (@Sendable () -> Date)? = subscriptionClock.map { subscriptionClock in { subscriptionClock.now() } } let manager = PaykitPaymentRequestManager( service: PaykitPaymentRequestService( @@ -4910,6 +6398,7 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { logWarning: { _ in } ), presentationStore: PaymentRequestPresentationMemoryStore(), + acceptanceStore: acceptanceStore, subscriptionStateStore: subscriptionStateStore, subscriptionNotificationScheduler: subscriptionNotificationScheduler, completedPaymentProofKinds: { _ in completedPaymentProofKinds }, @@ -4947,7 +6436,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", @@ -4956,6 +6444,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, @@ -4967,7 +6456,6 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { ) throws -> PaymentRequestRecord { try PaymentRequestRecord( counterparty: counterparty, - counterpartyReceiverPath: counterpartyReceiverPath, paymentRequestId: id, localRole: role, state: state, @@ -4975,12 +6463,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) @@ -5013,6 +6506,7 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { streamItemId: 2, paymentReference: PaymentReference(text: "invoice-123"), billingPeriod: billingPeriod, + paymentAppId: "bitkit", paymentEndpointIdentifier: endpoint, allowanceId: nil, conversionQuoteId: nil, @@ -5027,10 +6521,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, @@ -5044,14 +6537,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 - func load(identity: String) -> Set { - states[identity] ?? [] + init(ids: Set = []) { + states["pubky\(String(repeating: "z", count: 52))"] = ids } - func save(_ ids: Set, identity: String) { + func load(identity: String) throws -> Set { + if shouldFailLoad { throw PaymentRequestSdkMockError.process } + return states[identity] ?? [] + } + + func save(_ ids: Set, identity: String) throws { + if shouldFailSave { throw PaymentRequestSdkMockError.process } states[identity] = ids } } @@ -5094,25 +6595,33 @@ 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 paymentRequestListError: Error? 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 receiverPathLookupPriorities: [PaykitPublicReadPriority] = [] - private var failingReceiverPathKeys: Set = [] + 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 capabilityLookupPriorities: [PaykitPublicReadPriority] = [] private var proposalResult: PaymentRequestRecord? private var uploadCount = 0 private var shouldPauseNextUpload = false 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? @@ -5120,6 +6629,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 @@ -5141,7 +6652,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 } @@ -5151,7 +6677,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 } @@ -5161,10 +6687,17 @@ private actor PaymentRequestSdkMock: PaykitPaymentRequestSdkHandling, PaykitPaym if let receiveError { throw receiveError } + records.append(contentsOf: incomingRecords) + incomingRecords = [] return receiveReports } func paymentRequests() async -> [PaymentRequestRecord] { + await sharedPaymentRequests().filter(PaykitSdkService.isBitkitPaymentRequest) + } + + func sharedPaymentRequests() async -> [PaymentRequestRecord] { + paymentRequestListCallCount += 1 let snapshot = records guard shouldPauseNextPaymentRequestList else { return snapshot } @@ -5175,13 +6708,24 @@ 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, liveSessionAvailable: liveSessionAvailable) + IdentityStatus(publicKey: activeIdentity, capability: liveSessionAvailable ? .privateLinkCapable : .signedOut) } func submitPaymentProof( counterparty _: String, - counterpartyReceiverPath _: String, paymentRequestId _: String, proof _: PaymentProofSubmission ) throws -> PaymentRequestRecord { @@ -5191,9 +6735,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 @@ -5201,16 +6743,21 @@ private actor PaymentRequestSdkMock: PaykitPaymentRequestSdkHandling, PaykitPaym await withCheckedContinuation { linkedPeersContinuation = $0 } isLinkedPeersPaused = false } + if let error { throw error } return snapshot } - func paymentRequestReceiverPaths(publicKey: String, priority: PaykitPublicReadPriority) throws -> [String] { - receiverPathLookupCount += 1 - receiverPathLookupPriorities.append(priority) - if failingReceiverPathKeys.contains(publicKey) { + func canReceivePaymentRequests(publicKey: String, priority: PaykitPublicReadPriority) async throws -> Bool { + capabilityLookupPublicKeys.append(publicKey) + capabilityLookupPriorities.append(priority) + activeCapabilityLookups += 1 + maxConcurrentCapabilityLookups = max(maxConcurrentCapabilityLookups, activeCapabilityLookups) + defer { activeCapabilityLookups -= 1 } + await capabilityLookupGate?(publicKey) + if failingCapabilityKeys.contains(publicKey) { throw PaymentRequestSdkMockError.receive } - return receiverPathsByPublicKey[publicKey] ?? [] + return requestCapabilitiesByPublicKey[publicKey] ?? false } func pauseNextUpload() { @@ -5242,7 +6789,6 @@ private actor PaymentRequestSdkMock: PaykitPaymentRequestSdkHandling, PaykitPaym func proposePaymentRequest( counterparty: String, - counterpartyReceiverPath: String, terms: PaymentRequestTerms, expectedIdentity: String ) async throws -> PaymentRequestRecord { @@ -5259,12 +6805,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, @@ -5275,9 +6819,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 { @@ -5288,9 +6841,11 @@ 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.counterpartyReceiverPath == counterpartyReceiverPath && $0.paymentRequestId == paymentRequestId && $0.terms?.recurrence != nil }) { @@ -5299,17 +6854,16 @@ private actor PaymentRequestSdkMock: PaykitPaymentRequestSdkHandling, PaykitPaym } else { record = try removeRecord( counterparty: counterparty, - counterpartyReceiverPath: counterpartyReceiverPath, id: paymentRequestId ) } + if let acceptanceResponseError { throw acceptanceResponseError } if acceptFailuresAfterRemoval > 0 { acceptFailuresAfterRemoval -= 1 throw PaymentRequestSdkMockError.process } acceptedRequests.append(PaymentRequestInvocation( counterparty: counterparty, - counterpartyReceiverPath: counterpartyReceiverPath, paymentRequestId: paymentRequestId )) return record @@ -5317,13 +6871,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 { @@ -5332,7 +6884,6 @@ private actor PaymentRequestSdkMock: PaykitPaymentRequestSdkHandling, PaykitPaym } rejectedRequests.append(PaymentRequestInvocation( counterparty: counterparty, - counterpartyReceiverPath: counterpartyReceiverPath, paymentRequestId: paymentRequestId )) return record @@ -5340,13 +6891,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 ) } @@ -5355,8 +6904,13 @@ private actor PaymentRequestSdkMock: PaykitPaymentRequestSdkHandling, PaykitPaym processFailuresRemaining += 1 } - func pauseNextProcess() { + func setAcceptanceResponseError(_ error: Error) { + acceptanceResponseError = error + } + + func pauseNextProcess(for counterparty: String? = nil) { shouldPauseNextProcess = true + pausedProcessCounterparty = counterparty } func processIsPaused() -> Bool { @@ -5410,12 +6964,16 @@ private actor PaymentRequestSdkMock: PaykitPaymentRequestSdkHandling, PaykitPaym self.records = records } + func setIncomingRecords(_ records: [PaymentRequestRecord]) { + incomingRecords = records + } + func configureRecipients( peers: [LinkedPeerRecord], - receiverPathsByPublicKey: [String: [String]] + requestCapabilitiesByPublicKey: [String: Bool] ) { peerRecords = peers - self.receiverPathsByPublicKey = receiverPathsByPublicKey + self.requestCapabilitiesByPublicKey = requestCapabilitiesByPublicKey } func setLiveSessionAvailable(_ value: Bool) { @@ -5426,23 +6984,27 @@ private actor PaymentRequestSdkMock: PaykitPaymentRequestSdkHandling, PaykitPaym linkedPeersCallCount } - func receiverPathLookups() -> Int { - receiverPathLookupCount + func capabilityLookups() -> Int { + capabilityLookupPublicKeys.count + } + + func setCapabilityLookupGate(_ gate: @escaping @Sendable (String) async -> Void) { + capabilityLookupGate = gate } - func receiverPathReadPriorities() -> [PaykitPublicReadPriority] { - receiverPathLookupPriorities + func capabilityReadPriorities() -> [PaykitPublicReadPriority] { + capabilityLookupPriorities } 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) } } @@ -5496,7 +7058,9 @@ private actor PaymentRequestSdkMock: PaykitPaymentRequestSdkHandling, PaykitPaym PaymentRequestSdkSnapshot( uploadCount: uploadCount, processCallCount: processCallCount, + processedCounterparties: processedCounterparties, receiveCallCount: receiveCallCount, + paymentRequestListCallCount: paymentRequestListCallCount, acceptedRequests: acceptedRequests, rejectedRequests: rejectedRequests, proposedRequests: proposedRequests @@ -5505,12 +7069,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 @@ -5522,7 +7084,9 @@ private actor PaymentRequestSdkMock: PaykitPaymentRequestSdkHandling, PaykitPaym private struct PaymentRequestSdkSnapshot { let uploadCount: Int let processCallCount: Int + let processedCounterparties: [String] let receiveCallCount: Int + let paymentRequestListCallCount: Int let acceptedRequests: [PaymentRequestInvocation] let rejectedRequests: [PaymentRequestInvocation] let proposedRequests: [ProposedPaymentRequestInvocation] @@ -5530,7 +7094,6 @@ private struct PaymentRequestSdkSnapshot { private struct ProposedPaymentRequestInvocation { let counterparty: String - let counterpartyReceiverPath: String let amount: String let asset: String let expiresAt: String? @@ -5541,7 +7104,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..53e080c3f 100644 --- a/BitkitTests/PaykitPaymentStateBackupTests.swift +++ b/BitkitTests/PaykitPaymentStateBackupTests.swift @@ -24,10 +24,11 @@ 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 { - 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 +36,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 +45,7 @@ final class PaykitPaymentStateBackupTests: XCTestCase { let proof = PendingPaykitPaymentProof( identity: identity, requestId: requestId, + paymentAppId: "bitkit", paymentEndpointIdentifier: "bitcoin-onchain", kind: .onchain, billingPeriod: period, @@ -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() @@ -198,7 +206,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 +245,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 +298,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/PaykitPublicReadLaneTests.swift b/BitkitTests/PaykitPublicReadLaneTests.swift index afef45fcd..ef9bee796 100644 --- a/BitkitTests/PaykitPublicReadLaneTests.swift +++ b/BitkitTests/PaykitPublicReadLaneTests.swift @@ -12,20 +12,9 @@ final class PaykitPublicReadLaneTests: XCTestCase { let resolution = try await service.resolveContactProfile(publicKey: publicKey, allowPubkyProfileFallback: true, priority: .bulk) XCTAssertNil(resolution, "profile lookup") }), - ("receiver discovery", { service, publicKey in - let paths = try await service.discoverRelevantReceiverPaths(publicKey: publicKey, priority: .bulk) - XCTAssertEqual(paths, [PaykitReceiverPath.wallet, PaykitReceiverPath.server], "receiver discovery") - }), - ("payment request receiver paths", { service, publicKey in - let paths = try await service.paymentRequestReceiverPaths(publicKey: publicKey, priority: .bulk) - XCTAssertEqual(paths, [PaykitReceiverPath.server], "payment request receiver paths") - }), - ("private receiver path selection", { service, publicKey in - let selection = try await service.privateReceiverPathSelection(publicKey: publicKey, savedReceiverPaths: [PaykitReceiverPath.server]) - XCTAssertEqual(selection.linkableReceiverPaths, [PaykitReceiverPath.server], "private receiver path selection") - XCTAssertEqual(selection.publishableReceiverPaths, [PaykitReceiverPath.server], "private receiver path selection") - XCTAssertEqual(selection.cleanupProtectedReceiverPaths, [], "private receiver path selection") - XCTAssertNil(selection.error, "private receiver path selection") + ("payment request capability", { service, publicKey in + let canReceive = try await service.canReceivePaymentRequests(publicKey: publicKey, priority: .bulk) + XCTAssertTrue(canReceive) }), ] for testCase in cases { @@ -59,22 +48,6 @@ final class PaykitPublicReadLaneTests: XCTestCase { } } - func testPrivateReceiverPathSelectionWithoutAnSdkProtectsEverySavedPath() async throws { - let service = PaykitSdkService(sdkFactory: { throw PubkyServiceError.sessionNotActive }) - - let selection = try await service.privateReceiverPathSelection( - publicKey: "contact", - savedReceiverPaths: [PaykitReceiverPath.server] - ) - - XCTAssertEqual(selection.linkableReceiverPaths, []) - XCTAssertEqual(selection.publishableReceiverPaths, []) - XCTAssertEqual(selection.cleanupProtectedReceiverPaths, [PaykitReceiverPath.wallet, PaykitReceiverPath.server]) - guard case .sessionNotActive? = selection.error as? PubkyServiceError else { - return XCTFail("Expected sessionNotActive, got \(String(describing: selection.error))") - } - } - func testPublicReadWaitingToBuildAnSdkWhenAWipeStartsFailsWithoutBuildingOne() async throws { let sdk = WipeRaceSdk(noPointer: .init()) await sdk.gate.open() @@ -139,8 +112,8 @@ final class PaykitPublicReadLaneTests: XCTestCase { XCTAssertEqual(code, "wallet_wipe_in_progress") } do { - _ = try await service.privateReceiverPathSelection(publicKey: "follower", savedReceiverPaths: [PaykitReceiverPath.server]) - XCTFail("Expected a receiver path selection during a wipe to be rejected") + _ = try await service.canReceivePaymentRequests(publicKey: "follower") + XCTFail("Expected a capability read during a wipe to be rejected") } catch let PaykitError.Storage(code, _) { XCTAssertEqual(code, "wallet_wipe_in_progress") } @@ -222,11 +195,10 @@ private final class PublicReadLaneSdk: PaykitSdk, @unchecked Sendable { let log = PublicReadLog() let gate = PublicReadGate() - override func resolveContactProfile( + override func resolveProfile( publicKey: String, - receiverPath _: String, allowPubkyProfileFallback _: Bool - ) async throws -> ContactProfileResolution? { + ) async throws -> ProfileResolution? { await log.record("profile:\(publicKey)") await gate.wait() return nil @@ -237,21 +209,16 @@ private final class PublicReadLaneSdk: PaykitSdk, @unchecked Sendable { return Data() } - override func paykitReceiverPaths(publicKey: String) async throws -> [String] { - await log.record("paths:\(publicKey)") - await gate.wait() - return [PaykitReceiverPath.wallet, PaykitReceiverPath.server] - } - - /// The wallet path has no marker; the server path takes private payments and payment requests. - override func paykitReceiverMarker(publicKey: String, receiverPath: String) async throws -> PaykitReceiverMarker? { - await log.record("marker:\(publicKey):\(receiverPath)") + override func paykitAppRegistry(publicKey: String) async throws -> PaykitAppRegistry? { + await log.record("registry:\(publicKey)") await gate.wait() - guard receiverPath == PaykitReceiverPath.server else { return nil } - return PaykitReceiverMarker( - receiverPath: receiverPath, - capabilities: PaykitReceiverCapabilities(privatePayments: true, paymentRequests: true, receipts: false, outgoingPayments: true), - noisePublicKey: "noise" + return PaykitAppRegistry( + keyGeneration: 1, noisePublicKey: nil, + apps: [PaykitApp( + appId: "bitkit", displayName: "Bitkit", + capabilities: PaykitAppCapabilities(privatePayments: true, paymentRequests: true, receipts: false, outgoingPayments: true) + )], + defaultAppId: nil, defaultAppsByEndpoint: [:] ) } @@ -299,14 +266,14 @@ private final class WipeRaceSdk: PaykitSdk, @unchecked Sendable { let log = PublicReadLog() let gate = PublicReadGate() - override func fetchPubkyFollows(publicKey: String) async throws -> [String] { + override func fetchPubkyFollows(publicKey: String, maxEntries _: UInt64) async throws -> [String] { await log.record("follows:\(publicKey)") await gate.wait() return ["follow"] } - override func paykitReceiverMarker(publicKey: String, receiverPath: String) async throws -> PaykitReceiverMarker? { - await log.record("marker:\(publicKey):\(receiverPath)") + override func paykitAppRegistry(publicKey: String) async throws -> PaykitAppRegistry? { + await log.record("registry:\(publicKey)") return nil } diff --git a/BitkitTests/PaykitReceivedPaymentContactsTests.swift b/BitkitTests/PaykitReceivedPaymentContactsTests.swift new file mode 100644 index 000000000..333604885 --- /dev/null +++ b/BitkitTests/PaykitReceivedPaymentContactsTests.swift @@ -0,0 +1,319 @@ +@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 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) + 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 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 + } + let afterSkippedScan = try await service.getActivity(id: activity.activityId) + XCTAssertEqual(afterSkippedScan, persisted) + + let hardwareWalletId = try HwWalletId.derive(xpubs: ["nativeSegwit": "xpub-\(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) + 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 { + 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, 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, + 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: [identifier], + paymentEndpoints: [identifier: 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/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 5cef00605..65512ed48 100644 --- a/BitkitTests/PaykitSdkClientConfigTests.swift +++ b/BitkitTests/PaykitSdkClientConfigTests.swift @@ -8,22 +8,45 @@ 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, .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) { - 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 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 +67,295 @@ 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") + } + } + } + + @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) + } + + @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) } 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 +363,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,23 +381,26 @@ 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) } } @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 + 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,17 +413,13 @@ 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.removeObject(forKey: metadataKeys[2]) defaults.set("Original profile", forKey: metadataKeys[0]) defaults.set("pubky://original/avatar", forKey: metadataKeys[1]) ContactsManager.restoreContactProfileOverrides(overrides) @@ -123,13 +429,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", "authorize"]) if previousKey == differentKey { XCTAssertNil(manager.displayName) XCTAssertNil(manager.displayImageUri) @@ -142,6 +451,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 @@ -150,12 +467,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 +491,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,47 +504,53 @@ 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", "authorize"] { + 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 + 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) + 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) + 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") + 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 testActivationReturnsWhileIdentityRepublishIsStillRunning() async throws { + 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 { @@ -238,14 +562,7 @@ 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 publicKey = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" + try Keychain.delete(key: .paykitKeyGeneration(publicKey: publicKey)) let republishStarted = expectation(description: "Republish started") let republishFinished = expectation(description: "Republish finished") let activationReturned = expectation(description: "Activation returned") @@ -263,8 +580,8 @@ final class PaykitSdkClientConfigTests: XCTestCase { sdk.previousKey = publicKey let service = PaykitSdkService(sdkFactory: { sdk }) { _, _ in bootstrap } let session = CacheActivationSession(noPointer: .init()) - session.noiseBytes = noiseBytes - let result = PubkySessionBootstrapResult(sessionAccess: session, publicKey: publicKey) + session.localSecretKey = try PaykitSdkService.localSecretKey(fromHex: secret) + let result = PubkySessionBootstrapResult(sessionAccess: session, publicKey: publicKey, capability: .privateLinkCapable) let activation = Task { try await service.activateRegisteredIdentity(.init(result: result, walletGeneration: 0)) @@ -280,12 +597,15 @@ final class PaykitSdkClientConfigTests: XCTestCase { } 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) @@ -294,18 +614,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() @@ -313,9 +628,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") @@ -331,6 +646,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() @@ -435,18 +751,92 @@ 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? + 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 authorizationError: Error? + 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, liveSessionAvailable: false) + IdentityStatus(publicKey: previousKey, capability: capability) + } + + 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: capability) + } + + 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 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" + } + + override func stateRevision() throws -> String? { + "state" } } @@ -456,12 +846,15 @@ private final class CacheActivationBootstrap: PubkySessionBootstrap, @unchecked 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 { @@ -471,51 +864,55 @@ 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 } } private final class RecoverySdk: PaykitSdk, @unchecked Sendable { var onForget: () -> Void = {} + var initializationCalls = 0 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 { + initializationCalls += 1 + return 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" } + override func stateRevision() throws -> String? { + nil + } + override func forgetSessionAccess() async throws -> IdentityStatus { onForget() try Keychain.delete(key: .paykitSession) try Keychain.delete(key: .pubkySecretKey) - return IdentityStatus(publicKey: nil, liveSessionAvailable: false) + return IdentityStatus(publicKey: nil, capability: .signedOut) } } @@ -525,14 +922,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/PaykitSdkIdentityCheckTests.swift b/BitkitTests/PaykitSdkIdentityCheckTests.swift index aceccf1b2..08df9bec9 100644 --- a/BitkitTests/PaykitSdkIdentityCheckTests.swift +++ b/BitkitTests/PaykitSdkIdentityCheckTests.swift @@ -301,16 +301,20 @@ private final class IdentitySwitchingSdk: PaykitSdk, @unchecked Sendable { } override func identityStatus() async throws -> IdentityStatus? { - IdentityStatus(publicKey: identity, liveSessionAvailable: identity != nil && hasLiveSession) + IdentityStatus(publicKey: identity, capability: identity != nil && hasLiveSession ? .publicOnly : .signedOut) } override func backupStateRevision() async throws -> String { "revision" } + override func stateRevision() throws -> String? { + nil + } + override func signOut() async throws -> IdentityStatus { identity = nil - return IdentityStatus(publicKey: nil, liveSessionAvailable: false) + return IdentityStatus(publicKey: nil, capability: .signedOut) } override func contactRecords() async throws -> [ContactRecord] { @@ -342,16 +346,26 @@ private final class IdentitySwitchingSdk: PaykitSdk, @unchecked Sendable { ) } - override func publishPaykitProfile(profile: PaykitProfile) async throws -> PaykitProfileRecord { + override func fetchPaykitProfile(publicKey _: String) async throws -> PaykitProfileRecord? { + nil + } + + override func publishPaykitProfile(profile: PaykitProfile, expectedRevision _: String?) async throws -> PaykitProfileRecord { recordWrite("profile:\(profile.displayName ?? "")") - return PaykitProfileRecord(publicKey: identity ?? "", profile: profile, path: "/pub/paykit/profile.json", updatedAt: "2026-10-02T00:00:00Z") + return PaykitProfileRecord( + publicKey: identity ?? "", + profile: profile, + path: "/pub/paykit/profile.json", + revision: "test", + updatedAt: "2026-10-02T00:00:00Z" + ) } private static func record(publicKey: String, label: String?) -> ContactRecord { ContactRecord( - publicKey: publicKey, receiverPaths: [PaykitReceiverPath.wallet], label: label, profile: nil, + publicKey: publicKey, label: label, profile: nil, profileFetchedAt: nil, createdAt: "2026-10-02T00:00:00Z", updatedAt: "2026-10-02T00:00:00Z", - publicContactMarkerStatus: .notPublished, publicContactMarkerReceiverPath: nil, + publicContactMarkerStatus: .notPublished, publicContactPublishedAt: nil, publicContactRemovedAt: nil, publicContactLastError: nil ) } diff --git a/BitkitTests/PaykitSdkOperationLockTests.swift b/BitkitTests/PaykitSdkOperationLockTests.swift index afa9c5cb3..247aae1c9 100644 --- a/BitkitTests/PaykitSdkOperationLockTests.swift +++ b/BitkitTests/PaykitSdkOperationLockTests.swift @@ -11,6 +11,165 @@ 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() } + 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.contactRecords() } + await fulfillment(of: [started], timeout: 2) + + 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) + release.finish() + _ = try await holder.value + 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 { + try await lock.withoutLock { + 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.withoutLock { 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() @@ -30,10 +189,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") } @@ -54,11 +216,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 @@ -172,72 +336,6 @@ final class PaykitSdkOperationLockTests: XCTestCase { 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 - - do { - try await lookup.value - XCTFail("Expected the cancelled lookup to throw") - } catch is CancellationError {} - let events = await recorder.events - XCTAssertEqual(events, ["payment"]) - } - - 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"]) - } - private func waitUntilWiping(_ lock: PaykitSdkOperationLock) async throws { let deadline = ContinuousClock.now.advanced(by: .seconds(2)) while (try? lock.walletGeneration()) != nil { @@ -248,3 +346,24 @@ 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 + } + + override func contactRecords() async throws -> [ContactRecord] { + await lockedRead() + return [] + } +} 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..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,9 @@ 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, 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..aca8625f7 100644 --- a/BitkitTests/PrivatePaykitServiceTests.swift +++ b/BitkitTests/PrivatePaykitServiceTests.swift @@ -12,19 +12,22 @@ 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 }) + 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 { @@ -92,19 +95,377 @@ final class PrivatePaykitServiceTests: XCTestCase { XCTAssertEqual(preparedContacts, contacts) } - func testReceiverNoiseDerivationMatchesCrossPlatformVector() { - let seed = ( - "c55257c360c07c72029aebc1b53c05ed0362ada38ead3e3e9efa3708e534955" + - "31f09a6987599d18264c1e1c92f2cf141630c7a3c4ab7c81b2f001698e7463b04" - ).hexaData + @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) + } + + @MainActor + func testBackgroundPreparationStopsPublicationWhenSessionEnds() async throws { + PrivatePaykitService.setContactSharingCleanupPending(false) + let publicKey = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" + var isSessionCurrent = true + let preparing = expectation(description: "Background preparation reached the link") + let (resume, continuation) = AsyncStream.makeStream() + defer { continuation.finish() } + let service = PrivatePaykitService(publicationOperations: .init( + currentPublicKey: { "pubkylocal" }, + ensureLink: { _ in + preparing.fulfill() + for await _ in resume { + break + } + return .linked + }, + buildEndpoints: { _ in XCTFail("Ended sessions must not build endpoints"); return [] }, + syncPaymentLists: { _ in + XCTFail("Ended sessions must not publish") + return .init(queued: [], cleared: [], failedToQueue: [], failedToDeliver: []) + } + )) - let key = PaykitReceiverNoiseKeyDerivation.derive( - seed: seed, - network: "bitcoin", - receiverPath: PaykitReceiverPath.wallet + _ = await service.prepareSavedContacts( + [publicKey], wallet: WalletViewModel(), isSessionCurrent: { isSessionCurrent } ) + await fulfillment(of: [preparing], timeout: 2) + isSessionCurrent = false + continuation.finish() + try await service.awaitContactPreparation() + } - XCTAssertEqual(key.hex, "500f4799bbb2d02103e3b74b365ddb478a3187333c053fa9eb62f4052ba6a327") + @MainActor + func testBackgroundPreparationUsesTheLatestSessionsQueuedWork() async throws { + PrivatePaykitService.setContactSharingCleanupPending(false) + let publicKey = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" + let endpoint = PublicPaykitService.Endpoint( + methodId: .regtestOnchainP2wpkh, value: "bcrt1qendpoint", min: nil, max: nil, + rawPayload: #"{"value":"bcrt1qendpoint"}"# + ) + var session = 1 + var started = false + var published = [String]() + let preparing = expectation(description: "Original preparation started") + let (resume, continuation) = AsyncStream.makeStream() + defer { continuation.finish() } + let service = PrivatePaykitService(publicationOperations: .init( + currentPublicKey: { "pubkylocal" }, + ensureLink: { _ in + if !started { + started = true + preparing.fulfill() + for await _ in resume { + break + } + } + return .linked + }, + buildEndpoints: { _ in [endpoint] }, + syncPaymentLists: { updates in + published += updates.map(\.counterparty) + return .init(queued: [], cleared: [], failedToQueue: [], failedToDeliver: []) + } + )) + + _ = await service.prepareSavedContacts([publicKey], wallet: WalletViewModel(), isSessionCurrent: { session == 1 }) + await fulfillment(of: [preparing], timeout: 2) + session = 2 + _ = await service.prepareSavedContacts([publicKey], wallet: WalletViewModel(), isSessionCurrent: { session == 2 }) + continuation.finish() + try await service.awaitContactPreparation() + + XCTAssertEqual(published, [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: { + return [LinkedPeerRecord( + counterparty: publicKey, state: advanced && isLinked ? .linked : .linking, + lastSyncAt: nil, lastPrivateReceiveAt: nil, failureCount: 0, + localRecoveryAttemptId: nil, localRecoveryMarkerCreatedAt: nil, localRecoveryMarkerLastError: nil, + remoteRecoveryAttemptId: nil, remoteRecoveryMarkerObservedAt: nil + )] + }, + processPending: { key in + XCTAssertEqual(key, publicKey) + processed += 1 + }, + receive: { key in + XCTAssertEqual(key, publicKey) + received += 1 + } + )) + + XCTAssertTrue(advanced) + XCTAssertEqual(processed, hasOutbound ? 1 : 0) + XCTAssertEqual(received, isLinked ? 1 : 0) + } + } + } + + 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" + 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) } + ) + ) + XCTAssertTrue(advanced.isEmpty) + XCTAssertEqual(sent, [publicKey]) + XCTAssertEqual(received, [publicKey]) + } + } + + func testPrivateMessageDrainStopsAfterInvalidationAtEachSuspension() async throws { + for stage in ["initialPeers", "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: { + 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 = ["initialPeers", "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: { XCTAssertFalse(Task.isCancelled); 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, state: LinkedPeerState = .linked) -> LinkedPeerRecord { + LinkedPeerRecord( + counterparty: publicKey, state: state, + lastSyncAt: nil, lastPrivateReceiveAt: nil, failureCount: 0, + localRecoveryAttemptId: nil, localRecoveryMarkerCreatedAt: nil, localRecoveryMarkerLastError: nil, + remoteRecoveryAttemptId: nil, remoteRecoveryMarkerObservedAt: nil + ) } private func withIsolatedDefaults(_ body: (UserDefaults) throws -> Void) throws { @@ -208,8 +569,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 +577,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 +600,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) } @@ -305,6 +653,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)) @@ -327,13 +706,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 +725,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 +753,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 +761,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 +772,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 +801,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 +816,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 +836,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 +850,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 +859,7 @@ final class PrivatePaykitServiceTests: XCTestCase { outcome: .definitePreBroadcastFailure ) let stateAfterCurrentRelease = await service.testContactState(publicKey: publicKey) - XCTAssertEqual(stateAfterCurrentRelease?.consumedPrivatePaymentListVersionsByReceiverPath[receiverPath], 7) + XCTAssertEqual(stateAfterCurrentRelease?.consumedPrivatePaymentListVersion, 7) } } @@ -507,28 +884,36 @@ 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 testPrivatePaymentRecoveryKeepsActiveRetryWhenContactsAreAdded() async { let service = PrivatePaykitService() let publicKey = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" + let otherPublicKey = "pubky1rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" - await service.schedulePrivatePaymentRecovery(for: publicKey, receiverPath: PaykitReceiverPath.server) + 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, - [PrivateMessageDrainRetryKey(publicKey: publicKey, receiverPath: PaykitReceiverPath.server)] - ) + 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() } @@ -550,14 +935,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 +958,231 @@ final class PrivatePaykitServiceTests: XCTestCase { XCTAssertEqual(PrivatePaykitService.pendingDeletedContactCleanupKeys(), [failedPublicKey]) } - func testImmediatePublicationContinuesAfterMarkerInspectionFailure() async throws { + func testCleanupFailuresKeepRegistryReconciliationPending() async throws { + let publicKey = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" + UserDefaults.standard.set(false, forKey: PrivatePaykitService.publishingEnabledKey) + for failureStage in ["lookup", "withdraw", "queue", "pending", "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 [] + }, + clearPaymentLists: { _ 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: [.init(counterparty: publicKey, outboundMessageId: 1, error: nil)], + failedToQueue: [], failedToDeliver: [] + ) + }, + drainMessages: { keys in + XCTAssertTrue(shouldFail && failureStage == "pending") + XCTAssertEqual(keys, [publicKey]) + }, + pendingDrainKeys: { _ in shouldFail && failureStage == "pending" ? [publicKey] : [] }, + 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 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 + ) + var linkingPeer = peer + linkingPeer.counterparty = "pubky5rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" + linkingPeer.state = .linking + 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( + linkedPeers: { + if failLookup { throw PrivatePaykitError.privateUnavailable } + 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: { XCTAssertEqual(Set($0), 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 + } + ) + + 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 + 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.count, expectedKeys.count * 2) + 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() + 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 testImmediatePublicationSkipsContactsWithoutPaykitOrUnavailableLinks() async { + for linkError in [ + PaykitError.NotFound(code: "not_found", context: "No App Registry"), + 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 }, + 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 +1193,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 .linked }, + buildEndpoints: { publicKey in + if publicKey == failedPublicKey { throw preparationError } XCTAssertEqual(publicKey, successfulPublicKey) - XCTAssertEqual(receiverPath, PaykitReceiverPath.wallet) return [endpoint] }, syncPaymentLists: { updates in @@ -627,6 +1211,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", @@ -634,27 +1219,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 .linked }, + buildEndpoints: { _ in throw preparationError }, syncPaymentLists: { _ in XCTFail("No payment list should be synced") return PrivatePaymentListDeliveryReport( @@ -667,6 +1245,7 @@ final class PrivatePaykitServiceTests: XCTestCase { ) let service = PrivatePaykitService() + _ = await service.rememberSavedContacts([publicKey], replacing: true) let error = await service.syncLocalEndpointPublication( for: [publicKey], reason: "test", @@ -677,6 +1256,346 @@ 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)) + } + + @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: { [] }, + clearPaymentLists: { keys in + XCTAssertEqual(keys, [publicKey]) + withdrawing.fulfill() + for await _ in resume { + break + } + return .init( + queued: [], cleared: [.init(counterparty: publicKey, outboundMessageId: 1, error: nil)], + 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( + syncPublicEndpoints: { publish, _ in + XCTAssertFalse(publish) + XCTAssertEqual(registryUpdates, 1) + XCTAssertTrue(PublicPaykitService.isCleanupPending) + }, + preparePrivateEndpoints: { _, _, _ in XCTFail("OFF must not publish"); return nil }, + removePrivateEndpoints: { _ in 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 = [ + "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: { [] }, + clearPaymentLists: { _ 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) + } + + 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: { _ in XCTFail("No outbound messages are queued") }, + receive: { _ in 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 { @@ -694,6 +1613,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 @@ -701,14 +1626,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 4ac327baa..db1cd2ee7 100644 --- a/BitkitTests/PubkyIdentityRepublishTests.swift +++ b/BitkitTests/PubkyIdentityRepublishTests.swift @@ -277,7 +277,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 3bc7fda14..6441ffb21 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 UIKit @@ -115,7 +116,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 @@ -333,7 +334,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 = [ @@ -512,7 +513,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 = [ @@ -858,7 +859,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 { @@ -874,7 +875,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 { @@ -926,7 +929,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] = [] @@ -978,7 +983,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 @@ -1050,7 +1057,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") @@ -2466,6 +2475,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, diff --git a/BitkitTests/PublicPaykitServiceTests.swift b/BitkitTests/PublicPaykitServiceTests.swift index 3f2d68f18..c87610c7e 100644 --- a/BitkitTests/PublicPaykitServiceTests.swift +++ b/BitkitTests/PublicPaykitServiceTests.swift @@ -267,6 +267,51 @@ final class PublicPaykitServiceTests: XCTestCase { } } + @MainActor + func testPublicationSyncsAppBeforeApplyingPreparedEndpoints() 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, ["build", "app", "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, ["build", "app"]) + } + private func endpoint(_ methodId: PublicPaykitService.MethodId, value: String) -> PublicPaykitService.Endpoint { PublicPaykitService.Endpoint( methodId: methodId, diff --git a/BitkitTests/ReceiveSheetSessionTests.swift b/BitkitTests/ReceiveSheetSessionTests.swift index b97b52a30..b7f61c8fe 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: .hardware), + .edit(tab: .spending, onchainOnly: false), + .edit(tab: .hardware, 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) + ) + } } 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 + } + } } 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..d4f863a4c --- /dev/null +++ b/Docs/pubky-auth-companion-claims.md @@ -0,0 +1,61 @@ +# 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. + +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: + - `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/856.changed.md b/changelog.d/next/856.changed.md new file mode 100644 index 000000000..b49ee46bd --- /dev/null +++ b/changelog.d/next/856.changed.md @@ -0,0 +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. diff --git a/journeys/README.md b/journeys/README.md index 4f51c0782..2e28c2d6a 100644 --- a/journeys/README.md +++ b/journeys/README.md @@ -154,7 +154,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/contacts/README.md b/journeys/contacts/README.md index 52a8d0820..ad325f6e3 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 @@ -23,3 +28,33 @@ 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. + +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. + +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. diff --git a/journeys/contacts/contact-payment-sharing.xml b/journeys/contacts/contact-payment-sharing.xml new file mode 100644 index 000000000..f9272f688 --- /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. 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 + + 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/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. diff --git a/journeys/payment-requests/README.md b/journeys/payment-requests/README.md index 033c17459..f30540d07 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: @@ -29,6 +29,33 @@ 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 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. Incoming messages wait for the next poll plus synchronization time. + +## 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. + +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 @@ -76,8 +103,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/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 + + diff --git a/journeys/payment-requests/automatic-presentation.xml b/journeys/payment-requests/automatic-presentation.xml index 55252a289..8d198c125 100644 --- a/journeys/payment-requests/automatic-presentation.xml +++ b/journeys/payment-requests/automatic-presentation.xml @@ -1,11 +1,11 @@ - 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 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" @@ -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/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/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 e74090701..ce519ff23 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" @@ -8,13 +8,17 @@ 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 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 + 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" On the payer, open "After readd" from Payment Requests and verify its Payment Request confirmation shows the saved contact name diff --git a/journeys/payment-requests/issuer-interoperability.xml b/journeys/payment-requests/issuer-interoperability.xml index ac3e6a28a..32a24274d 100644 --- a/journeys/payment-requests/issuer-interoperability.xml +++ b/journeys/payment-requests/issuer-interoperability.xml @@ -1,23 +1,25 @@ - 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") 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/request-summary.xml b/journeys/payment-requests/request-summary.xml index 31f2c5918..ea1394589 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" @@ -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/payment-requests/requested-resolution-failure.xml b/journeys/payment-requests/requested-resolution-failure.xml index 9c829c68b..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 @@ -13,9 +13,14 @@ 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 + 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/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..d22255ff3 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. @@ -11,23 +11,21 @@ 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. -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. @@ -44,24 +42,26 @@ 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 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, @@ -80,9 +80,9 @@ 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 | +| 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 | @@ -90,48 +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. - -## 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`. +`PaymentRequestsBell`; iOS uses a downward swipe from the sheet drag indicator instead of Android system back. diff --git a/journeys/pubky-marketplace/paykit-only-approval.xml b/journeys/pubky-marketplace/paykit-only-approval.xml new file mode 100644 index 000000000..caa41aa61 --- /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 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 new file mode 100644 index 000000000..c5fccaa76 --- /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 + 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..8010f155d 100644 --- a/journeys/pubky-marketplace/wallet-leg.xml +++ b/journeys/pubky-marketplace/wallet-leg.xml @@ -2,24 +2,25 @@ 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 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") 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") - 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 @@ -32,9 +33,9 @@ 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 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,8 +48,12 @@ 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 + 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 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 + + diff --git a/journeys/subscriptions/create-and-propose.xml b/journeys/subscriptions/create-and-propose.xml index 98e344c8d..0d5b6961c 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 @@ -11,7 +12,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") @@ -19,14 +20,17 @@ 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" + Verify a row for "Journey Sub" appears in the CREATED section with subtitle "Proposal sent", not "Proposal queued" 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 + + 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.