diff --git a/Bitkit.xcodeproj/project.pbxproj b/Bitkit.xcodeproj/project.pbxproj index 8e20f751e..c7a21a576 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-rc69"; }; }; 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..54877fd54 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" : "dd97fc9a4fa82e3d49fd3744157a3b8c5e1ba689", + "version" : "0.1.0-rc69" } }, { diff --git a/Bitkit/AppScene.swift b/Bitkit/AppScene.swift index 5b229a06d..1fc8cb9a8 100644 --- a/Bitkit/AppScene.swift +++ b/Bitkit/AppScene.swift @@ -114,8 +114,114 @@ enum IncomingPaykitPaymentRequestPresentationDispatch: Equatable { case presentNext } +@Observable @MainActor +final class IncomingPaykitPaymentRequestPreparation { + private(set) var request: PaykitPaymentRequest? + private(set) var resolvedRoute: SendRoute? + private let session: PubkyProfileManager.SignedInSession? + var paymentContext: ContactPaymentContext? + + init(request: PaykitPaymentRequest, session: PubkyProfileManager.SignedInSession?) { + self.request = request + self.session = session + } + + func visibleRequest( + manager: PaykitPaymentRequestManager, + session: PubkyProfileManager.SignedInSession?, + paymentContext: ContactPaymentContext?, + now: Date = Date() + ) -> PaykitPaymentRequest? { + guard let request, matchesSession(session), + paymentContext == self.paymentContext, + manager.isCurrentPresentation(request) || manager.isWaitingForPresentationRetry(request), + !request.isExpired(at: now) + else { return nil } + return request + } + + func matchesSession(_ session: PubkyProfileManager.SignedInSession?) -> Bool { + session != nil && session == self.session + } + + func clear() { + request = nil + resolvedRoute = nil + } + + func ownsSheet(_ sheets: SheetViewModel) -> Bool { + sheets.activeSheetConfiguration?.id == .send && + (sheets.activeSheetConfiguration?.data as? SendConfig)?.preparation === self && !sheets.isReplacingSheet + } + + func complete( + route: SendRoute, + manager: PaykitPaymentRequestManager, + session: PubkyProfileManager.SignedInSession?, + app: AppViewModel, + sheets: SheetViewModel + ) -> Bool { + guard !Task.isCancelled, ownsSheet(sheets), paymentContext != nil, + let request = visibleRequest(manager: manager, session: session, paymentContext: app.contactPaymentContext), + manager.isCurrentPresentation(request) + else { return false } + resolvedRoute = route + return true + } + + func whilePreparing(_ operation: () async throws -> T) async throws -> T { + try await withTaskCancellationHandler { + try Task.checkCancellation() + let result = try await operation() + try Task.checkCancellation() + return result + } onCancel: { + Task { @MainActor [weak self] in self?.clear() } + } + } +} + @MainActor enum IncomingPaykitPaymentRequestPresentationDispatcher { + static func canHandleSubscriptionNotification( + manager: PaykitPaymentRequestManager, + app: AppViewModel, + sheets: SheetViewModel + ) -> Bool { + manager.requestedPresentationId == nil && + sheets.activeSheetConfiguration == nil && + !sheets.isReplacingSheet && + app.contactPaymentContext == nil + } + + static func presentNextItem( + manager: PaykitPaymentRequestManager, + sheets: SheetViewModel, + canRetryPreparation: Bool, + handleSubscriptionNotification: () async -> Void, + presentPaymentRequest: () async -> Void + ) async { + guard sheets.activeSheetConfiguration == nil || canRetryPreparation, + !sheets.isReplacingSheet + else { return } + if canRetryPreparation || manager.requestedPresentationId != nil { + await presentPaymentRequest() + return + } + if PaykitSubscriptionNotificationTargetStore.load() != nil { + await handleSubscriptionNotification() + guard PaykitSubscriptionNotificationTargetStore.load() == nil, + sheets.activeSheetConfiguration == nil, + !sheets.isReplacingSheet + else { return } + } + if let subscription = manager.subscriptionProposalForPresentation() { + sheets.showSheet(.subscription, data: SubscriptionSheetItem(route: .review(subscription))) + return + } + await presentPaymentRequest() + } + static func feedback( deferring request: PaykitPaymentRequest, reason: IncomingPaykitPaymentRequestFailureReason, @@ -189,24 +295,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 + + init(now: ContinuousClock.Instant = .now) { + nextMaintenance = now.advanced(by: Self.maintenanceIntervals[0]) + } - mutating func takeRound(isConnected: Bool) -> PaykitPaymentRequestPollingRound { + 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 +347,10 @@ 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 incomingPaymentRequestPreparation: IncomingPaykitPaymentRequestPreparation? + @State private var receivedPaymentBackfillCache = PaykitReceivedPaymentBackfillCache( + activityChanges: CoreService.shared.activity.activitiesChangedPublisher + ) @State private var hideSplash = false @State private var removeSplash = false @@ -330,13 +440,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) } ) } @@ -370,7 +494,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) } @@ -439,16 +562,17 @@ struct AppScene: View { .environment(paykitPaymentRequestManager) } - private var appEventContent: some View { + private var paykitEventContent: some View { configuredContent .onChange(of: pubkyProfile.authState, initial: true) { _, authState in + receivedPaymentBackfillCache.invalidate() 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() } @@ -462,61 +586,51 @@ 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() } + PaykitPaymentActivity.shared.runWhenIdle(.proofRefresh) { + await refreshIncomingPaykitPaymentRequests(mode: .stored, forceFresh: true) + } } .onReceive(PaykitPaymentProofService.onchainPaymentResolutionPublisher) { resolution in - Task { await associateResolvedPaykitOnchainPayment(resolution) } + Task { @MainActor in + app.retainPaykitOnchainPaymentResolution(resolution, identity: pubkyProfile.publicKey) + await associateResolvedPaykitOnchainPayment(resolution) + } } .onReceive(NotificationCenter.default.publisher(for: .paykitSubscriptionPaymentDue)) { _ in Task { await handlePendingPaykitSubscriptionNotification() } } + } + + private var appEventContent: some View { + paykitEventContent .onChange(of: sheets.activeSheetConfiguration?.id) { _, activeSheetId in guard activeSheetId == nil, !sheets.isReplacingSheet else { return } - Task { - try? await Task.sleep(for: .milliseconds(700)) - guard !Task.isCancelled, - sheets.activeSheetConfiguration == nil, - !sheets.isReplacingSheet - else { return } - await presentNextIncomingPaykitItem() + scheduleNextIncomingPaykitItemPresentation() + } + .onChange(of: isIncomingPaymentRequestPreparationInvalid) { _, invalid in + guard invalid, let preparation = incomingPaymentRequestPreparation else { return } + endIncomingPaymentRequestPreparation(preparation) + } + .onChange(of: app.showDrawer) { _, isShowing in + if !isShowing { + Task { await presentNextIncomingPaykitItem() } } } .onChange(of: incomingPaykitPaymentRequestPresentationState) { previous, current in handleIncomingPaykitPaymentRequestPresentationStateChange(from: previous, to: current) } - .onChange(of: paykitPaymentRequestManager.pendingRequests) { _, requests in - guard let request = app.contactPaymentContext?.incomingPaymentRequest, - !requests.contains(where: { $0.id == request.id }), - !paykitPaymentRequestManager.isApprovedForPayment(request) - else { return } - - let activeSheetId = sheets.activeSheetConfiguration?.id - guard activeSheetId == .send || - (activeSheetId == .subscription && app.contactPaymentContext?.isInitialSubscriptionPayment == true) - else { return } - - sheets.hideSheet(reason: "Incoming payment request is no longer available") - } .onChange(of: navigation.currentRoute) { oldRoute, newRoute in guard shouldDiscardPendingImport(currentRoute: oldRoute, destination: newRoute) else { return @@ -868,6 +982,7 @@ struct AppScene: View { @Sendable private func setupTask() async { + configurePrivatePaykitContactResolvers() AppReset.hardwareWallets = hwWalletManager do { // Handle orphaned keychain before anything else @@ -1042,17 +1157,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 { @@ -1100,13 +1210,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() } @@ -1115,38 +1223,63 @@ 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 { + @discardableResult + private func refreshIncomingPaykitPaymentRequests( + presentItems: Bool = true, + mode: PaykitPaymentRequestRefreshMode = .full, + forceFresh: Bool = false, + messagePriority: PaykitSdkOperationLock.Priority = .ordered + ) async -> Bool { guard PaykitFeatureFlags.isUIEnabled, wallet.walletExists == true, pubkyProfile.authState == .authenticated else { paykitPaymentRequestManager.clearEligibleTargets() - return + return false } - if refreshMaintenance { + guard !Task.isCancelled else { return false } + paykitPaymentRequestManager.updateSavedPublicKeys(contactsManager.contacts.map(\.publicKey)) + if mode == .full { await PaykitPaymentProofService.shared.reconcile() } - await paykitPaymentRequestManager.refresh() + guard let identity = pubkyProfile.publicKey else { return false } + let refreshed = await paykitPaymentRequestManager.refresh(mode: mode, forceFresh: forceFresh, messagePriority: messagePriority) + guard pubkyProfile.authState == .authenticated, + PubkyPublicKeyFormat.matches(identity, pubkyProfile.publicKey) + else { return false } + 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)) } + return refreshed } private func associateResolvedPaykitOnchainPayment(_ resolution: PaykitOnchainPaymentResolution) async { @@ -1156,8 +1289,10 @@ struct AppScene: View { do { _ = try await tryNTimes( toTry: { - try? await activity.syncLdkNodePayments() - return try await activity.findActivity(byPaymentId: resolution.transactionId) + if resolution.walletId == WalletScope.default { + try? await activity.syncLdkNodePayments() + } + return try await activity.findActivity(byPaymentId: resolution.transactionId, walletId: resolution.walletId) }, times: 12, interval: 2 @@ -1165,6 +1300,7 @@ struct AppScene: View { try await activity.setContact( resolution.requestId.counterparty, forPaymentId: resolution.transactionId, + walletId: resolution.walletId, syncLdkPayments: false ) } catch { @@ -1181,6 +1317,7 @@ struct AppScene: View { guard scenePhase == .active, network.isConnected else { return } await PubkyService.republishIdentityIfNeeded(publicKey: pubkyProfile.publicKey) + await refreshIncomingPaykitPaymentRequests(messagePriority: .background) var schedule = PaykitPaymentRequestPollingSchedule() while !Task.isCancelled { do { @@ -1191,60 +1328,94 @@ 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 { + let session = pubkyProfile.currentSession + if mode == .full { await PubkyService.republishIdentityIfNeeded(publicKey: pubkyProfile.publicKey) - 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 + await retryPendingPaykitEndpointRemoval() } - guard scenePhase == .active else { return } - await refreshIncomingPaykitPaymentRequests() + await refreshIncomingPaykitPaymentRequests(mode: mode, messagePriority: .background) + guard mode == .full, + !Task.isCancelled, + scenePhase == .active, + network.isConnected, + PaykitFeatureFlags.isUIEnabled, + let session, session == pubkyProfile.currentSession, + !app.showDrawer, + sheets.activeSheetConfiguration == nil, + !sheets.isReplacingSheet, + app.contactPaymentContext == nil + else { continue } + await PrivatePaykitService.shared.refreshKnownSavedContactEndpoints( + wallet: wallet, + reason: "payment request polling" + ) } } private func presentNextIncomingPaykitPaymentRequest() async { guard scenePhase == .active, isPinVerified || !settings.pinEnabled, - sheets.activeSheetConfiguration == nil, + PaykitFeatureFlags.isUIEnabled, + pubkyProfile.currentSession != nil, + !app.showDrawer, + sheets.activeSheetConfiguration == nil || canRetryIncomingPaymentRequestPreparation, !sheets.isReplacingSheet, app.contactPaymentContext == nil else { return } var shouldPresentNextRequest = true let attemptedPresentation = await paykitPaymentRequestManager.presentRequests { requests in - guard sheets.activeSheetConfiguration == nil, !sheets.isReplacingSheet, app.contactPaymentContext == nil else { return } + guard sheets.activeSheetConfiguration == nil || canRetryIncomingPaymentRequestPreparation, + !sheets.isReplacingSheet, app.contactPaymentContext == nil + else { return } for request in requests { guard paykitPaymentRequestManager.isCurrentPresentation(request) else { return } + let preparation: IncomingPaykitPaymentRequestPreparation + if let current = incomingPaymentRequestPreparation, current.ownsSheet(sheets) { + guard current.request?.id == request.id else { continue } + preparation = current + } else { + preparation = IncomingPaykitPaymentRequestPreparation(request: request, session: pubkyProfile.currentSession) + incomingPaymentRequestPreparation = preparation + sheets.showSheet(.send, data: SendConfig(view: .confirm, preparation: preparation, onDismiss: { + if preparation.resolvedRoute == nil, scenePhase == .active, + preparation.matchesSession(pubkyProfile.currentSession), let request = preparation.request + { + paykitPaymentRequestManager.dismissPreparingRequest(request) + } + preparation.clear() + })) + } + defer { + if preparation.resolvedRoute == nil { + if !Task.isCancelled, preparation.ownsSheet(sheets), + preparation.matchesSession(pubkyProfile.currentSession), + paykitPaymentRequestManager.isWaitingForPresentationRetry(request) + { + if let context = preparation.paymentContext, app.ownsContactPaymentContext(context) { + app.resetSendState() + wallet.resetSendState(speed: settings.defaultTransactionSpeed) + } + preparation.paymentContext = nil + } else { + endIncomingPaymentRequestPreparation(preparation) + } + } + } do { - let result = try await PrivatePaykitService.shared.beginPaymentRequest(request) - guard paykitPaymentRequestManager.isCurrentPresentation(request), - sheets.activeSheetConfiguration == nil, - !sheets.isReplacingSheet, - app.contactPaymentContext == nil + let result = try await preparation.whilePreparing { + try await PrivatePaykitService.shared.beginPaymentRequest(request) + } + guard isCurrentIncomingPaymentRequestPreparation(preparation), app.contactPaymentContext == nil else { return } if case .privateLinkPending = result { if let feedback = IncomingPaykitPaymentRequestPresentationDispatcher.finishPendingPrivateLink( @@ -1253,14 +1424,14 @@ struct AppScene: View { ) { presentIncomingPaykitPaymentRequestFeedback(feedback, for: request) } - continue + return } guard case let .opened(paymentTarget, privatePaymentContext) = result else { deferIncomingPaykitPaymentRequestPresentation( request, reason: result.incomingPaymentRequestFailureReason ?? .resolutionFailed ) - continue + return } let contactPaymentContext = ContactPaymentContext( @@ -1269,17 +1440,18 @@ struct AppScene: View { incomingPaymentRequest: request ) guard app.claimContactPaymentContext(contactPaymentContext) else { return } + preparation.paymentContext = contactPaymentContext do { - try await app.handleScannedData( - paymentTarget, - claimedContactPaymentContext: contactPaymentContext, - alternativeOnchainBalanceSats: hwWalletManager.maximumFundingBalanceSats - ) - guard paykitPaymentRequestManager.isCurrentPresentation(request), - app.ownsContactPaymentContext(contactPaymentContext), - sheets.activeSheetConfiguration == nil, - !sheets.isReplacingSheet + try await preparation.whilePreparing { + try await app.handleScannedData( + paymentTarget, + claimedContactPaymentContext: contactPaymentContext, + alternativeOnchainBalanceSats: hwWalletManager.maximumFundingBalanceSats + ) + } + guard isCurrentIncomingPaymentRequestPreparation(preparation), + app.ownsContactPaymentContext(contactPaymentContext) else { if app.ownsContactPaymentContext(contactPaymentContext) { app.resetSendState() @@ -1306,15 +1478,15 @@ struct AppScene: View { return } - guard paykitPaymentRequestManager.isCurrentPresentation(request) else { + guard isCurrentIncomingPaymentRequestPreparation(preparation) else { app.resetSendState() wallet.resetSendState(speed: settings.defaultTransactionSpeed) return } } catch ScanHandlingError.pubkyAuthRequest { - guard paykitPaymentRequestManager.isCurrentPresentation(request) else { return } + guard isCurrentIncomingPaymentRequestPreparation(preparation) else { return } _ = paykitPaymentRequestManager.markPresentedIfPending(request) - continue + return } catch is CancellationError { if app.ownsContactPaymentContext(contactPaymentContext) { app.resetSendState() @@ -1323,7 +1495,7 @@ struct AppScene: View { return } catch { guard app.ownsContactPaymentContext(contactPaymentContext) else { return } - guard paykitPaymentRequestManager.isCurrentPresentation(request) else { + guard isCurrentIncomingPaymentRequestPreparation(preparation) else { app.resetSendState() wallet.resetSendState(speed: settings.defaultTransactionSpeed) return @@ -1340,7 +1512,7 @@ struct AppScene: View { return } deferIncomingPaykitPaymentRequestPresentation(request, reason: .invalidPaymentTarget) - continue + return } guard PaykitPaymentRequestPresentationCoordinator.canPresentPreparedRequest( @@ -1363,13 +1535,20 @@ struct AppScene: View { deferIncomingPaykitPaymentRequestPresentation(request, reason: .paymentTargetNotRoutable) return } - sheets.showSheet(.send, data: SendConfig(view: route)) + _ = preparation.complete( + route: route, + manager: paykitPaymentRequestManager, + session: pubkyProfile.currentSession, + app: app, + sheets: sheets + ) return } catch is CancellationError { return } catch { - guard paykitPaymentRequestManager.isCurrentPresentation(request) else { return } + guard isCurrentIncomingPaymentRequestPreparation(preparation) else { return } deferIncomingPaykitPaymentRequestPresentation(request, reason: .resolutionFailed) + return } } } @@ -1382,7 +1561,50 @@ struct AppScene: View { !sheets.isReplacingSheet, app.contactPaymentContext == nil else { return } - await presentNextIncomingPaykitPaymentRequest() + scheduleNextIncomingPaykitItemPresentation() + } + + private func isCurrentIncomingPaymentRequestPreparation(_ preparation: IncomingPaykitPaymentRequestPreparation) -> Bool { + scenePhase == .active && (isPinVerified || !settings.pinEnabled) && PaykitFeatureFlags.isUIEnabled && + !app.showDrawer && preparation.ownsSheet(sheets) && preparation.visibleRequest( + manager: paykitPaymentRequestManager, + session: pubkyProfile.currentSession, + paymentContext: app.contactPaymentContext + ) != nil + } + + private var canRetryIncomingPaymentRequestPreparation: Bool { + guard let preparation = incomingPaymentRequestPreparation, preparation.resolvedRoute == nil else { return false } + return isCurrentIncomingPaymentRequestPreparation(preparation) + } + + private var isIncomingPaymentRequestPreparationInvalid: Bool { + guard let preparation = incomingPaymentRequestPreparation else { return false } + if preparation.resolvedRoute != nil { return !preparation.ownsSheet(sheets) } + return !isCurrentIncomingPaymentRequestPreparation(preparation) + } + + private func endIncomingPaymentRequestPreparation(_ preparation: IncomingPaykitPaymentRequestPreparation) { + let context = preparation.paymentContext + preparation.clear() + if let context, app.ownsContactPaymentContext(context) { + app.resetSendState() + wallet.resetSendState(speed: settings.defaultTransactionSpeed) + } + if preparation.ownsSheet(sheets) { + sheets.hideSheet(reason: "Incoming payment request preparation ended") + } + if incomingPaymentRequestPreparation === preparation { + incomingPaymentRequestPreparation = nil + } + } + + private func scheduleNextIncomingPaykitItemPresentation() { + Task { + try? await Task.sleep(for: .milliseconds(700)) + guard !Task.isCancelled, sheets.activeSheetConfiguration == nil, !sheets.isReplacingSheet else { return } + await presentNextIncomingPaykitItem() + } } private func deferIncomingPaykitPaymentRequestPresentation( @@ -1439,17 +1661,27 @@ 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 { + guard let session = pubkyProfile.currentSession else { return } + guard target.matches(identity: session.publicKey) else { PaykitSubscriptionNotificationTargetStore.clear() return } - guard sheets.activeSheetConfiguration == nil, - !sheets.isReplacingSheet, - app.contactPaymentContext == nil + guard IncomingPaykitPaymentRequestPresentationDispatcher.canHandleSubscriptionNotification( + manager: paykitPaymentRequestManager, app: app, sheets: sheets + ) else { return } + guard await refreshIncomingPaykitPaymentRequests(presentItems: false, mode: .stored), + !Task.isCancelled, + pubkyProfile.currentSession == session, + PaykitSubscriptionNotificationTargetStore.load() == target, + IncomingPaykitPaymentRequestPresentationDispatcher.canHandleSubscriptionNotification( + manager: paykitPaymentRequestManager, app: app, sheets: sheets + ) else { return } - await refreshIncomingPaykitPaymentRequests(presentItems: false) guard let request = paykitPaymentRequestManager.pendingRequests.first(where: target.matches) else { if paykitPaymentRequestManager.historyRequests.contains(where: target.matches) { PaykitSubscriptionNotificationTargetStore.clear() @@ -1458,7 +1690,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() @@ -1466,52 +1697,50 @@ struct AppScene: View { return } - if paykitPaymentRequestManager.requestedPresentationId != request.id { - guard paykitPaymentRequestManager.requestPresentation(request) else { return } - } + guard paykitPaymentRequestManager.requestPresentation(request) else { return } + PaykitSubscriptionNotificationTargetStore.clear() await presentNextIncomingPaykitPaymentRequest() } private func presentNextIncomingPaykitItem() async { guard scenePhase == .active, - isPinVerified || !settings.pinEnabled, - sheets.activeSheetConfiguration == nil, - !sheets.isReplacingSheet + isPinVerified || !settings.pinEnabled else { return } - if PaykitSubscriptionNotificationTargetStore.load() != nil { - await handlePendingPaykitSubscriptionNotification() - guard PaykitSubscriptionNotificationTargetStore.load() == nil, - sheets.activeSheetConfiguration == nil, - !sheets.isReplacingSheet - else { return } - } - if let subscription = paykitPaymentRequestManager.subscriptionProposalForPresentation() { - sheets.showSheet(.subscription, data: SubscriptionSheetItem(route: .review(subscription))) - return - } - await presentNextIncomingPaykitPaymentRequest() + await IncomingPaykitPaymentRequestPresentationDispatcher.presentNextItem( + manager: paykitPaymentRequestManager, + sheets: sheets, + canRetryPreparation: canRetryIncomingPaymentRequestPreparation, + handleSubscriptionNotification: handlePendingPaykitSubscriptionNotification, + presentPaymentRequest: presentNextIncomingPaykitPaymentRequest + ) } private func retryPendingPaykitEndpointRemoval() async { - 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. @@ -1564,13 +1793,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/Extensions/BroadcastError+Connectivity.swift b/Bitkit/Extensions/BroadcastError+Connectivity.swift index 4c53c6c40..5f34ade09 100644 --- a/Bitkit/Extensions/BroadcastError+Connectivity.swift +++ b/Bitkit/Extensions/BroadcastError+Connectivity.swift @@ -37,6 +37,21 @@ extension BroadcastError { } extension Error { + func isDefiniteHardwarePreBroadcastFailure() -> Bool { + if let error = self as? BroadcastError { + switch error { + case .InvalidHex, .InvalidTransaction: + return true + default: + return false + } + } + if let error = self as? AppError, let underlyingError = error.underlyingError { + return underlyingError.isDefiniteHardwarePreBroadcastFailure() + } + return false + } + func isBroadcastConnectivityFailure() -> Bool { if let broadcastError = self as? BroadcastError { return broadcastError.isConnectivityFailure diff --git a/Bitkit/MainNavView.swift b/Bitkit/MainNavView.swift index 7588f92ae..4a4d264e2 100644 --- a/Bitkit/MainNavView.swift +++ b/Bitkit/MainNavView.swift @@ -765,7 +765,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 8e6ff01af..a3db83c49 100644 --- a/Bitkit/Managers/ContactsManager.swift +++ b/Bitkit/Managers/ContactsManager.swift @@ -150,6 +150,7 @@ class ContactsManager: ObservableObject { private let fetchRemoteProfile: @Sendable (_ publicKey: String, _ priority: PaykitPublicReadPriority) async throws -> PubkyProfile? private let saveContactLabel: @Sendable (_ publicKey: String, _ label: String, _ expectedIdentity: String) async throws -> Void private let removeContactRecord: @Sendable (_ publicKey: String) async throws -> Void + private let removeContactRecords: @Sendable (_ publicKeys: [String]) async throws -> [ContactRecord] private let forgetRemovedContacts: @Sendable (_ publicKeys: [String]) async -> Void private let currentDate: @Sendable () -> Date @@ -165,6 +166,9 @@ class ContactsManager: ObservableObject { removeContactRecord: @escaping @Sendable (_ publicKey: String) async throws -> Void = { _ = try await PubkyService.removeContact(publicKey: $0) }, + removeContactRecords: @escaping @Sendable (_ publicKeys: [String]) async throws -> [ContactRecord] = { + try await PubkyService.removeContacts(publicKeys: $0) + }, forgetRemovedContacts: @escaping @Sendable (_ publicKeys: [String]) async -> Void = { await PrivatePaykitService.shared.removeSavedContacts(publicKeys: $0) }, @@ -175,6 +179,7 @@ class ContactsManager: ObservableObject { self.fetchRemoteProfile = fetchRemoteProfile self.saveContactLabel = saveContactLabel self.removeContactRecord = removeContactRecord + self.removeContactRecords = removeContactRecords self.forgetRemovedContacts = forgetRemovedContacts self.currentDate = currentDate } @@ -598,11 +603,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 ) @@ -614,25 +617,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" ) } @@ -643,41 +644,33 @@ class ContactsManager: ObservableObject { /// Remembers the profiles it saves for this session, so the Contacts list shows them at once rather than only their /// saved labels. A placeholder for a follow whose lookup failed is not remembered, so that profile is still looked up. /// An import outlives its screens, so a reset or another identity's load while it runs, such as after a sign-out, - /// stops it quietly: it saves nothing more, adds nothing to the next session's list and reports no error. + /// drops its result quietly: it adds nothing to the next session's list and reports no error. func importContacts( contacts selected: [PubkyContact], - saveContact: (String, String) async throws -> Void = { publicKey, label in - _ = try await PubkyService.saveContact(publicKey: publicKey, label: label, restorePrivateConnection: true) + saveContacts: ([ContactUpdate], String?) async throws -> Void = { updates, expectedIdentity in + _ = try await PubkyService.saveContacts(updates: updates, expectedIdentity: expectedIdentity) } ) async throws { let profilesGeneration = resolvedProfilesGeneration - var imported: [PubkyContact] = [] + let expectedIdentity = resolvedProfilesOwner var existingKeys = Set(contacts.map(\.publicKey)) - var firstError: Error? + let imported = selected.filter { existingKeys.insert($0.publicKey).inserted } + try Task.checkCancellation() + guard !imported.isEmpty else { return } - for contact in selected { + do { + try await saveContacts(imported.map { ContactUpdate(publicKey: $0.publicKey, label: $0.displayName) }, expectedIdentity) + } catch is CancellationError { + throw CancellationError() + } catch { try Task.checkCancellation() - guard profilesGeneration == resolvedProfilesGeneration else { break } - guard !existingKeys.contains(contact.publicKey) else { continue } - do { - // The preview already resolved this profile. Receiver discovery runs during contact refresh. - try await saveContact(contact.publicKey, contact.displayName) - imported.append(contact) - existingKeys.insert(contact.publicKey) - } catch is CancellationError { - throw CancellationError() - } catch { - firstError = firstError ?? error - Logger.warn( - "Failed to save imported contact '\(PubkyPublicKeyFormat.redacted(contact.publicKey))': \(error)", - context: "ContactsManager" - ) - } + guard profilesGeneration == resolvedProfilesGeneration else { return } + throw error } try Task.checkCancellation() guard profilesGeneration == resolvedProfilesGeneration else { - Logger.info("Stopped a contact import that a reset overtook after \(imported.count) saves", context: "ContactsManager") + Logger.info("Discarded a contact import that a reset overtook", context: "ContactsManager") return } for contact in imported { @@ -686,9 +679,6 @@ class ContactsManager: ObservableObject { let currentKeys = Set(contacts.map(\.publicKey)) contacts = (contacts + imported.filter { !currentKeys.contains($0.publicKey) }).sorted(by: Self.isOrderedByName) Logger.info("Imported \(imported.count) new contacts", context: "ContactsManager") - if let firstError { - throw firstError - } } // MARK: - Update Contact @@ -867,7 +857,7 @@ class ContactsManager: ObservableObject { stopProfileRefresh() stopPendingProfileLookups() let contactRecords = contactRecords - let removeContactRecord = removeContactRecord + let removeContactRecords = removeContactRecords let records: [ContactRecord] do { records = try await Task.detached { @@ -885,35 +875,17 @@ class ContactsManager: ObservableObject { return } - var deletedKeys = Set() - var firstError: Error? - - for record in records { - guard let contactKey = PubkyPublicKeyFormat.normalized(record.publicKey) else { continue } - do { - try await Task.detached { - try await removeContactRecord(contactKey) - }.value - deletedKeys.insert(contactKey) - } catch { - firstError = firstError ?? error - Logger.warn("Failed to delete contact '\(PubkyPublicKeyFormat.redacted(contactKey))': \(error)", context: "ContactsManager") - } - } - - if let firstError { - if !deletedKeys.isEmpty { - await forgetRemovedContacts(Array(deletedKeys)) - for publicKey in deletedKeys { - Self.removeContactProfileOverride(publicKey: publicKey) - } - contacts.removeAll { deletedKeys.contains($0.publicKey) } - } - throw firstError + let keys = records.compactMap { PubkyPublicKeyFormat.normalized($0.publicKey) } + let removed = try await Task.detached { try await removeContactRecords(keys) }.value + let deletedKeys = Set(removed.compactMap { PubkyPublicKeyFormat.normalized($0.publicKey) }) + await forgetRemovedContacts(Array(deletedKeys)) + Self.removeContactProfileOverrides(publicKeys: deletedKeys) + contacts.removeAll { deletedKeys.contains($0.publicKey) } + guard deletedKeys.isSuperset(of: keys) else { + throw PrivatePaykitError.privateUnavailable } // All remote deletes succeeded, so clear any local-only contacts too. - await forgetRemovedContacts(Array(deletedKeys)) Self.clearContactProfileOverrides() await PrivatePaykitService.shared.pruneUnsavedContactState(savedPublicKeys: []) contacts.removeAll() @@ -1160,20 +1132,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 { @@ -1211,9 +1169,12 @@ class ContactsManager: ObservableObject { private nonisolated static func removeContactProfileOverride(publicKey: String) { guard let prefixedKey = PubkyPublicKeyFormat.normalized(publicKey) else { return } - var overrides = loadContactProfileOverrides() - overrides.removeValue(forKey: prefixedKey) - saveContactProfileOverrides(overrides) + removeContactProfileOverrides(publicKeys: [prefixedKey]) + } + + private nonisolated static func removeContactProfileOverrides(publicKeys: Set) { + guard !publicKeys.isEmpty else { return } + saveContactProfileOverrides(loadContactProfileOverrides().filter { !publicKeys.contains($0.key) }) } private nonisolated static func clearContactProfileOverrides() { diff --git a/Bitkit/Managers/HwWalletManager.swift b/Bitkit/Managers/HwWalletManager.swift index 7d09b65f7..0066d4a7e 100644 --- a/Bitkit/Managers/HwWalletManager.swift +++ b/Bitkit/Managers/HwWalletManager.swift @@ -1541,10 +1541,11 @@ final class HwWalletManager { } /// Broadcast a signed funding transaction and return its txid. Does not require a connected device. - func broadcastFunding(serializedTx: String) async throws -> String { + func broadcastFunding(serializedTx: String, paymentDeadline: PaykitPreciseInstant? = nil) async throws -> String { try await OnChainHwService.shared.broadcastRawTx( serializedTx: serializedTx, - electrumUrl: electrumUrlProvider() + electrumUrl: electrumUrlProvider(), + paymentDeadline: paymentDeadline ) } diff --git a/Bitkit/Managers/PubkyProfileManager.swift b/Bitkit/Managers/PubkyProfileManager.swift index 6f6e8c510..6c91b06b7 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 @@ -52,6 +53,7 @@ class PubkyProfileManager: ObservableObject { enum SessionInitializationResult: Equatable { case noSession case restored(publicKey: String) + case restorationDeferred case restorationFailed } @@ -103,6 +105,8 @@ class PubkyProfileManager: ObservableObject { private var isSignupInFlight = false private var initializationTask: Task? + private var completedRecoveryVersion = 0 + private var sessionRestorationDeferred = false private var savedIdentityLookup: (revision: UUID, exists: Bool)? private static var isRingAdoptionInFlight = false private static var sessionRevision = UUID() @@ -204,6 +208,7 @@ class PubkyProfileManager: ObservableObject { } guard Self.sessionMutationCount == 0 else { return } isRestoringSession = true + let revision = Self.sessionRevision let task = Task { defer { initializationTask = nil @@ -211,6 +216,9 @@ class PubkyProfileManager: ObservableObject { } guard Self.sessionMutationCount == 0 else { return } await initializeSessionState(mode: mode, initializeSession: initializeSession) + if case .automaticRecovery = mode, revision == Self.sessionRevision { + completedRecoveryVersion += 1 + } } initializationTask = task await task.value @@ -223,9 +231,12 @@ class PubkyProfileManager: ObservableObject { try await PubkyProfileManager.initializePersistedSession() } ) async { + let recoveryVersion = completedRecoveryVersion + let revision = Self.sessionRevision if let initializationTask { await initializationTask.value } + if completedRecoveryVersion != recoveryVersion, revision == Self.sessionRevision { return } guard !Task.isCancelled, publicKey == nil, authState == .idle, Self.sessionMutationCount == 0 else { return } do { guard try hasStoredIdentity() else { return } @@ -246,16 +257,27 @@ class PubkyProfileManager: ObservableObject { ) async { // A usable connection can return without a new network-path event. let maximumDelay = Duration.seconds(180) + let revision = Self.sessionRevision var delay = retryDelay - while !Task.isCancelled { + var deferredRetries = 0 + while !Task.isCancelled, revision == Self.sessionRevision { await restoreSessionIfNeeded(hasStoredIdentity: hasStoredIdentity, initializeSession: initializeSession) - guard !isAuthenticated, (try? hasStoredIdentity()) != false else { return } + guard !Task.isCancelled, revision == Self.sessionRevision, + !isAuthenticated, (try? hasStoredIdentity()) != false + else { return } + let nextDelay: Duration + if sessionRestorationDeferred, deferredRetries < 8 { + deferredRetries += 1 + nextDelay = min(retryDelay, .seconds(5)) + } else { + nextDelay = delay + delay = min(delay * 2, maximumDelay) + } do { - try await sleep(min(delay * jitter(), maximumDelay)) + try await sleep(min(nextDelay * jitter(), maximumDelay)) } catch { return } - delay = min(delay * 2, maximumDelay) } } @@ -264,6 +286,7 @@ class PubkyProfileManager: ObservableObject { initializeSession: @escaping @Sendable () async throws -> SessionInitializationResult ) async { let revision = Self.sessionRevision + sessionRestorationDeferred = false if case .userVisible = mode { isInitialized = false initializationErrorMessage = nil @@ -280,12 +303,16 @@ class PubkyProfileManager: ObservableObject { isInitialized = true return } - Logger.error("Failed to initialize paykit: \(error)", context: "PubkyProfileManager") - authState = .idle - if case .userVisible = mode { - initializationErrorMessage = error.localizedDescription + if Self.isTemporarySessionRestorationError(error) { + result = .restorationDeferred + } else { + Logger.error("Failed to initialize paykit: \(error)", context: "PubkyProfileManager") + authState = .idle + if case .userVisible = mode { + initializationErrorMessage = error.localizedDescription + } + return } - return } guard revision == Self.sessionRevision else { @@ -312,6 +339,10 @@ class PubkyProfileManager: ObservableObject { if case .userVisible = mode { sessionRestorationFailed = true } + case .restorationDeferred: + clearAuthenticatedState(clearCachedProfile: false) + sessionRestorationFailed = false + sessionRestorationDeferred = true } await checkAdoptedSource() @@ -937,8 +968,10 @@ class PubkyProfileManager: ObservableObject { return true } - func deleteProfile() async throws { - await Self.removePrivatePaykitEndpointsBestEffort(context: "PubkyProfileManager.deleteProfile") + func deleteProfile(cleanPrivatePaykitEndpoints: Bool = true) async throws { + if cleanPrivatePaykitEndpoints { + await Self.removePrivatePaykitEndpointsBestEffort(context: "PubkyProfileManager.deleteProfile") + } do { try await Task.detached { try await PubkyService.deletePaykitProfile() @@ -1264,11 +1297,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() } @@ -1294,9 +1324,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 } @@ -1411,6 +1441,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" @@ -1442,10 +1473,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) @@ -1465,6 +1506,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) } @@ -1754,6 +1796,10 @@ class PubkyProfileManager: ObservableObject { let publicKey = try await importSession(savedSessionSecret) return .restored(publicKey: publicKey) } catch { + if isTemporarySessionRestorationError(error) { + Logger.warn("Deferred session restoration, keeping saved session", context: "PubkyProfileManager") + return .restorationDeferred + } Logger.warn("Failed to import saved session, attempting re-sign-in: \(error)", context: "PubkyProfileManager") } } @@ -1779,7 +1825,16 @@ class PubkyProfileManager: ObservableObject { return .restored(publicKey: publicKey) } catch { Logger.warn("Re-sign-in failed, keeping saved session for retry: \(error)", context: "PubkyProfileManager") - return .restorationFailed + return isTemporarySessionRestorationError(error) ? .restorationDeferred : .restorationFailed + } + } + + private nonisolated static func isTemporarySessionRestorationError(_ error: Error) -> Bool { + switch error { + case is CancellationError, PaykitError.ConcurrentUpdate, PaykitError.SharedStateBusy, PaykitError.Transport: + return true + default: + return false } } } 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..864d5e70c 100644 --- a/Bitkit/Services/BackupService.swift +++ b/Bitkit/Services/BackupService.swift @@ -103,7 +103,9 @@ class BackupService { private var isWiping = false private var lastNotificationTime: UInt64 = 0 - private let defaults = UserDefaults.standard + private let defaults: UserDefaults + private let backupData: ((BackupCategory) async throws -> Data)? + private let uploadBackup: (String, Data) async throws -> Void private let backupStatusesKey = "backupStatuses" private let statusUpdateQueue = DispatchQueue(label: "backup-service-status-update", qos: .userInitiated) @@ -125,7 +127,16 @@ class BackupService { .eraseToAnyPublisher() } - private init() { + init( + defaults: UserDefaults = .standard, + backupData: ((BackupCategory) async throws -> Data)? = nil, + uploadBackup: @escaping (String, Data) async throws -> Void = { key, data in + _ = try await VssBackupClient.shared.putObject(key: key, data: data) + } + ) { + self.defaults = defaults + self.backupData = backupData + self.uploadBackup = uploadBackup let statuses = getAllBackupStatuses() var clearedStatuses = statuses for category in BackupCategory.allCases { @@ -216,8 +227,12 @@ class BackupService { } do { - let data = try await getBackupDataBytes(category: category) - let _ = try await vssBackupClient.putObject(key: category.rawValue, data: data) + let data = if let backupData { + try await backupData(category) + } else { + try await getBackupDataBytes(category: category) + } + try await uploadBackup(category.rawValue, data) updateBackupStatus(category: category) { status in BackupItemStatus( @@ -232,7 +247,7 @@ class BackupService { updateBackupStatus(category: category) { status in BackupItemStatus( synced: status.synced, - required: status.synced, + required: category == .wallet ? status.required : status.synced, running: false ) } @@ -251,7 +266,11 @@ class BackupService { } try? await ServiceQueue.background(.backup) { self.runningBackupTasks[category] = backupTask } - await backupTask.value + await withTaskCancellationHandler { + await backupTask.value + } onCancel: { + backupTask.cancel() + } } func hasPendingWalletRestore() -> Bool { @@ -319,6 +338,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 +528,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 +901,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..08b38bf0d 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 @@ -24,7 +26,12 @@ enum ContactPaymentsService { static func live(wallet: WalletViewModel) -> Operations { Operations( syncPublicEndpoints: { publish, isChangeCurrent in - try await PublicPaykitService.syncPublishedEndpoints(wallet: wallet, publish: publish, isSessionCurrent: isChangeCurrent) + try await PublicPaykitService.syncPublishedEndpoints( + wallet: wallet, + publish: publish, + isSessionCurrent: isChangeCurrent, + appSyncPriority: publish ? .ordered : .interactive + ) }, preparePrivateEndpoints: { contactPublicKeys, requireImmediatePublication, isChangeCurrent in await PrivatePaykitService.shared.prepareSavedContacts( @@ -62,13 +69,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 +85,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 +108,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 +121,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 +144,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 +164,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 +272,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 +308,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 +320,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 +341,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 +370,7 @@ enum ContactPaymentsService { if restoresPrivateEndpoints { if let error = await operations.preparePrivateEndpoints( contactPublicKeys, - true, + false, isChangeCurrent ) { if isChangeCurrent() { @@ -268,18 +380,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..2e29ffefa 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) } } } @@ -775,7 +788,8 @@ class LightningService { sats: UInt64, satsPerVbyte: UInt32, utxosToSpend: [SpendableUtxo]? = nil, - isMaxAmount: Bool = false + isMaxAmount: Bool = false, + beforeSubmission: @escaping () throws -> Void = {} ) async throws -> Txid { guard let node else { throw AppError(serviceError: .nodeNotSetup) @@ -784,7 +798,7 @@ class LightningService { Logger.info("Sending \(sats) sats to \(address) with fee rate \(satsPerVbyte) sats/vbyte (isMaxAmount: \(isMaxAmount))") do { - return try await ServiceQueue.background(.ldk) { + return try await Self.submitPayment(beforeSubmission: beforeSubmission) { if isMaxAmount { // For max amount sends, use sendAllToAddress to send all available funds try node.onchainPayment().sendAllToAddress( @@ -808,7 +822,12 @@ class LightningService { } } - func send(bolt11: String, sats: UInt64? = nil, params: RouteParametersConfig? = nil) async throws -> PaymentHash { + func send( + bolt11: String, + sats: UInt64? = nil, + params: RouteParametersConfig? = nil, + beforeSubmission: @escaping () throws -> Void = {} + ) async throws -> PaymentHash { guard let node else { throw AppError(serviceError: .nodeNotSetup) } @@ -816,7 +835,7 @@ class LightningService { Logger.info("Paying bolt11: \(bolt11)") do { - return try await ServiceQueue.background(.ldk) { + return try await Self.submitPayment(beforeSubmission: beforeSubmission) { if let sats { try node.bolt11Payment().sendUsingAmount( invoice: .fromStr(invoiceStr: bolt11), amountMsat: sats * 1000, routeParameters: params @@ -832,6 +851,17 @@ class LightningService { } } + static func submitPayment( + beforeSubmission: @escaping () throws -> Void, + send: @escaping () throws -> Result + ) async throws -> Result { + try await ServiceQueue.background(.ldk) { + // Authorization can precede a queue wait; only the actual submission time is relevant here. + try beforeSubmission() + return try send() + } + } + func closeChannel(userChannelId: ChannelId, counterpartyNodeId: PublicKey, force: Bool = false, forceCloseReason: String? = nil) async throws { guard let node else { throw AppError(serviceError: .nodeNotStarted) diff --git a/Bitkit/Services/OnChainHwService.swift b/Bitkit/Services/OnChainHwService.swift index 2cabcf081..52c1f6546 100644 --- a/Bitkit/Services/OnChainHwService.swift +++ b/Bitkit/Services/OnChainHwService.swift @@ -117,9 +117,10 @@ class OnChainHwService { /// Broadcast a signed raw transaction via Electrum. /// - Returns: The transaction ID (txid) - func broadcastRawTx(serializedTx: String, electrumUrl: String) async throws -> String { + func broadcastRawTx(serializedTx: String, electrumUrl: String, paymentDeadline: PaykitPreciseInstant? = nil) async throws -> String { try await ServiceQueue.background(.core) { - try await onchainBroadcastRawTx(serializedTx: serializedTx, electrumUrl: electrumUrl) + try PaykitPaymentRequest.checkPaymentDeadline(paymentDeadline) + return try await onchainBroadcastRawTx(serializedTx: serializedTx, electrumUrl: electrumUrl) } } diff --git a/Bitkit/Services/PaykitPaymentProofService.swift b/Bitkit/Services/PaykitPaymentProofService.swift index a2ec54aa9..95a05415f 100644 --- a/Bitkit/Services/PaykitPaymentProofService.swift +++ b/Bitkit/Services/PaykitPaymentProofService.swift @@ -18,11 +18,20 @@ struct PaykitOnchainPaymentResolution: Equatable { let identity: String let requestId: PaykitPaymentRequest.ID let transactionId: String + let walletId: String + + init(identity: String, requestId: PaykitPaymentRequest.ID, transactionId: String, walletId: String = WalletScope.default) { + self.identity = identity + self.requestId = requestId + self.transactionId = transactionId + self.walletId = walletId + } } struct PendingPaykitPaymentProof: Codable, Equatable { let identity: String let requestId: PaykitPaymentRequest.ID + let paymentAppId: String let paymentEndpointIdentifier: String let kind: PaykitPaymentProofKind let billingPeriod: PaykitBillingPeriod? @@ -34,13 +43,14 @@ struct PendingPaykitPaymentProof: Codable, Equatable { let onchainWalletId: String? var onchainMatchingTransactionIdsBeforeAttempt: Set? - var hasUnsupportedOnchainWallet: Bool { - kind == .onchain && onchainWalletId != nil && onchainWalletId != WalletScope.default + var effectiveOnchainWalletId: String { + onchainWalletId ?? WalletScope.default } init( identity: String, requestId: PaykitPaymentRequest.ID, + paymentAppId: String, paymentEndpointIdentifier: String, kind: PaykitPaymentProofKind, billingPeriod: PaykitBillingPeriod? = nil, @@ -54,6 +64,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 +111,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 @@ -140,22 +150,47 @@ struct PaykitLightningPaymentProofLookup: PaykitLightningPaymentProofLookingUp { } protocol PaykitOnchainPaymentProofLookingUp: Sendable { - func existingTransactionIds(address: String, amountSats: UInt64) async throws -> Set - func transactionId(address: String, amountSats: UInt64, excluding transactionIds: Set) async throws -> String? + func supportsWallet(_ walletId: String) -> Bool + func existingTransactionIds(address: String, amountSats: UInt64, walletId: String) async throws -> Set + func transactionId(address: String, amountSats: UInt64, walletId: String, excluding transactionIds: Set) async throws -> String? +} + +extension PaykitOnchainPaymentProofLookingUp { + func supportsWallet(_ walletId: String) -> Bool { + walletId == WalletScope.default + } } struct PaykitOnchainPaymentProofLookup: PaykitOnchainPaymentProofLookingUp { - func existingTransactionIds(address: String, amountSats: UInt64) async throws -> Set { - try await Set(matchingTransactionIds(address: address, amountSats: amountSats).map { $0.lowercased() }) + func supportsWallet(_ walletId: String) -> Bool { + walletId == WalletScope.default || !HwKnownDeviceStorage.loadAll(walletId: walletId).isEmpty } - func transactionId(address: String, amountSats: UInt64, excluding transactionIds: Set) async throws -> String? { - try await matchingTransactionIds(address: address, amountSats: amountSats) + func existingTransactionIds(address: String, amountSats: UInt64, walletId: String) async throws -> Set { + try await Set(matchingTransactionIds(address: address, amountSats: amountSats, walletId: walletId).map { $0.lowercased() }) + } + + func transactionId(address: String, amountSats: UInt64, walletId: String, excluding transactionIds: Set) async throws -> String? { + try await matchingTransactionIds(address: address, amountSats: amountSats, walletId: walletId) .reversed() .first { !transactionIds.contains($0.lowercased()) } } - private func matchingTransactionIds(address: String, amountSats: UInt64) async throws -> [String] { + private func matchingTransactionIds(address: String, amountSats: UInt64, walletId: String) async throws -> [String] { + guard supportsWallet(walletId) else { throw PaykitPaymentRequestError.requestUnavailable } + if walletId != WalletScope.default { + let activities = try await CoreService.shared.activity.get(filter: .onchain, txType: .sent, walletId: walletId) + var transactionIds: [String] = [] + for case let .onchain(activity) in activities { + guard activity.walletId == walletId, activity.doesExist, + let details = try await CoreService.shared.activity.getTransactionDetails(txid: activity.txId, walletId: walletId), + details.walletId == walletId, + details.outputs.contains(where: { $0.scriptpubkeyAddress == address && $0.value == amountSats }) + else { continue } + transactionIds.append(activity.txId) + } + return transactionIds + } guard let payments = await LightningService.shared.listPayments() else { throw PaykitPaymentRequestError.requestUnavailable } @@ -202,7 +237,7 @@ actor PaykitPaymentProofService { func restoreBackup(_ proofs: [PaykitPaymentStateBackup.Proof]) async throws { let restoredProofs = try proofs.map { try $0.restored() } - let unsupportedWalletProofCount = restoredProofs.filter(\.hasUnsupportedOnchainWallet).count + let unsupportedWalletProofCount = restoredProofs.filter(hasUnsupportedOnchainWallet).count if unsupportedWalletProofCount > 0 { logWarning("Retained \(unsupportedWalletProofCount) pending Paykit payment proof(s) for another wallet") } @@ -231,13 +266,17 @@ actor PaykitPaymentProofService { func prepare( request: PaykitPaymentRequest, + paymentAppId: String, paymentEndpointIdentifier: String, - kind: PaykitPaymentProofKind + kind: PaykitPaymentProofKind, + walletId: String = WalletScope.default ) async throws { let proof = try await pendingProof( request: request, + paymentAppId: paymentAppId, paymentEndpointIdentifier: paymentEndpointIdentifier, - kind: kind + kind: kind, + walletId: walletId ) var pendingProofs = try await loadProofs() @@ -251,7 +290,8 @@ actor PaykitPaymentProofService { pendingProofs.removeAll { PubkyPublicKeyFormat.matches($0.identity, proof.identity) && $0.requestId == request.id && - !$0.hasUnsupportedOnchainWallet && + $0.effectiveOnchainWalletId == proof.effectiveOnchainWalletId && + !hasUnsupportedOnchainWallet($0) && !$0.paymentStarted && $0.paymentIdentifier == nil && $0.proofData == nil @@ -262,13 +302,16 @@ actor PaykitPaymentProofService { private func pendingProof( request: PaykitPaymentRequest, + paymentAppId: String, paymentEndpointIdentifier: String, - kind: PaykitPaymentProofKind + kind: PaykitPaymentProofKind, + walletId: String = WalletScope.default ) async throws -> PendingPaykitPaymentProof { guard request.acceptedPaymentEndpointIdentifiers.contains(paymentEndpointIdentifier), Self.endpoint(paymentEndpointIdentifier, supports: kind), + kind != .onchain || onchainPaymentLookup.supportsWallet(walletId), let identityStatus = try await sdk.identityStatus(), - identityStatus.liveSessionAvailable, + identityStatus.capability == .privateLinkCapable, let publicKey = identityStatus.publicKey, let identity = PubkyPublicKeyFormat.normalized(publicKey) else { @@ -278,11 +321,13 @@ actor PaykitPaymentProofService { return PendingPaykitPaymentProof( identity: identity, requestId: request.id, + paymentAppId: paymentAppId, paymentEndpointIdentifier: paymentEndpointIdentifier, kind: kind, billingPeriod: request.billingPeriod, paymentIdentifier: nil, - proofData: nil + proofData: nil, + onchainWalletId: kind == .onchain ? walletId : nil ) } @@ -308,18 +353,20 @@ actor PaykitPaymentProofService { try await persist(pendingProofs) } - func markOnchainPaymentStarted(_ request: PaykitPaymentRequest, address: String) async throws { + func markOnchainPaymentStarted(_ request: PaykitPaymentRequest, address: String, walletId: String = WalletScope.default) async throws { let identity = try await currentIdentity() let existingTransactionIds = try await onchainPaymentLookup.existingTransactionIds( address: address, - amountSats: request.amountSats + amountSats: request.amountSats, + walletId: walletId ) var pendingProofs = try await loadProofs() guard let index = pendingProofs.lastIndex(where: { PubkyPublicKeyFormat.matches($0.identity, identity) && $0.requestId == request.id && $0.kind == .onchain && - !$0.hasUnsupportedOnchainWallet && + $0.effectiveOnchainWalletId == walletId && + !hasUnsupportedOnchainWallet($0) && !$0.paymentStarted && $0.paymentIdentifier == nil && $0.proofData == nil @@ -363,18 +410,33 @@ actor PaykitPaymentProofService { func completeOnchainPayment( _ request: PaykitPaymentRequest, txid: String, - paymentEndpointIdentifier: String + paymentAppId: String, + paymentEndpointIdentifier: String, + walletId: String = WalletScope.default ) async { + let prepared = try? await loadProofs().filter { + $0.requestId == request.id && $0.paymentAppId == paymentAppId && + $0.paymentEndpointIdentifier == paymentEndpointIdentifier && + $0.kind == .onchain && $0.paymentStarted && $0.effectiveOnchainWalletId == walletId && + $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, walletId: walletId, fallbackProof: proof) + return + } guard let identity = try? await currentIdentity() else { return } let fallbackProof = try? await pendingProof( request: request, + paymentAppId: paymentAppId, paymentEndpointIdentifier: paymentEndpointIdentifier, - kind: .onchain + kind: .onchain, + walletId: walletId ) await completeOnchainPayment( requestId: request.id, identity: identity, txid: txid, + walletId: walletId, fallbackProof: fallbackProof ) } @@ -383,6 +445,7 @@ actor PaykitPaymentProofService { requestId: PaykitPaymentRequest.ID, identity: String, txid: String, + walletId: String, fallbackProof: PendingPaykitPaymentProof? = nil ) async { guard Self.isHex(txid, byteCount: 32) else { @@ -396,7 +459,7 @@ actor PaykitPaymentProofService { PubkyPublicKeyFormat.matches($0.identity, identity) && $0.requestId == requestId && $0.kind == .onchain && - !$0.hasUnsupportedOnchainWallet && + $0.effectiveOnchainWalletId == walletId && $0.paymentStarted && $0.paymentIdentifier == nil && $0.proofData == nil @@ -420,7 +483,8 @@ actor PaykitPaymentProofService { Self.onchainPaymentResolutionSubject.send(PaykitOnchainPaymentResolution( identity: completedProof.identity, requestId: requestId, - transactionId: txid.lowercased() + transactionId: txid.lowercased(), + walletId: completedProof.effectiveOnchainWalletId )) } catch { logWarning("Failed to load a Paykit on-chain payment proof; attempting immediate delivery: \(error)") @@ -432,7 +496,8 @@ actor PaykitPaymentProofService { Self.onchainPaymentResolutionSubject.send(PaykitOnchainPaymentResolution( identity: fallbackProof.identity, requestId: requestId, - transactionId: txid.lowercased() + transactionId: txid.lowercased(), + walletId: fallbackProof.effectiveOnchainWalletId )) } } @@ -445,7 +510,9 @@ actor PaykitPaymentProofService { func failLightningPayment(paymentHash: String, submissionError: Error) async -> Bool { let underlyingError = (submissionError as? AppError)?.underlyingError ?? submissionError - if let serviceError = underlyingError as? CustomServiceError { + if underlyingError as? PaykitPaymentRequestError == .requestExpired { + // The local deadline gate runs before submitting to the node. + } else if let serviceError = underlyingError as? CustomServiceError { guard serviceError == .nodeNotSetup || serviceError == .nodeNotStarted else { return false } } else if let nodeError = underlyingError as? NodeError { switch nodeError { @@ -461,19 +528,19 @@ actor PaykitPaymentProofService { return true } - func failOnchainPayment(_ request: PaykitPaymentRequest) async { + func failOnchainPayment(_ request: PaykitPaymentRequest, walletId: String = WalletScope.default) async { await removeRequestProofs(request) { $0.kind == .onchain && - !$0.hasUnsupportedOnchainWallet && + $0.effectiveOnchainWalletId == walletId && $0.paymentStarted && $0.paymentIdentifier == nil && $0.proofData == nil } } - func cancelPreparation(_ request: PaykitPaymentRequest) async { + func cancelPreparation(_ request: PaykitPaymentRequest, walletId: String = WalletScope.default) async { await removeRequestProofs(request) { - !$0.hasUnsupportedOnchainWallet && + $0.effectiveOnchainWalletId == walletId && !$0.paymentStarted && $0.paymentIdentifier == nil && $0.proofData == nil @@ -485,7 +552,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 } @@ -496,7 +563,7 @@ actor PaykitPaymentProofService { } for proof in identityProofs { do { - guard !proof.hasUnsupportedOnchainWallet else { continue } + guard !hasUnsupportedOnchainWallet(proof) else { continue } if proof.proofData != nil { await submit(proof) continue @@ -508,10 +575,13 @@ actor PaykitPaymentProofService { let txid = try await onchainPaymentLookup.transactionId( address: address, amountSats: amountSats, + walletId: proof.effectiveOnchainWalletId, excluding: proof.onchainMatchingTransactionIdsBeforeAttempt ?? [] ) { - await completeOnchainPayment(requestId: proof.requestId, identity: proof.identity, txid: txid) + await completeOnchainPayment( + requestId: proof.requestId, identity: proof.identity, txid: txid, walletId: proof.effectiveOnchainWalletId + ) continue } guard proof.kind == PaykitPaymentProofKind.lightning, let paymentHash = proof.paymentIdentifier else { continue } @@ -537,14 +607,14 @@ actor PaykitPaymentProofService { identity: String ) async -> [PendingPaykitPaymentProof] { let candidates = pendingProofs.filter { - $0.hasUnsupportedOnchainWallet && PubkyPublicKeyFormat.matches($0.identity, identity) + hasUnsupportedOnchainWallet($0) && PubkyPublicKeyFormat.matches($0.identity, identity) } guard !candidates.isEmpty else { return pendingProofs } 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 +639,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 +696,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 } @@ -636,7 +704,7 @@ actor PaykitPaymentProofService { return proof.requestId }) let remainingProofs = proofs.filter { - !belongsToSubscription($0) || $0.hasUnsupportedOnchainWallet || $0.paymentStarted || $0.paymentIdentifier != nil || $0.proofData != nil + !belongsToSubscription($0) || hasUnsupportedOnchainWallet($0) || $0.paymentStarted || $0.paymentIdentifier != nil || $0.proofData != nil } if remainingProofs != proofs { try await persist(remainingProofs) @@ -659,23 +727,23 @@ actor PaykitPaymentProofService { @discardableResult private func submit(_ pendingProof: PendingPaykitPaymentProof) async -> Bool { - guard !pendingProof.hasUnsupportedOnchainWallet, let proofData = pendingProof.proofData else { return false } + guard !hasUnsupportedOnchainWallet(pendingProof), 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 +751,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, @@ -712,6 +780,10 @@ actor PaykitPaymentProofService { try await store.load() } + private func hasUnsupportedOnchainWallet(_ proof: PendingPaykitPaymentProof) -> Bool { + proof.kind == .onchain && !onchainPaymentLookup.supportsWallet(proof.effectiveOnchainWalletId) + } + private func persist(_ proofs: [PendingPaykitPaymentProof]) async throws { try await store.save(proofs) Self.proofStateChangedSubject.send() @@ -766,7 +838,7 @@ actor PaykitPaymentProofService { await removeProofs { PubkyPublicKeyFormat.matches($0.identity, proof.identity) && $0.requestId == proof.requestId && - !$0.hasUnsupportedOnchainWallet + $0.effectiveOnchainWalletId == proof.effectiveOnchainWalletId } } @@ -806,6 +878,7 @@ actor PaykitPaymentProofService { static func isDefiniteOnchainPreBroadcastFailure(_ error: Error) -> Bool { let underlyingError = (error as? AppError)?.underlyingError ?? error + if underlyingError as? PaykitPaymentRequestError == .requestExpired { return true } if let serviceError = underlyingError as? CustomServiceError { switch serviceError { case .nodeNotSetup, .nodeNotStarted: diff --git a/Bitkit/Services/PaykitPaymentRequestService.swift b/Bitkit/Services/PaykitPaymentRequestService.swift index f5df8f0e5..daddeb40a 100644 --- a/Bitkit/Services/PaykitPaymentRequestService.swift +++ b/Bitkit/Services/PaykitPaymentRequestService.swift @@ -1,3 +1,4 @@ +import Combine import Foundation import ImageIO import LDKNode @@ -14,6 +15,7 @@ struct PaykitPaymentRequest: Identifiable, Hashable { case recurringRequest = "recurring_request" case unsupportedAsset = "unsupported_asset" case unsupportedPaymentDeadline = "unsupported_payment_deadline" + case invalidPaymentDeadline = "invalid_payment_deadline" case invalidAmount = "invalid_amount" case amountOutOfRange = "amount_out_of_range" case noSupportedEndpoint = "no_supported_endpoint" @@ -38,30 +40,27 @@ 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? let createdAt: Date? let expiresAt: Date? + let paymentDeadline: PaykitPreciseInstant? let acceptedPaymentEndpointIdentifiers: [String] let deliveryStatus: DeliveryStatus? let direction: Direction @@ -77,7 +76,6 @@ struct PaykitPaymentRequest: Identifiable, Hashable { ID( paymentRequestId: paymentRequestId, counterparty: counterparty, - counterpartyReceiverPath: counterpartyReceiverPath, billingPeriodStartsAt: billingPeriod?.startsAt ) } @@ -133,6 +131,8 @@ struct PaykitPaymentRequest: Identifiable, Hashable { return .failure(.nonActionableState) } lifecycleState = actionableState + } else if expectedRole == .payer, let actionableState = actionableLifecycleState(for: record) { + lifecycleState = actionableState } else { lifecycleState = record.state } @@ -140,8 +140,18 @@ struct PaykitPaymentRequest: Identifiable, Hashable { guard record.state != .activeRecurring else { return .failure(.recurringRequest) } guard let terms = record.terms else { return .failure(.missingTerms) } guard terms.recurrence == nil else { return .failure(.recurringRequest) } - if requiresActionableRequest, terms.paymentDeadline != nil { - return .failure(.unsupportedPaymentDeadline) + let paymentDeadline: PaykitPreciseInstant? + if let deadline = terms.paymentDeadline { + guard case .at = deadline else { return .failure(.unsupportedPaymentDeadline) } + guard let timestamp = try? Paykit.paymentDeadlineAt(deadline: deadline, billingPeriod: nil), + let instant = PaykitPreciseInstant(timestamp: timestamp) + else { return .failure(.invalidPaymentDeadline) } + if requiresActionableRequest, instant < PaykitPreciseInstant(date: now) { + return .failure(.expired) + } + paymentDeadline = instant + } else { + paymentDeadline = nil } guard terms.amount.asset == PaykitIssuerInterop.bitcoinAsset else { return .failure(.unsupportedAsset) } guard let amountSats = Self.sats(fromBitcoinAmount: terms.amount.value) else { return .failure(.invalidAmount) } @@ -169,12 +179,12 @@ 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), createdAt: record.lastEventAt.flatMap(Self.parseDate), expiresAt: expiresAt, + paymentDeadline: paymentDeadline, acceptedPaymentEndpointIdentifiers: acceptedPaymentEndpointIdentifiers, deliveryStatus: expectedRole == .payee ? Self.deliveryStatus(from: record.proposalOutboundStatus) : nil, direction: expectedRole == .payer ? .incoming : .outgoing, @@ -213,13 +223,13 @@ 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) note = trimmedNote.isEmpty ? nil : trimmedNote createdAt = createdRecord.lastEventAt.flatMap(Self.parseDate) ?? fallbackCreatedAt expiresAt = draft.expiresAt + paymentDeadline = nil self.acceptedPaymentEndpointIdentifiers = acceptedPaymentEndpointIdentifiers self.deliveryStatus = deliveryStatus direction = .outgoing @@ -235,12 +245,12 @@ struct PaykitPaymentRequest: Identifiable, Hashable { PaykitPaymentRequest( paymentRequestId: paymentRequestId, counterparty: counterparty, - counterpartyReceiverPath: counterpartyReceiverPath, amountValue: amountValue, amountSats: amountSats, note: note, createdAt: createdAt, expiresAt: expiresAt, + paymentDeadline: paymentDeadline, acceptedPaymentEndpointIdentifiers: acceptedPaymentEndpointIdentifiers, deliveryStatus: deliveryStatus, direction: direction, @@ -259,12 +269,12 @@ struct PaykitPaymentRequest: Identifiable, Hashable { ) { paymentRequestId = subscription.paymentRequestId counterparty = subscription.counterparty - counterpartyReceiverPath = subscription.counterpartyReceiverPath amountValue = subscription.amountValue amountSats = subscription.amountSats note = subscription.note createdAt = billingPeriod.startsAt expiresAt = nil + paymentDeadline = nil acceptedPaymentEndpointIdentifiers = subscription.acceptedPaymentEndpointIdentifiers deliveryStatus = nil self.direction = direction @@ -276,12 +286,12 @@ struct PaykitPaymentRequest: Identifiable, Hashable { private init( paymentRequestId: String, counterparty: String, - counterpartyReceiverPath: String, amountValue: String, amountSats: UInt64, note: String?, createdAt: Date?, expiresAt: Date?, + paymentDeadline: PaykitPreciseInstant?, acceptedPaymentEndpointIdentifiers: [String], deliveryStatus: DeliveryStatus?, direction: Direction, @@ -291,12 +301,12 @@ struct PaykitPaymentRequest: Identifiable, Hashable { ) { self.paymentRequestId = paymentRequestId self.counterparty = counterparty - self.counterpartyReceiverPath = counterpartyReceiverPath self.amountValue = amountValue self.amountSats = amountSats self.note = note self.createdAt = createdAt self.expiresAt = expiresAt + self.paymentDeadline = paymentDeadline self.acceptedPaymentEndpointIdentifiers = acceptedPaymentEndpointIdentifiers self.deliveryStatus = deliveryStatus self.direction = direction @@ -306,7 +316,17 @@ struct PaykitPaymentRequest: Identifiable, Hashable { } func isExpired(at date: Date) -> Bool { - lifecycleState == .proposed && (expiresAt.map { $0 <= date } ?? false) + (lifecycleState == .proposed && (expiresAt.map { $0 <= date } ?? false)) || isPaymentDeadlineExpired(at: date) + } + + func isPaymentDeadlineExpired(at date: Date) -> Bool { + paymentDeadline.map { $0 < PaykitPreciseInstant(date: date) } ?? false + } + + static func checkPaymentDeadline(_ deadline: PaykitPreciseInstant?, at date: Date = Date()) throws { + if let deadline, deadline < PaykitPreciseInstant(date: date) { + throw PaykitPaymentRequestError.requestExpired + } } func acceptsLightningInvoiceAmount(milliSatoshis: UInt64?) -> Bool { @@ -326,8 +346,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 +402,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 +444,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 +521,39 @@ enum PaykitPaymentRequestError: LocalizedError, Equatable { } protocol PaykitPaymentRequestSdkHandling: Sendable { - func processPendingPrivateMessages() async throws -> [Paykit.OutboundPrivateCounterpartySendReport] - func receivePrivateMessagesFromLinkedPeers() async throws -> [Paykit.PrivateStreamCounterpartyIntakeReport] + func processOutboundPrivateMessages(counterparty: String) async throws -> Paykit.OutboundPrivateSendReport + func processOutboundPrivateMessages(counterparty: String, priority: PaykitSdkOperationLock.Priority) async throws -> Paykit + .OutboundPrivateSendReport + func processPendingPrivateMessages(priority: PaykitSdkOperationLock.Priority) async throws -> [Paykit.OutboundPrivateCounterpartySendReport] + func receivePrivateMessagesFromLinkedPeers(priority: PaykitSdkOperationLock.Priority) async throws + -> [Paykit.PrivateStreamCounterpartyIntakeReport] func paymentRequests() async throws -> [Paykit.PaymentRequestRecord] + func sharedPaymentRequests() async throws -> [Paykit.PaymentRequestRecord] + func sharedPaymentRequests(priority: PaykitSdkOperationLock.Priority) async throws -> [Paykit.PaymentRequestRecord] + func sharedPaymentRequests(expectedIdentity: String) async throws -> [Paykit.PaymentRequestRecord] func identityStatus() async throws -> Paykit.IdentityStatus? + func identityStatus(priority: PaykitSdkOperationLock.Priority) async throws -> Paykit.IdentityStatus? func linkedPeers() async throws -> [Paykit.LinkedPeerRecord] - func paymentRequestReceiverPaths(publicKey: String, priority: PaykitPublicReadPriority) async throws -> [String] + func linkedPeers(priority: PaykitSdkOperationLock.Priority) async throws -> [Paykit.LinkedPeerRecord] + 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 +561,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 +593,25 @@ 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, + priority: PaykitSdkOperationLock.Priority = .ordered, + messagePriority: PaykitSdkOperationLock.Priority = .ordered + ) async throws -> PaykitPaymentRequestSnapshot { + if mode == .full { + try await processPendingMessages(priority: messagePriority) + } + if mode >= .inbox { + let intakeReports = try await sdk.receivePrivateMessagesFromLinkedPeers(priority: messagePriority) + logIntakeFailures(intakeReports) + } let synchronizationDate = now() - let records = try await sdk.paymentRequests() - let blockedPeers = try await sdk.linkedPeers().filter { $0.state == .blocked } + let sharedRecords = try await sdk.sharedPaymentRequests(priority: priority) + let records = sharedRecords.filter(PaykitSdkService.isBitkitPaymentRequest) + let blockedPeers = try await sdk.linkedPeers(priority: priority).filter { $0.state == .blocked } let availableRecords = records.filter { record in !blockedPeers.contains { - PubkyPublicKeyFormat.matches($0.counterparty, record.counterparty) && $0.counterpartyReceiverPath == record.counterpartyReceiverPath + PubkyPublicKeyFormat.matches($0.counterparty, record.counterparty) } } var rejections: [IncomingPaykitPaymentRequestRejection] = [] @@ -602,7 +645,8 @@ struct PaykitPaymentRequestService { return PaykitPaymentRequestSnapshot( incoming: incoming, history: history, - subscriptions: subscriptions + subscriptions: subscriptions, + receivedPaymentContacts: PaykitReceivedPaymentContacts(records: sharedRecords) ) } @@ -610,7 +654,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,61 +663,80 @@ 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, + let sdkPriority: PaykitSdkOperationLock.Priority = priority == .bulk ? .background : .interactive + guard let identityStatus = try await sdk.identityStatus(priority: sdkPriority), + identityStatus.capability == .privateLinkCapable, PubkyPublicKeyFormat.matches(identityStatus.publicKey, expectedIdentity) else { return unavailable } var seenSavedKeys = Set() let savedKeys = savedPublicKeys.compactMap(PubkyPublicKeyFormat.normalized).filter { 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 linkedPeers = try await sdk.linkedPeers(priority: sdkPriority).filter { $0.state == .linked } + let linkedKeys = Set(linkedPeers.compactMap { PubkyPublicKeyFormat.normalized($0.counterparty) }) + let candidates = savedKeys.filter { linkedKeys.contains($0) } + let lookup: @Sendable (String) async throws -> (String, Result) = { [sdk] publicKey in 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 +753,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 +769,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 +812,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 +833,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 +853,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 +901,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) @@ -843,10 +910,15 @@ struct PaykitPaymentRequestService { } func ensurePaymentAllowed(_ request: PaykitPaymentRequest) async throws { + guard !request.isPaymentDeadlineExpired(at: now()) else { + throw PaykitPaymentRequestError.requestExpired + } guard try await !sdk.linkedPeers().contains(where: { - $0.state == .blocked && PubkyPublicKeyFormat.matches($0.counterparty, request.counterparty) && - $0.counterpartyReceiverPath == request.counterpartyReceiverPath + $0.state == .blocked && PubkyPublicKeyFormat.matches($0.counterparty, request.counterparty) }) else { throw PaykitPaymentRequestError.requestUnavailable } + guard !request.isPaymentDeadlineExpired(at: now()) else { + throw PaykitPaymentRequestError.requestExpired + } } func accept(_ request: PaykitPaymentRequest) async throws { @@ -856,10 +928,17 @@ 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 { @@ -869,7 +948,6 @@ struct PaykitPaymentRequestService { _ = try await sdk.rejectPaymentRequest( counterparty: request.counterparty, - counterpartyReceiverPath: request.counterpartyReceiverPath, paymentRequestId: request.paymentRequestId, reason: nil ) @@ -879,7 +957,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,10 +970,8 @@ struct PaykitPaymentRequestService { let record = try await sdk.acceptPaymentRequest( counterparty: subscription.counterparty, - counterpartyReceiverPath: subscription.counterpartyReceiverPath, paymentRequestId: subscription.paymentRequestId ) - _ = try? await processPendingMessages() guard let subscription = PaykitSubscription(record: record) else { throw PaykitPaymentRequestError.requestUnavailable } @@ -910,7 +985,6 @@ struct PaykitPaymentRequestService { let record = try await sdk.cancelPaymentRequest( counterparty: subscription.counterparty, - counterpartyReceiverPath: subscription.counterpartyReceiverPath, paymentRequestId: subscription.paymentRequestId, reason: nil ) @@ -956,9 +1030,22 @@ struct PaykitPaymentRequestService { } @discardableResult - private func processPendingMessages() async throws -> [Paykit.OutboundPrivateCounterpartySendReport] { + private func processPendingMessages( + to counterparty: String? = nil, + priority: PaykitSdkOperationLock.Priority = .ordered + ) 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, priority: .interactive) + reports = [Paykit.OutboundPrivateCounterpartySendReport( + counterparty: counterparty, + report: report, + error: nil + )] + } else { + reports = try await sdk.processPendingPrivateMessages(priority: priority) + } for report in reports { if let error = report.error { logWarning( @@ -983,16 +1070,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 +1124,7 @@ struct PaykitPaymentRequestService { } } -protocol PaykitPaymentRequestPresentationStoring { +protocol PaykitPaymentRequestIdStoring { func load(identity: String) throws -> Set func save(_ ids: Set, identity: String) throws } @@ -1063,13 +1181,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 +1202,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 +1237,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 +1248,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] @@ -1121,9 +1263,13 @@ final class PaykitPaymentRequestManager { private let subscriptionNow: @Sendable () -> Date private let logWarning: @Sendable (String) -> Void private let isAvailable: @MainActor () -> Bool + private let paymentActivity: PaykitPaymentActivity + private let scheduleAcceptedRequestDelivery: @MainActor (String) async -> Void private var processingRequestIds: Set = [] private var approvedPaymentRequestIds: Set = [] + private var acceptedRequestIds: Set = [] private var presentedRequestIds: Set = [] + private var dismissedPreparingRequestIds: Set = [] private var presentationRetryAttempts: [PaykitPaymentRequest.ID: Int] = [:] private var presentationRetryDeadlines: [PaykitPaymentRequest.ID: ContinuousClock.Instant] = [:] private var automaticPresentationDiagnosticReasons: @@ -1131,7 +1277,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 +1297,7 @@ final class PaykitPaymentRequestManager { private var subscriptionClockOffset: TimeInterval { subscriptionNow().timeIntervalSince(now()) } + private var presentedSubscriptionProposalIds: Set = [] private var dismissedSubscriptionPaymentIds: Set = [] private var persistedSubscriptionState = PaykitSubscriptionState() @@ -1164,8 +1311,11 @@ final class PaykitPaymentRequestManager { } init( + scheduleAcceptedRequestDelivery: (@MainActor (String) async -> Void)? = nil, + paymentActivity: PaykitPaymentActivity? = nil, 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 +1344,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 @@ -1203,6 +1354,10 @@ final class PaykitPaymentRequestManager { self.subscriptionNow = subscriptionNow self.retryNow = retryNow self.isAvailable = isAvailable + self.paymentActivity = paymentActivity ?? .shared + self.scheduleAcceptedRequestDelivery = scheduleAcceptedRequestDelivery ?? { + await PrivatePaykitService.shared.schedulePrivatePaymentRecovery(for: $0) + } self.logWarning = logWarning } @@ -1212,6 +1367,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 +1400,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 +1429,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 +1442,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 +1481,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 +1520,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 +1594,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 +1654,23 @@ final class PaykitPaymentRequestManager { return subscription } - func refresh() async { - await refresh(excludingProtectedRequestId: nil) + @discardableResult + func refresh( + mode: PaykitPaymentRequestRefreshMode = .full, + forceFresh: Bool = false, + messagePriority: PaykitSdkOperationLock.Priority = .ordered + ) async -> Bool { + await refresh( + excludingProtectedRequestId: nil, + mode: mode, + forceFresh: forceFresh, + messagePriority: forceFresh ? .ordered : messagePriority + ) } /// Applies a changed subscription clock offset: waits for a refresh already reading the old clock, then refreshes again. func refreshAfterSubscriptionClockChange() async { - if let refreshTask { - await refreshTask.value - } - await refresh() + await refresh(forceFresh: true) } func synchronizeSubscriptionNotifications(enabled: Bool) async { @@ -1506,30 +1686,60 @@ final class PaykitPaymentRequestManager { ) } - private func refresh(excludingProtectedRequestId: PaykitPaymentRequest.ID?) async { + @discardableResult + private func refresh( + excludingProtectedRequestId: PaykitPaymentRequest.ID?, + mode: PaykitPaymentRequestRefreshMode = .full, + forceFresh: Bool = false, + messagePriority: PaykitSdkOperationLock.Priority = .ordered + ) async -> Bool { if let refreshTask { - await refreshTask.value - return + let generation = stateGeneration + let refreshed = await refreshTask.task.value + guard generation == stateGeneration, !Task.isCancelled else { return false } + if forceFresh || mode > refreshTask.mode { + return await refresh(excludingProtectedRequestId: excludingProtectedRequestId, mode: mode, messagePriority: messagePriority) + } + return refreshed } refreshGeneration += 1 let generation = refreshGeneration let task = Task { [weak self] in - guard let self else { return } - await performRefresh(generation: generation, excludingProtectedRequestId: excludingProtectedRequestId) + guard let self else { return false } + let refreshed = await performRefresh( + generation: generation, + excludingProtectedRequestId: excludingProtectedRequestId, + mode: mode, + messagePriority: messagePriority + ) + guard generation == refreshGeneration else { return false } + refreshTask = nil + return refreshed } - refreshTask = task - await task.value - - guard generation == refreshGeneration else { return } - refreshTask = nil + refreshTask = (mode, task) + let refreshed = await task.value + return refreshed && !Task.isCancelled } func ensurePaymentAllowed(_ request: PaykitPaymentRequest) async throws { + let generation = stateGeneration do { + guard !request.isPaymentDeadlineExpired(at: now()) else { + throw PaykitPaymentRequestError.requestExpired + } + guard let identity = activeIdentity, isApprovedForPayment(request) else { + throw PaykitPaymentRequestError.requestUnavailable + } 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 || error as? PaykitPaymentRequestError == .requestExpired { + approvedPaymentRequestIds.remove(request.id) + } throw error } } @@ -1538,6 +1748,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 +1761,40 @@ 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) + guard !$0.isExpired(at: now()) else { throw PaykitPaymentRequestError.requestExpired } 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) + deferAcceptedRequestDelivery(counterparty: $0.counterparty, identity: identity, generation: actionGeneration) + } 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 { @@ -1572,6 +1814,16 @@ final class PaykitPaymentRequestManager { } } + private func deferAcceptedRequestDelivery(counterparty: String, identity: String, generation: Int) { + paymentActivity.runWhenIdle(.acceptance(identity: identity, counterparty: counterparty)) { [weak self] in + guard let self, + generation == stateGeneration, + PubkyPublicKeyFormat.matches(activeIdentity, identity) + else { return } + await scheduleAcceptedRequestDelivery(counterparty) + } + } + func dismiss(_ request: PaykitPaymentRequest) async throws { if request.billingPeriod != nil { guard dismissSubscriptionPayment(request) else { @@ -1597,7 +1849,9 @@ final class PaykitPaymentRequestManager { } private func clearNotificationTarget(matching request: PaykitPaymentRequest) { - if PaykitSubscriptionNotificationTargetStore.load()?.matches(request) == true { + if let identity = activeIdentity, let target = PaykitSubscriptionNotificationTargetStore.load(), + target.matches(identity: identity), target.matches(request) + { PaykitSubscriptionNotificationTargetStore.clear() } } @@ -1677,13 +1931,14 @@ final class PaykitPaymentRequestManager { guard actionGeneration == stateGeneration, PubkyPublicKeyFormat.matches(self.activeIdentity, activeIdentity) else { return nil } + deferAcceptedRequestDelivery(counterparty: current.counterparty, identity: activeIdentity, generation: actionGeneration) subscriptionAcceptedAt[current.id] = acceptanceInstant presentedSubscriptionProposalIds.insert(current.id) requestedSubscriptionProposalId = nil persistSubscriptionState(identity: activeIdentity) await applyCommittedSubscription(acceptedSubscription, at: subscriptionNow()) invalidateRefresh() - await refresh() + await refresh(mode: .stored) return pendingRequests .filter { $0.belongs(to: current) } .min { ($0.billingPeriod?.startsAt ?? .distantFuture) < ($1.billingPeriod?.startsAt ?? .distantFuture) } @@ -1733,13 +1988,31 @@ final class PaykitPaymentRequestManager { requestedPresentationId == nil else { return false } presentationGeneration += 1 + dismissedPreparingRequestIds.remove(request.id) presentationRetryAttempts.removeValue(forKey: request.id) presentationRetryDeadlines.removeValue(forKey: request.id) requestedPresentationId = request.id + clearNotificationTarget(matching: request) schedulePresentationRetry() return true } + func dismissPreparingRequest(_ request: PaykitPaymentRequest) { + guard pendingRequests.contains(where: { $0.id == request.id }), + requestedPresentationId == nil || requestedPresentationId == request.id, + !presentedRequestIds.contains(request.id) || requestedPresentationId == request.id + else { return } + dismissedPreparingRequestIds.insert(request.id) + clearNotificationTarget(matching: request) + presentationGeneration += 1 + if requestedPresentationId == request.id { + requestedPresentationId = nil + } + presentationRetryAttempts.removeValue(forKey: request.id) + presentationRetryDeadlines.removeValue(forKey: request.id) + schedulePresentationRetry() + } + func clear() { stateGeneration += 1 let clearedStateGeneration = stateGeneration @@ -1755,13 +2028,16 @@ final class PaykitPaymentRequestManager { pendingRequests = [] historyRequests = [] subscriptions = [] + receivedPaymentContacts = PaykitReceivedPaymentContacts() eligibleTargets = [] processingRequestIds = [] approvedPaymentRequestIds = [] + acceptedRequestIds = [] activeIdentity = nil savedPublicKeys = [] presentedRequestIds = [] persistedPresentedRequestIds = [] + dismissedPreparingRequestIds = [] presentationRetryAttempts = [:] presentationRetryDeadlines = [:] automaticPresentationDiagnosticReasons = [:] @@ -1797,6 +2073,7 @@ final class PaykitPaymentRequestManager { return pendingRequests.filter { !presentedRequestIds.contains($0.id) && + !dismissedPreparingRequestIds.contains($0.id) && !processingRequestIds.contains($0.id) && (presentationRetryDeadlines[$0.id].map { $0 <= date } ?? true) } @@ -1828,13 +2105,31 @@ final class PaykitPaymentRequestManager { return requestedPresentationId.map { $0 == request.id } ?? true } + func isWaitingForPresentationRetry(_ request: PaykitPaymentRequest) -> Bool { + pendingRequests.contains(where: { $0.id == request.id }) && + !processingRequestIds.contains(request.id) && + !dismissedPreparingRequestIds.contains(request.id) && + presentationRetryDeadlines[request.id] != nil && + (requestedPresentationId.map { $0 == request.id } ?? !presentedRequestIds.contains(request.id)) + } + func isApprovedForPayment(_ request: PaykitPaymentRequest) -> Bool { - approvedPaymentRequestIds.contains(request.id) + let date = now() + guard approvedPaymentRequestIds.contains(request.id), !request.isPaymentDeadlineExpired(at: date) else { return false } + guard let billingPeriod = request.billingPeriod else { + return historyRequests.contains { + $0.id == request.id && $0.direction == .incoming && $0.lifecycleState == .accepted && !$0.isPaymentDeadlineExpired(at: date) + } + } + 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 @@ -1860,6 +2155,7 @@ final class PaykitPaymentRequestManager { } guard sdkLifecycleState == .accepted, actionGeneration == stateGeneration, + !acceptedRequest.isPaymentDeadlineExpired(at: now()), !pendingRequests.contains(where: { $0.id == request.id }) else { return } @@ -1890,7 +2186,9 @@ final class PaykitPaymentRequestManager { let payableStates: [Paykit.PaymentRequestLifecycleState] = pendingRequest == nil ? [.accepted] : [.proposed, .accepted] guard let sdkLifecycleState, payableStates.contains(sdkLifecycleState), - actionGeneration == stateGeneration + sdkLifecycleState != .accepted || acceptedRequestIds.contains(id), + actionGeneration == stateGeneration, + !request.isPaymentDeadlineExpired(at: now()) else { return nil } if let pendingRequest = pendingRequests.first(where: { $0.id == id }) { return pendingRequest @@ -1997,22 +2295,26 @@ final class PaykitPaymentRequestManager { private func performRefresh( generation: Int, - excludingProtectedRequestId: PaykitPaymentRequest.ID? - ) async { + excludingProtectedRequestId: PaykitPaymentRequest.ID?, + mode: PaykitPaymentRequestRefreshMode, + messagePriority: PaykitSdkOperationLock.Priority + ) async -> Bool { do { - let snapshot = try await service.synchronize() - guard generation == refreshGeneration, let activeIdentity else { return } + let snapshot = try await service.synchronize(mode: mode, priority: .background, messagePriority: messagePriority) + guard generation == refreshGeneration, let activeIdentity else { return false } async let completedProofKinds = completedPaymentProofKinds(activeIdentity) async let inFlightRequestIds = inFlightPaymentRequestIds(activeIdentity) let (locallyCompletedProofKinds, locallyInFlightRequestIds) = await (completedProofKinds, inFlightRequestIds) let locallyCompletedRequestIds = Set(locallyCompletedProofKinds.keys) guard generation == refreshGeneration, PubkyPublicKeyFormat.matches(self.activeIdentity, activeIdentity) - else { return } + else { return false } + pruneAcceptedRequestIds(snapshot.history, identity: activeIdentity) let refreshDate = now() let subscriptionDate = subscriptionNow() 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 +2366,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) } @@ -2107,12 +2410,15 @@ final class PaykitPaymentRequestManager { persistPresentedRequestIds() discardExpiredRequests(handledRequestedExpirationId: handledRequestedExpirationId) schedulePresentationRetry() + return generation == refreshGeneration && + PubkyPublicKeyFormat.matches(self.activeIdentity, activeIdentity) && !Task.isCancelled } catch is CancellationError { - return + return false } catch { - guard generation == refreshGeneration else { return } + guard generation == refreshGeneration else { return false } discardExpiredRequests() logWarning("Failed to refresh incoming Paykit payment requests: \(error)") + return false } } @@ -2186,6 +2492,7 @@ final class PaykitPaymentRequestManager { try await operation(request) guard actionGeneration == stateGeneration else { return } if markApprovedForPayment { + guard !request.isPaymentDeadlineExpired(at: now()) else { throw PaykitPaymentRequestError.requestExpired } approvedPaymentRequestIds.insert(request.id) } if preservePending { @@ -2219,10 +2526,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..8248c10fc 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" ) } @@ -297,12 +295,16 @@ actor PrivatePaykitAddressReservationStore { } func clearContactAssignment(publicKey: String) { - guard let normalizedKey = PubkyPublicKeyFormat.normalized(publicKey) else { return } + clearContactAssignments(publicKeys: [publicKey]) + } + + func clearContactAssignments(publicKeys: [String]) { + let normalizedKeys = Set(publicKeys.compactMap(PubkyPublicKeyFormat.normalized)) let previousCount = ledger.contactAssignments.count let previousHistoryCount = ledger.contactAssignmentHistory.count - ledger.contactAssignments = ledger.contactAssignments.filter { Self.publicKey(fromAssignmentKey: $0.key) != normalizedKey } - ledger.contactAssignmentHistory = ledger.contactAssignmentHistory.filter { Self.publicKey(fromAssignmentKey: $0.key) != normalizedKey } + ledger.contactAssignments = ledger.contactAssignments.filter { !normalizedKeys.contains($0.key) } + ledger.contactAssignmentHistory = ledger.contactAssignmentHistory.filter { !normalizedKeys.contains($0.key) } let removedCurrent = ledger.contactAssignments.count != previousCount let removedHistory = ledger.contactAssignmentHistory.count != previousHistoryCount guard removedCurrent || removedHistory else { return } @@ -314,9 +316,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 +386,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..f87ecfb1d 100644 --- a/Bitkit/Services/PrivatePaykitService+Contacts.swift +++ b/Bitkit/Services/PrivatePaykitService+Contacts.swift @@ -15,12 +15,37 @@ 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 func live(readPriority: PaykitSdkOperationLock.Priority = .ordered) -> PrivateMessageDrainOperations { + PrivateMessageDrainOperations( + ensureLink: { _ = try await PaykitSdkService.shared.ensureLinkWithPeer($0, priority: readPriority) }, + pendingOutbound: { try await PaykitSdkService.shared.pendingOutboundPrivateCounterparties(priority: readPriority) }, + linkedPeers: { try await PaykitSdkService.shared.linkedPeers(priority: readPriority) }, + processPending: { _ = try await PaykitSdkService.shared.processOutboundPrivateMessages(counterparty: $0, priority: readPriority) }, + 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 +55,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 +112,219 @@ 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 + ) { + guard !isDeletingProfile else { return } + 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 + activeLinkPreparationKeys.removeAll() + pendingMessageDrainRetryTask?.cancel() + pendingMessageDrainRetryTask = nil + pendingMessageDrainRetryKeys.removeAll() + pendingMessageDrainRetryGeneration += 1 + } + + func beginProfileDeletion() { + isDeletingProfile = true + invalidateContactPreparation() + } + + func endProfileDeletion() { + isDeletingProfile = false + } + @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, + readPriority: requireImmediatePublication ? .ordered : .background + ) + 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 { - failedPublicKeys.formUnion(pendingRetryKeys.map(\.publicKey)) + await operations.drainMessages(Array(pendingRetryKeys)) + pendingRetryKeys = await operations.pendingDrainKeys(clearedRetryKeys) + } + if !pendingRetryKeys.isEmpty { + Logger.warn( + "Private Paykit endpoint withdrawal remains pending for \(pendingRetryKeys.map(PubkyPublicKeyFormat.redacted))", + context: "PrivatePaykit" + ) + failedPublicKeys.formUnion(pendingRetryKeys) firstError = firstError ?? PrivatePaykitError.privateUnavailable } } @@ -243,11 +352,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,21 +381,23 @@ extension PrivatePaykitService { let normalizedKeys = normalizedSavedContactKeys(publicKeys) for publicKey in normalizedKeys { knownSavedContactKeys.remove(publicKey) + unavailableLinkRetryAt[publicKey] = nil + } + if isDeletingProfile { + await clearContactStates(publicKeys: normalizedKeys) + return } Self.markDeletedContactCleanupPending(normalizedKeys) do { try await removePublishedEndpoints(for: normalizedKeys) - for publicKey in normalizedKeys { - await clearContactState(publicKey: publicKey) - } + await clearContactStates(publicKeys: normalizedKeys) } catch { Logger.warn("Failed to remove private Paykit endpoints for deleted contacts: \(error)", context: "PrivatePaykit") } } 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 } @@ -287,17 +406,21 @@ extension PrivatePaykitService { let cleanupKeys = staleKeys.union(Self.pendingDeletedContactCleanupKeys().subtracting(savedKeys)) guard !cleanupKeys.isEmpty else { return } + if isDeletingProfile { + await clearContactStates(publicKeys: Array(staleKeys)) + return + } + do { try await removePublishedEndpoints(for: Array(cleanupKeys)) - for publicKey in staleKeys { - await clearContactState(publicKey: publicKey) - } + await clearContactStates(publicKeys: Array(staleKeys)) } catch { Logger.warn("Failed to prune private Paykit endpoints for unsaved contacts: \(error)", context: "PrivatePaykit") } } func retryPendingEndpointReconciliation(wallet: WalletViewModel, savedPublicKeys publicKeys: [String]) async { + guard !isDeletingProfile else { return } let savedKeys = Set(normalizedSavedContactKeys(publicKeys)) let isFullCleanupPending = UserDefaults.standard.bool(forKey: Self.cleanupPendingKey) if isFullCleanupPending, @@ -309,17 +432,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,18 +451,11 @@ 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)) - for publicKey in cleanupKeys where !savedKeys.contains(publicKey) { - await clearContactState(publicKey: publicKey) - } + try await removePublishedEndpoints(for: isFullCleanupPending ? nil : Array(cleanupKeys)) + await clearContactStates(publicKeys: Array(cleanupKeys.subtracting(savedKeys))) if isFullCleanupPending { Self.setContactSharingCleanupPending(false) } @@ -353,6 +469,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, @@ -363,7 +489,8 @@ extension PrivatePaykitService { ) async -> Error? { let operations = endpointPublicationOperations( wallet: wallet, - forceRefreshLightning: forceRefreshLightning + forceRefreshLightning: forceRefreshLightning, + readPriority: requireImmediatePublication ? .ordered : .background ) return await syncLocalEndpointPublication( for: publicKeys, @@ -374,60 +501,22 @@ 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 + forceRefreshLightning: Bool, + readPriority: PaykitSdkOperationLock.Priority ) -> 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,155 +526,133 @@ extension PrivatePaykitService { updates, clearUnlistedLinkedPeers: false ) - } + }, + linkedPeers: { try await PaykitSdkService.shared.linkedPeers(priority: readPriority) }, + 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 withLinkPreparation(publicKey, operation: { 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 + if !linkRetryKeys.isEmpty { + let sessionIsCurrent = await isSessionCurrent?() ?? true + let currentIdentity = await operations.currentPublicKey() + if !Task.isCancelled, sessionIsCurrent, generation == preparationGeneration, currentIdentity == identity, + !UserDefaults.standard.bool(forKey: Self.cleanupPendingKey) + { + schedulePendingPrivateMessageDrainRetries( + reason: reason, retryKeys: linkRetryKeys.filter { knownSavedContactKeys.contains($0) } + ) + } + } } return requireImmediatePublication ? firstError : nil } private func prepareRelevantPrivateLinksIfAvailable(_ publicKeys: [String], reason: String) async { - guard await canUsePrivateLinks() else { return } - - var retryKeys = [PrivateMessageDrainRetryKey]() - for publicKey in normalizedSavedContactKeys(publicKeys) { - do { - let receiverPaths = try await receiverPathsForSavedContact(publicKey: publicKey) - let selection = try await PaykitSdkService.shared.privateReceiverPathSelection( - publicKey: publicKey, - savedReceiverPaths: receiverPaths - ) - if let error = selection.error { - Logger.warn( - "Failed to inspect private Paykit receiver markers for \(PubkyPublicKeyFormat.redacted(publicKey)) during \(reason): \(error)", - context: "PrivatePaykit" - ) - } - retryKeys.append(contentsOf: selection.linkableReceiverPaths.map { - PrivateMessageDrainRetryKey(publicKey: publicKey, receiverPath: $0) - }) - } catch is CancellationError { - return - } catch { - Logger.warn( - "Failed to prepare private Paykit links for \(PubkyPublicKeyFormat.redacted(publicKey)) during \(reason): \(error)", - context: "PrivatePaykit" - ) - } - } + guard !UserDefaults.standard.bool(forKey: Self.cleanupPendingKey), await canUsePrivateLinks() else { return } - await drainAndSchedulePrivateLinkRetries(reason: reason, retryKeys: retryKeys) + await drainAndSchedulePrivateLinkRetries(reason: reason, retryKeys: normalizedSavedContactKeys(publicKeys)) } private func canUsePrivateLinks() async -> Bool { @@ -596,55 +663,99 @@ 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 PaykitSdkService.shared.ensureLinkWithPeer(retryKey.publicKey, receiverPath: retryKey.receiverPath) + _ = try await withLinkPreparation(retryKey) { try await operations.ensureLink(retryKey) } } catch { Logger.warn( - "Failed to advance private Paykit link for \(PubkyPublicKeyFormat.redacted(retryKey.publicKey)) during \(reason): \(error)", + "Failed to advance private Paykit link for \(PubkyPublicKeyFormat.redacted(retryKey)) during \(reason): \(error)", + context: "PrivatePaykit" + ) + } + } + 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 operations.receive(publicKey) + } catch { + Logger.warn( + "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 withLinkPreparation(_ publicKey: String, operation: () async throws -> T) async throws -> T? { + try Task.checkCancellation() + guard activeLinkPreparationKeys.insert(publicKey).inserted else { return nil } + let generation = preparationGeneration + defer { + if generation == preparationGeneration { activeLinkPreparationKeys.remove(publicKey) } + } + return try await operation() + } + + 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 +767,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, priority: .background) + if !drainKeys.isEmpty { + await drainPendingPrivateMessages(reason: reason, advancing: Array(drainKeys), operations: .live(readPriority: .background)) + } await updatePendingMessageDrainRetryKeys(retryKeys) } @@ -691,22 +806,26 @@ extension PrivatePaykitService { pendingMessageDrainRetryKeys.removeAll() } - private func updatePendingMessageDrainRetryKeys(_ retryKeys: [PrivateMessageDrainRetryKey]) async { - let remainingKeys = await pendingPrivateMessageDrainKeys(retryKeys) + private func updatePendingMessageDrainRetryKeys(_ retryKeys: [String]) async { + let remainingKeys = await pendingPrivateMessageDrainKeys(retryKeys, priority: .background) pendingMessageDrainRetryKeys.subtract(retryKeys) pendingMessageDrainRetryKeys.formUnion(remainingKeys) } - private func pendingPrivateMessageDrainKeys(_ retryKeys: [PrivateMessageDrainRetryKey]) async -> Set { + private func pendingPrivateMessageDrainKeys( + _ retryKeys: [String], + retryMissingPeers: Bool = false, + priority: PaykitSdkOperationLock.Priority = .ordered + ) async -> Set { let retryKeys = Set(retryKeys) guard !retryKeys.isEmpty else { return [] } - let linkedPeers: [PrivateMessageDrainRetryKey: LinkedPeerState] + let linkedPeers: [String: LinkedPeerState] do { - var peersByKey: [PrivateMessageDrainRetryKey: LinkedPeerState] = [:] - for peer in try await PaykitSdkService.shared.linkedPeers() { + var peersByKey: [String: LinkedPeerState] = [:] + for peer in try await PaykitSdkService.shared.linkedPeers(priority: priority) { 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 +833,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(priority: priority) + 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 +871,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 +926,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 +946,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 +964,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 +980,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..1a2f32f90 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() @@ -22,17 +20,25 @@ extension PrivatePaykitService { } func clearContactState(publicKey: String) async { - guard let normalizedKey = PubkyPublicKeyFormat.normalized(publicKey) else { return } - privatePaymentListConsumptions = privatePaymentListConsumptions.filter { $0.key.publicKey != normalizedKey } - let consumedVersions = state.contacts[normalizedKey]?.consumedPrivatePaymentListVersionsByReceiverPath ?? [:] - if consumedVersions.isEmpty { - state.contacts[normalizedKey] = nil - } else { - var contactState = ContactState() - contactState.consumedPrivatePaymentListVersionsByReceiverPath = consumedVersions - state.contacts[normalizedKey] = contactState + await clearContactStates(publicKeys: [publicKey]) + } + + func clearContactStates(publicKeys: [String]) async { + let keys = Set(publicKeys.compactMap(PubkyPublicKeyFormat.normalized)) + guard !keys.isEmpty else { return } + privatePaymentListConsumptions = privatePaymentListConsumptions.filter { !keys.contains($0.key.publicKey) } + for key in keys { + unavailableLinkRetryAt[key] = nil + let consumedVersion = state.contacts[key]?.consumedPrivatePaymentListVersion + if consumedVersion == nil { + state.contacts[key] = nil + } else { + var contactState = ContactState() + contactState.consumedPrivatePaymentListVersion = consumedVersion + state.contacts[key] = contactState + } } - await PrivatePaykitAddressReservationStore.shared.clearContactAssignment(publicKey: normalizedKey) + await PrivatePaykitAddressReservationStore.shared.clearContactAssignments(publicKeys: Array(keys)) persistState(markWalletBackup: true) } diff --git a/Bitkit/Services/PrivatePaykitService.swift b/Bitkit/Services/PrivatePaykitService.swift index fad1c0329..1fff469f9 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,29 @@ 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 activeLinkPreparationKeys: Set = [] + var preparationTask: Task? + var pendingPreparationOperation: (([String], Bool) async -> Void)? + var preparationGeneration = 0 + var isDeletingProfile = false + var pendingForceRefreshLightning = false + var unavailableLinkRetryAt: [String: Date] = [:] var pendingMessageDrainRetryTask: Task? - 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..e5575b9ab 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,27 +296,34 @@ 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 ) } + static func saveContacts(updates: [Paykit.ContactUpdate], expectedIdentity: String? = nil) async throws -> [Paykit.ContactRecord] { + try await PaykitSdkService.shared.saveContacts(updates: updates, expectedIdentity: expectedIdentity) + } + static func removeContact(publicKey: String) async throws -> Paykit.ContactRecord? { try await PaykitSdkService.shared.removeContact(publicKey: publicKey) } + static func removeContacts(publicKeys: [String]) async throws -> [Paykit.ContactRecord] { + try await PaykitSdkService.shared.removeContacts(publicKeys: publicKeys) + } + static func resolveContactProfile( 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 +331,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 +347,15 @@ enum PubkyService { actor PaykitSdkService { typealias BootstrapFactory = (String, PubkyClientConfig) throws -> PubkySessionBootstrap + private enum OperationDeferral: Error { + case paymentActive + } + + struct BackupStateSnapshot { + let stateRevision: String + let backupRevision: String + } + static let shared = PaykitSdkService() private static let walletBackupDataChangedSubject = PassthroughSubject() @@ -355,7 +363,6 @@ actor PaykitSdkService { walletBackupDataChangedSubject.eraseToAnyPublisher() } - private let stateStore = PaykitSdkStateBlobStore() private let sessionProvider = PaykitSdkSessionProvider() private let paymentAdapter = PaykitSdkPaymentAdapter() private let operationLock = PaykitSdkOperationLock() @@ -372,6 +379,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 +398,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 +412,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 +533,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 +547,12 @@ actor PaykitSdkService { } func identityStatus() async throws -> IdentityStatus? { - try await operationLock.withLock { - try await handle().identityStatus() + try await identityStatus(priority: .ordered) + } + + func identityStatus(priority: PaykitSdkOperationLock.Priority) async throws -> IdentityStatus? { + try await withSdk(priority: priority) { sdk in + try await sdk.identityStatus() } } @@ -543,13 +563,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 +577,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 +599,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 +609,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 +622,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 +674,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 +686,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 +711,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 +738,6 @@ actor PaykitSdkService { func saveContact( publicKey: String, label: String?, - receiverPaths: [String]? = nil, restorePrivateConnection: Bool = false, expectedIdentity: String? = nil ) async throws -> Paykit.ContactRecord { @@ -729,40 +745,29 @@ actor PaykitSdkService { if let expectedIdentity { try await Self.requireSignedInIdentity(expectedIdentity, in: sdk) } - let existing = try await sdk.contactRecord(publicKey: publicKey) - guard restorePrivateConnection || existing != nil else { throw PubkyServiceError.profileNotFound } - let existingPaths = existing?.receiverPaths ?? [] - let contactPaths = Self.mergedReceiverPaths(existingPaths + (receiverPaths ?? [])) + let update = Paykit.ContactUpdate(publicKey: publicKey, label: label) if restorePrivateConnection { - let blockedPeers = try await sdk.linkedPeers().filter { - $0.state == .blocked && PubkyPublicKeyFormat.matches($0.counterparty, publicKey) - } - do { - for peer in blockedPeers { - _ = try await sdk.unblockPeer(counterparty: peer.counterparty, counterpartyReceiverPath: peer.counterpartyReceiverPath) - } - return try await sdk.saveContact(update: Paykit.ContactUpdate(publicKey: publicKey, receiverPaths: contactPaths, label: label)) - } catch { - let restorationError = error - for peer in blockedPeers { - do { - _ = try await sdk.blockPeer(counterparty: peer.counterparty, counterpartyReceiverPath: peer.counterpartyReceiverPath) - } catch { - Logger.error("Failed to restore peer block after contact save failed: \(error)", context: "PaykitSdkService") - } - } - throw restorationError - } + let saved = try await sdk.saveContactsAndUnblockPeers(updates: [update]) + return saved[0] } - return try await sdk.saveContact(update: Paykit.ContactUpdate(publicKey: publicKey, receiverPaths: contactPaths, label: label)) + guard try await sdk.contactRecord(publicKey: publicKey) != nil else { throw PubkyServiceError.profileNotFound } + return try await sdk.saveContact(update: update) + } + } + + func saveContacts(updates: [Paykit.ContactUpdate], expectedIdentity: String? = nil) async throws -> [Paykit.ContactRecord] { + guard !updates.isEmpty else { return [] } + return try await withStateRevisionTracking { sdk in + if let expectedIdentity { + try await Self.requireSignedInIdentity(expectedIdentity, in: sdk) + } + return try await sdk.saveContactsAndUnblockPeers(updates: updates) } } 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 +781,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,140 +792,63 @@ 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) } } + func removeContacts(publicKeys: [String]) async throws -> [Paykit.ContactRecord] { + guard !publicKeys.isEmpty else { return [] } + return try await withStateRevisionTracking { sdk in + let now = Date() + let subscribedKeys = try await Set(sdk.paymentRequests().filter { + $0.state == .activeRecurring && + ($0.terms?.recurrence?.endsAt.flatMap(PaykitPaymentRequest.parseDate).map { $0 > now } ?? true) + }.compactMap { PubkyPublicKeyFormat.normalized($0.counterparty) }) + let removableKeys = publicKeys.filter { + PubkyPublicKeyFormat.normalized($0).map { !subscribedKeys.contains($0) } == true + } + guard !removableKeys.isEmpty else { return [] } + return try await sdk.removeContactsAndBlockPeers(publicKeys: removableKeys) + } + } + func resolveContactProfile( publicKey: String, allowPubkyProfileFallback: Bool, 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) } + try await sdk.paykitAppRegistry(publicKey: publicKey)?.apps.contains { + $0.capabilities.paymentRequests && $0.capabilities.outgoingPayments + } == true } } - /// 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 - ) - } - } - } - - func syncLocalReceiverMarker(isDiscoverable: Bool) async throws { - try await withStateRevisionTracking { sdk in - guard isDiscoverable else { - try await sdk.removePaykitReceiverMarker() - return - } - - _ = try await sdk.publishPaykitReceiverMarker(capabilities: receiverCapabilities(using: sdk)) + func syncPaykitApp(privatePaymentsEnabled: Bool, priority: PaykitSdkOperationLock.Priority = .ordered) async throws { + try await withStateRevisionTracking(priority: priority) { sdk in + var capabilities = try await appCapabilities(for: sdk.identityStatus()) + capabilities.privatePayments = capabilities.privatePayments && privatePaymentsEnabled + _ = try await sdk.publishPaykitApp(displayName: "Bitkit", capabilities: capabilities) } } 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 +857,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,58 +868,142 @@ actor PaykitSdkService { func ensureLinkWithPeer( _ counterparty: String, - receiverPath: String, - maxAdvanceSteps: UInt32 = 8 + maxAdvanceSteps: UInt32 = 1, + priority: PaykitSdkOperationLock.Priority = .ordered ) async throws -> LinkedPeerHandshakeReport { - try await withStateRevisionTracking { sdk in - try await sdk.ensureLinkWithPeer(counterparty: counterparty, counterpartyReceiverPath: receiverPath, maxAdvanceSteps: maxAdvanceSteps) + try await withStateRevisionTracking(priority: priority) { sdk in + 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 + ) } } @discardableResult func receivePrivateMessagesFromLinkedPeers() async throws -> [PrivateStreamCounterpartyIntakeReport] { - try await withStateRevisionTracking { sdk in + try await receivePrivateMessagesFromLinkedPeers(priority: .ordered) + } + + func receivePrivateMessagesFromLinkedPeers(priority: PaykitSdkOperationLock.Priority) async throws -> [PrivateStreamCounterpartyIntakeReport] { + try await withStateRevisionTracking(priority: priority) { sdk in try await sdk.receivePrivateMessagesFromLinkedPeers() } } @discardableResult - func processPendingPrivateMessages() async throws -> [OutboundPrivateCounterpartySendReport] { + 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 processOutboundPrivateMessages(counterparty: counterparty, priority: .ordered) + } + + func processOutboundPrivateMessages( + counterparty: String, + priority: PaykitSdkOperationLock.Priority + ) async throws -> OutboundPrivateSendReport { + try await withStateRevisionTracking(priority: priority, deferDuringPayment: priority == .background) { sdk in + try await sdk.processOutboundPrivateMessages(counterparty: counterparty) + } + } + + @discardableResult + func processPendingPrivateMessages() async throws -> [OutboundPrivateCounterpartySendReport] { + try await processPendingPrivateMessages(priority: .ordered) + } + + func processPendingPrivateMessages(priority: PaykitSdkOperationLock.Priority) async throws -> [OutboundPrivateCounterpartySendReport] { + try await withStateRevisionTracking(priority: priority) { sdk in try await sdk.processPendingPrivateMessages() } } 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 sharedPaymentRequests(priority: .ordered) + } + + func sharedPaymentRequests(priority: PaykitSdkOperationLock.Priority) async throws -> [Paykit.PaymentRequestRecord] { + try await withSdk(priority: priority) { 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 +1012,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 +1031,30 @@ 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 withStateRevisionTracking(priority: .interactive) { sdk in + try await sdk.claimAndAcceptPaymentRequest( counterparty: counterparty, - counterpartyReceiverPath: counterpartyReceiverPath, paymentRequestId: paymentRequestId ) } } + func claimPaymentRequestForExecution(counterparty: String, paymentRequestId: String) async throws -> Paykit.PaymentRequestRecord { + try await withStateRevisionTracking(priority: .interactive) { 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 +1063,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 +1076,74 @@ actor PaykitSdkService { } func linkedPeers() async throws -> [LinkedPeerRecord] { - try await operationLock.withLock { - try await handle().linkedPeers() + try await linkedPeers(priority: .ordered) + } + + func linkedPeers(priority: PaykitSdkOperationLock.Priority) async throws -> [LinkedPeerRecord] { + try await withSdk(priority: priority) { sdk in + try await sdk.linkedPeers() } } - func pendingOutboundPrivateCounterparties() async throws -> [CounterpartyReceiver] { - try await operationLock.withLock { - try await handle().pendingOutboundPrivateCounterparties() + func pendingOutboundPrivateCounterparties(priority: PaykitSdkOperationLock.Priority = .ordered) async throws -> [String] { + try await withSdk(priority: priority) { 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 { + let generation = try operationLock.walletGeneration() + let instance = try handle() + while true { + try await PaykitPaymentActivity.shared.waitUntilIdle() + let exported: String? = try await withSdk(priority: .background) { sdk in + guard sdk === instance, try operationLock.walletGeneration() == generation else { + throw PubkyServiceError.identityChanged + } + // Payment may have started while this export waited for the SDK lock. + guard await !PaykitPaymentActivity.shared.isActive else { return nil } + return try await sdk.exportBackupString() + } + if let exported { return exported } } - resetRuntime() } func signOut() async throws { @@ -1124,9 +1154,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 +1175,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 +1200,7 @@ actor PaykitSdkService { } nonisolated static func requiredCapabilities() throws -> String { - try Paykit.requiredSessionCapabilities(config: config()) + Paykit.paykitAuthorizerSessionCapabilities() } private func handle() throws -> PaykitSdk { @@ -1182,8 +1208,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 +1218,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,12 +1232,75 @@ 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, + deferDuringPayment: Bool = false, + _ operation: (PaykitSdk) async throws -> T + ) async throws -> T { + let generation = deferDuringPayment ? try operationLock.walletGeneration() : nil + let instance = deferDuringPayment ? try handle() : nil + while true { + if deferDuringPayment { try await PaykitPaymentActivity.shared.waitUntilIdle() } + let result: T? = try await operationLock.withLock(priority: priority) { + if let instance { + guard sdk === instance, try operationLock.walletGeneration() == generation else { + throw PubkyServiceError.identityChanged + } + // Release the SDK lock before waiting if payment started while this operation was queued. + guard await !PaykitPaymentActivity.shared.isActive else { return nil } + } + do { + return try await withSdkErrorHandling { + try await refreshPaykitKey() + return try await .some(operation(handle())) + } + } catch OperationDeferral.paymentActive { + return nil + } + } + if let result { return result } + } + } + + 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, + deferDuringPayment: Bool = false, + _ operation: (PaykitSdk) async throws -> T + ) async throws -> T { + try await withSdk(priority: priority, deferDuringPayment: deferDuringPayment) { 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() }, + readObservedSnapshot: { + try sdk.observedBackupStateRevision().map { + BackupStateSnapshot(stateRevision: $0.stateRevision, backupRevision: $0.backupRevision) + } + }, + cachedSnapshot: self.cachedBackupState, + onSnapshot: { self.cachedBackupState = $0 }, onChange: { self.markWalletBackupDataChanged() }, + beforeOperation: { + if deferDuringPayment { + guard await !PaykitPaymentActivity.shared.isActive else { throw OperationDeferral.paymentActive } + try Task.checkCancellation() + } + }, operation: { try await operation(sdk) } ) } @@ -1227,21 +1308,54 @@ actor PaykitSdkService { static func withBackupStateRevisionTracking( readRevision: () async throws -> String, + readStateRevision: () throws -> String? = { nil }, + readObservedSnapshot: () throws -> BackupStateSnapshot? = { nil }, + cachedSnapshot: BackupStateSnapshot? = nil, + onSnapshot: (BackupStateSnapshot?) -> Void = { _ in }, onChange: () async -> Void, + beforeOperation: () async throws -> Void = {}, operation: () async throws -> T ) async throws -> T { - let previousRevision = try? await readRevision() - let result: Result + // An intervening SDK read must not replace the last backup comparison baseline. + let previousRevision: String? = if let cachedSnapshot { + cachedSnapshot.backupRevision + } else { + try? await readRevision() + } + // Admission can be deferred after the revision read without marking an unattempted write as uncertain. + try await beforeOperation() + 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 nextRevision: String? + let nextSnapshot: BackupStateSnapshot? + if Task.isCancelled { + nextRevision = nil + nextSnapshot = nil + } else if let observed = try? readObservedSnapshot(), + observed.stateRevision == (try? readStateRevision()) + { + nextRevision = observed.backupRevision + nextSnapshot = observed + } else { + nextRevision = try? await readRevision() + if let stateRevision = try? readStateRevision(), let nextRevision { + nextSnapshot = BackupStateSnapshot(stateRevision: stateRevision, backupRevision: nextRevision) + } else { + nextSnapshot = nil + } } - let nextRevision = try? await readRevision() - if previousRevision == nil || nextRevision == nil || previousRevision != nextRevision { + onSnapshot(Task.isCancelled ? nil : nextSnapshot) + if Task.isCancelled || previousRevision == nil || nextRevision == nil || previousRevision != nextRevision { await onChange() } - return try result.get() + return result } private func markWalletBackupDataChanged() { @@ -1250,6 +1364,54 @@ actor PaykitSdkService { private func resetRuntime() { sdk = nil + cachedPaykitKey = nil + cachedBackupState = nil + } + + private func refreshPaykitKey(force: Bool = false) async throws { + if force { + cachedPaykitKey = nil + cachedBackupState = nil + } + guard let root = try sessionProvider.loadLocalSecretKey() else { + if cachedPaykitKey != nil { cachedBackupState = nil } + cachedPaykitKey = nil + return + } + 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 + cachedBackupState = 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 +1419,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 +1435,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 +1466,48 @@ 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 + if sdk == nil { + sdk = try sdkFactory?() + } + return try await sdk?.identityStatus()?.publicKey } /// Throws `identityChanged` unless `expectedIdentity` is the identity `sdk` is signed in as. Run it inside the locked - /// 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 +1515,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 +1525,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 +1552,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 +1566,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 +1617,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 +1688,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 +1711,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 +1728,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 +1741,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 +1885,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 +1904,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 +1934,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 +1982,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 +2008,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..ab7b4269c 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( @@ -318,57 +318,73 @@ enum PublicPaykitService { static func syncPublishedEndpoints( wallet: WalletViewModel, publish: Bool, - isSessionCurrent: (@MainActor () -> Bool)? = nil + isSessionCurrent: (@MainActor () -> Bool)? = nil, + appSyncPriority: PaykitSdkOperationLock.Priority = .ordered ) 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(priority: appSyncPriority) }, + 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 + static func syncPaykitApp( + privateSharingEnabled: Bool? = nil, + priority: PaykitSdkOperationLock.Priority = .ordered ) async throws { - let publicSharing = publicSharingEnabled ?? UserDefaults.standard.bool(forKey: publishingEnabledKey) let privateSharing = privateSharingEnabled ?? UserDefaults.standard.bool(forKey: PrivatePaykitService.publishingEnabledKey) - try await PaykitSdkService.shared.syncLocalReceiverMarker( - isDiscoverable: publicSharing || privateSharing - ) + try await PaykitSdkService.shared.syncPaykitApp(privatePaymentsEnabled: privateSharing, priority: priority) } 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/Utilities/PaykitPaymentActivity.swift b/Bitkit/Utilities/PaykitPaymentActivity.swift new file mode 100644 index 000000000..acda03809 --- /dev/null +++ b/Bitkit/Utilities/PaykitPaymentActivity.swift @@ -0,0 +1,66 @@ +import Foundation + +@MainActor +final class PaykitPaymentActivity { + enum DeferredWork: Hashable { + case proofRefresh + case acceptance(identity: String, counterparty: String) + } + + static let shared = PaykitPaymentActivity() + + private var payments: Set = [] + private var idleWaiters: [UUID: AsyncStream.Continuation] = [:] + private var deferredOperations: [DeferredWork: @MainActor () async -> Void] = [:] + private var deferredTasks: [DeferredWork: Task] = [:] + + var isActive: Bool { + !payments.isEmpty + } + + func begin() -> UUID { + let id = UUID() + payments.insert(id) + return id + } + + func end(_ id: UUID) { + payments.remove(id) + guard payments.isEmpty else { return } + let waiters = idleWaiters.values + idleWaiters.removeAll() + waiters.forEach { $0.finish() } + } + + func waitUntilIdle() async throws { + while isActive { + try Task.checkCancellation() + let id = UUID() + let (stream, continuation) = AsyncStream.makeStream() + idleWaiters[id] = continuation + for await _ in stream {} + idleWaiters.removeValue(forKey: id) + } + try Task.checkCancellation() + } + + @discardableResult + func runWhenIdle(_ key: DeferredWork, operation: @escaping @MainActor () async -> Void) -> Task { + deferredOperations[key] = operation + if let task = deferredTasks[key] { return task } + let task = Task { + do { + try await waitUntilIdle() + } catch { + deferredOperations.removeValue(forKey: key) + deferredTasks.removeValue(forKey: key) + return + } + let operation = deferredOperations.removeValue(forKey: key) + deferredTasks.removeValue(forKey: key) + await operation?() + } + deferredTasks[key] = task + return task + } +} diff --git a/Bitkit/Utilities/PaymentNavigationHelper.swift b/Bitkit/Utilities/PaymentNavigationHelper.swift index ea6a861b0..3bf47921e 100644 --- a/Bitkit/Utilities/PaymentNavigationHelper.swift +++ b/Bitkit/Utilities/PaymentNavigationHelper.swift @@ -1,5 +1,6 @@ import BitkitCore import Foundation +import Paykit @MainActor struct PaymentNavigationHelper { @@ -285,7 +286,15 @@ struct PaymentNavigationHelper { "Failed to pay contact \(PubkyPublicKeyFormat.redacted(publicKey)): \(error)", context: "PaymentNavigationHelper" ) - app.toast(type: .error, title: t("slashtags__error_pay_title"), description: error.localizedDescription) + if let description = contactPaymentErrorDescription(error) { + app.toast(type: .error, title: t("slashtags__error_pay_title"), description: description) + } } } + + static func contactPaymentErrorDescription(_ error: Error) -> String? { + let underlyingError = (error as? AppError)?.underlyingError ?? error + if underlyingError is CancellationError { return nil } + return underlyingError is PaykitError ? t("other__try_again") : error.localizedDescription + } } diff --git a/Bitkit/Utilities/Testing/ContactImportUITestFixture.swift b/Bitkit/Utilities/Testing/ContactImportUITestFixture.swift index e21364973..3e1334282 100644 --- a/Bitkit/Utilities/Testing/ContactImportUITestFixture.swift +++ b/Bitkit/Utilities/Testing/ContactImportUITestFixture.swift @@ -1,4 +1,5 @@ #if DEBUG + import Paykit import SwiftUI struct ContactImportUITestFixture: View { @@ -70,17 +71,17 @@ override func importContacts( contacts selected: [PubkyContact], - saveContact: (String, String) async throws -> Void + saveContacts: ([ContactUpdate], String?) async throws -> Void ) async throws { importCount += 1 - try await super.importContacts(contacts: selected) { key, _ in + try await super.importContacts(contacts: selected) { updates, _ in if self.savedKeys.isEmpty { await withCheckedContinuation { continuation in self.pendingSave = continuation self.isSavePending = true } } - self.savedKeys.append(key) + self.savedKeys.append(contentsOf: updates.map(\.publicKey)) } } diff --git a/Bitkit/ViewModels/AppViewModel.swift b/Bitkit/ViewModels/AppViewModel.swift index 7c407aec0..0bd8cf8a6 100644 --- a/Bitkit/ViewModels/AppViewModel.swift +++ b/Bitkit/ViewModels/AppViewModel.swift @@ -155,6 +155,7 @@ class AppViewModel: ObservableObject { /// When a payment that was shown on the pending screen succeeds or fails, this is set so SendPendingScreen can navigate. /// Consumed by SendPendingScreen via consumeSendSheetPendingResolution. @Published var sendSheetPendingResolution: SendSheetPendingResolution? + @Published private(set) var paykitOnchainPaymentResolution: PaykitOnchainPaymentResolution? /// App status init - shows "ready" until node is actually running /// This prevents flashing error status during startup/background transitions @@ -516,6 +517,18 @@ extension AppViewModel { sendSheetPendingResolution = nil } + func retainPaykitOnchainPaymentResolution(_ resolution: PaykitOnchainPaymentResolution, identity: String?) { + guard PubkyPublicKeyFormat.matches(resolution.identity, identity), + contactPaymentContext?.incomingPaymentRequest?.id == resolution.requestId + else { return } + paykitOnchainPaymentResolution = resolution + } + + func consumePaykitOnchainPaymentResolution(_ resolution: PaykitOnchainPaymentResolution) { + guard paykitOnchainPaymentResolution == resolution else { return } + paykitOnchainPaymentResolution = nil + } + func beginQuickPay(paymentHash: String) { isQuickPayActive = true quickPayPaymentHash = paymentHash @@ -1049,6 +1062,7 @@ extension AppViewModel { lnurlWithdrawData = nil if !preservingContactPaymentContext { contactPaymentContext = nil + paykitOnchainPaymentResolution = nil } resetQuickPay() } diff --git a/Bitkit/ViewModels/HwFundingSigner.swift b/Bitkit/ViewModels/HwFundingSigner.swift index e26ae938b..ad65836ba 100644 --- a/Bitkit/ViewModels/HwFundingSigner.swift +++ b/Bitkit/ViewModels/HwFundingSigner.swift @@ -108,8 +108,8 @@ struct HwFundingSigner { } /// Broadcasts a signed funding transaction without requiring the hardware device. - func broadcastSignedFunding(_ signed: HwFundingSignedTx) async throws -> HwFundingBroadcastResult { - let txId = try await broadcastStep(serializedTx: signed.serializedTx) + func broadcastSignedFunding(_ signed: HwFundingSignedTx, paymentDeadline: PaykitPreciseInstant? = nil) async throws -> HwFundingBroadcastResult { + let txId = try await broadcastStep(serializedTx: signed.serializedTx, paymentDeadline: paymentDeadline) return HwFundingBroadcastResult( txId: txId, miningFeeSats: signed.miningFeeSats, @@ -255,10 +255,10 @@ struct HwFundingSigner { /// already been handed to the network must never be reported as a signing timeout, so a timeout /// here surfaces `.broadcastUncertain` (the funding tx may still confirm) without tearing down the /// device session. - private func broadcastStep(serializedTx: String) async throws -> String { + private func broadcastStep(serializedTx: String, paymentDeadline: PaykitPreciseInstant?) async throws -> String { do { return try await withTimeout(timeouts.broadcast) { - try await funding.broadcastFunding(serializedTx: serializedTx) + try await funding.broadcastFunding(serializedTx: serializedTx, paymentDeadline: paymentDeadline) } } catch is CancellationError { throw CancellationError() @@ -379,8 +379,8 @@ final class HwSendCoordinator { } /// Whether the sign screen may be left. Reaching the device (a Jade may wait minutes for its PIN) - /// can be abandoned, and leaving cancels it; once the device is asked to sign, or a broadcast may - /// have gone out, it cannot. + /// can be abandoned, and leaving cancels it; signing, an active broadcast and completion of a + /// verified payment must finish first. var canLeave: Bool { (!isSigning || isConnectingDevice) && !isBroadcastUnresolved } @@ -505,6 +505,9 @@ final class HwSendCoordinator { address: String, sats: UInt64, satsPerVByte: UInt64, + paymentDeadline: PaykitPreciseInstant? = nil, + paykitRequestId: PaykitPaymentRequest.ID? = nil, + paykitIdentity: String? = nil, beforeFirstBroadcast: @escaping () async throws -> Void = {}, beforeBroadcastAttempt: @escaping () async throws -> Void = {}, afterBroadcast: @escaping (HwFundingBroadcastResult) async -> Void = { _ in }, @@ -514,6 +517,15 @@ final class HwSendCoordinator { throw AppError(message: "Unknown hardware wallet", debugMessage: "The send flow has no wallet id") } let request = PaymentRequest(address: address, sats: sats, satsPerVByte: satsPerVByte) + let identity = paykitIdentity.flatMap(PubkyPublicKeyFormat.normalized) + guard pendingPayment?.resolvedTransactionId == nil else { throw PaykitPaymentRequestError.requestUnavailable } + if let pendingPayment, + pendingPayment.request != request || pendingPayment.walletId != walletId || + pendingPayment.paykitRequestId != paykitRequestId || pendingPayment.paykitIdentity != identity + { + guard !pendingPayment.hasBroadcastAttempted else { throw PaykitPaymentRequestError.requestUnavailable } + self.pendingPayment = nil + } if let operationTask { guard operationRequest == request else { throw HwTransferError.deviceBusy(manager.vendor(walletId: walletId)) } return try await operationTask.value @@ -522,9 +534,16 @@ final class HwSendCoordinator { signingAttempt += 1 let attempt = signingAttempt let signer = signerFactory(manager, address, satsPerVByte) + let wasBroadcastUnresolved = isBroadcastUnresolved isSigning = true let task = Task { @MainActor in + @MainActor + func checkAttempt() throws { + try Task.checkCancellation() + guard signingAttempt == attempt else { throw CancellationError() } + } + defer { if signingAttempt == attempt { isSigning = false @@ -550,21 +569,28 @@ final class HwSendCoordinator { isConnectingDevice = isConnecting } ) - try Task.checkCancellation() - pendingPayment = PendingPayment(request: request, signedTx: signed) + try checkAttempt() + pendingPayment = PendingPayment( + request: request, signedTx: signed, walletId: walletId, + paykitRequestId: paykitRequestId, paykitIdentity: identity + ) } if pendingPayment?.isPreparedForBroadcast != true { try await beforeFirstBroadcast() - try Task.checkCancellation() + try checkAttempt() pendingPayment?.isPreparedForBroadcast = true } - var broadcastWasAttempted = pendingPayment?.hasBroadcastAttempted == true + let hadPriorBroadcastAttempt = pendingPayment?.hasBroadcastAttempted == true + var broadcastWasAttempted = hadPriorBroadcastAttempt do { do { try await beforeBroadcastAttempt() + try checkAttempt() + try PaykitPaymentRequest.checkPaymentDeadline(paymentDeadline) } catch { + try checkAttempt() if !broadcastWasAttempted { pendingPayment = nil } @@ -575,19 +601,34 @@ final class HwSendCoordinator { broadcastWasAttempted = true pendingPayment?.hasBroadcastAttempted = true do { - let result = try await signer.broadcastSignedFunding(signed) + let result = try await signer.broadcastSignedFunding(signed, paymentDeadline: paymentDeadline) + try checkAttempt() + pendingPayment?.resolvedTransactionId = result.txId await afterBroadcast(result) + try checkAttempt() return result } catch { + try checkAttempt() isBroadcastUnresolved = false - let outcomeIsUncertain = (error as? HwTransferError) == .broadcastUncertain - if !outcomeIsUncertain, !error.isBroadcastConnectivityFailure() { + let underlyingError = (error as? AppError)?.underlyingError ?? error + if underlyingError as? PaykitPaymentRequestError == .requestExpired { + // A queued retry can expire without changing the uncertainty of an earlier attempt. + isBroadcastUnresolved = wasBroadcastUnresolved + broadcastWasAttempted = hadPriorBroadcastAttempt + pendingPayment?.hasBroadcastAttempted = hadPriorBroadcastAttempt + if !hadPriorBroadcastAttempt { pendingPayment = nil } + throw error + } + if error.isDefiniteHardwarePreBroadcastFailure(), !hadPriorBroadcastAttempt { + broadcastWasAttempted = false pendingPayment = nil } throw error } } catch { + try checkAttempt() await afterFailure(broadcastWasAttempted ? .uncertain : .definitePreBroadcastFailure) + try checkAttempt() throw error } } @@ -601,7 +642,40 @@ final class HwSendCoordinator { operationSession = nil } } - return try await task.value + let result = try await task.value + guard signingAttempt == attempt else { throw CancellationError() } + return result + } + + func resolvePayment( + _ resolution: PaykitOnchainPaymentResolution, + identity: String?, + walletId: String + ) -> HwFundingBroadcastResult? { + guard let pendingPayment, pendingPayment.hasBroadcastAttempted, + self.walletId == walletId, pendingPayment.walletId == walletId, resolution.walletId == walletId, + pendingPayment.paykitRequestId == resolution.requestId, + PubkyPublicKeyFormat.matches(pendingPayment.paykitIdentity, resolution.identity), + PubkyPublicKeyFormat.matches(identity, resolution.identity), + pendingPayment.resolvedTransactionId == nil || pendingPayment.resolvedTransactionId == resolution.transactionId + else { return nil } + + // A verified payment supersedes any retry, including callbacks from uncancellable transports. + operationTask?.cancel() + signingAttempt += 1 + operationTask = nil + operationRequest = nil + operationSession = nil + isSigning = false + isConnectingDevice = false + isBroadcastUnresolved = true + self.pendingPayment?.resolvedTransactionId = resolution.transactionId + return HwFundingBroadcastResult( + txId: resolution.transactionId, + miningFeeSats: pendingPayment.signedTx.miningFeeSats, + feeRate: UInt64(pendingPayment.signedTx.feeRate.rounded(.up)), + totalSpent: pendingPayment.signedTx.totalSpent + ) } func reconnectWithPassphrase( @@ -676,8 +750,12 @@ final class HwSendCoordinator { private struct PendingPayment { let request: PaymentRequest let signedTx: HwFundingSignedTx + let walletId: String + let paykitRequestId: PaykitPaymentRequest.ID? + let paykitIdentity: String? var isPreparedForBroadcast = false var hasBroadcastAttempted = false + var resolvedTransactionId: String? } private struct OperationSession { diff --git a/Bitkit/ViewModels/NavigationViewModel.swift b/Bitkit/ViewModels/NavigationViewModel.swift index 9496a122a..872e03c0b 100644 --- a/Bitkit/ViewModels/NavigationViewModel.swift +++ b/Bitkit/ViewModels/NavigationViewModel.swift @@ -204,6 +204,19 @@ class NavigationViewModel: ObservableObject { path.removeLast() } + func returnToContactsAfterRemoving(publicKey: String) { + let remainingPath = path.filter { route in + switch route { + case let .contactDetail(shownKey), let .editContact(shownKey), let .contactSaved(shownKey): + return !PubkyPublicKeyFormat.matches(shownKey, publicKey) + default: + return true + } + } + guard remainingPath != path else { return } + path = remainingPath.isEmpty ? [.contacts] : remainingPath + } + func reset() { path.removeAll() } 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/SheetViewModel.swift b/Bitkit/ViewModels/SheetViewModel.swift index 1081b4e70..710e186cc 100644 --- a/Bitkit/ViewModels/SheetViewModel.swift +++ b/Bitkit/ViewModels/SheetViewModel.swift @@ -81,6 +81,9 @@ class SheetViewModel: ObservableObject { func hideSheet(reason: String? = nil, file: String = #file, function: String = #function, line: Int = #line) { if let config = activeSheetConfiguration { + if !isReplacingSheet { + (config.data as? SendConfig)?.onDismiss?() + } let fallback = "\(URL(fileURLWithPath: file).lastPathComponent):\(line) \(function)" let reasonText = " reason: \(reason ?? fallback)" Logger.debug("Hiding sheet \(config.id.rawValue)\(reasonText)", context: "SheetViewModel") @@ -437,11 +440,15 @@ class SheetViewModel: ObservableObject { guard let config = activeSheetConfiguration, config.id == .send else { return nil } let sendConfig = config.data as? SendConfig let initialRoute = sendConfig?.initialRoute ?? .options - return SendSheetItem(initialRoute: initialRoute, hardwareWalletId: sendConfig?.hardwareWalletId) + return SendSheetItem( + initialRoute: initialRoute, + hardwareWalletId: sendConfig?.hardwareWalletId, + preparation: sendConfig?.preparation + ) } set { if newValue == nil { - activeSheetConfiguration = nil + hideSheet(reason: "Send sheet dismissed") } } } diff --git a/Bitkit/ViewModels/TransferViewModel.swift b/Bitkit/ViewModels/TransferViewModel.swift index 280cb38fc..4b898c2e0 100644 --- a/Bitkit/ViewModels/TransferViewModel.swift +++ b/Bitkit/ViewModels/TransferViewModel.swift @@ -103,7 +103,7 @@ protocol HwTransferFunding: Sendable { addressType: AddressScriptType ) async throws -> UInt64 func signFunding(walletId: String, funding: HwFundingTransaction) async throws -> HwFundingSignedTx - func broadcastFunding(serializedTx: String) async throws -> String + func broadcastFunding(serializedTx: String, paymentDeadline: PaykitPreciseInstant?) async throws -> String } /// The device-session capability the transfer flow needs for on-device signing, addressed by wallet diff --git a/Bitkit/ViewModels/WalletViewModel.swift b/Bitkit/ViewModels/WalletViewModel.swift index 3108a5a04..7aa4ceb9b 100644 --- a/Bitkit/ViewModels/WalletViewModel.swift +++ b/Bitkit/ViewModels/WalletViewModel.swift @@ -605,6 +605,7 @@ class WalletViewModel: ObservableObject { address: String, sats: UInt64, isMaxAmount: Bool = false, + paymentDeadline: PaykitPreciseInstant? = nil, beforeBroadcastAttempt: () async throws -> Void = {} ) async throws -> Txid { guard let selectedFeeRateSatsPerVByte else { @@ -623,7 +624,8 @@ class WalletViewModel: ObservableObject { sats: sats, satsPerVbyte: selectedFeeRateSatsPerVByte, utxosToSpend: selectedUtxos, - isMaxAmount: isMaxAmount + isMaxAmount: isMaxAmount, + beforeSubmission: { try PaykitPaymentRequest.checkPaymentDeadline(paymentDeadline) } ) Task { @@ -933,10 +935,14 @@ class WalletViewModel: ObservableObject { bolt11: String, sats: UInt64? = nil, timeoutSeconds: TimeInterval = 10, + paymentDeadline: PaykitPreciseInstant? = nil, afterListening: (@MainActor (String) -> Void)? = nil, onTimeout: (@MainActor (String) -> Void)? = nil ) async throws -> SettledLightningPayment { - let hash = try await lightningService.send(bolt11: bolt11, sats: sats) + let hash = try await lightningService.send( + bolt11: bolt11, sats: sats, + beforeSubmission: { try PaykitPaymentRequest.checkPaymentDeadline(paymentDeadline) } + ) let paymentHash = String(hash) afterListening?(paymentHash) return try await waitForLightningPayment( @@ -1365,7 +1371,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 +1435,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 +1489,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..012521c87 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): @@ -278,11 +278,9 @@ struct AddContactView: View { } } catch { Logger.error("Failed to pay public pubky \(PubkyPublicKeyFormat.redacted(publicKey)): \(error)", context: "AddContactView") - app.toast( - type: .error, - title: t("slashtags__error_pay_title"), - description: error.localizedDescription - ) + if let description = PaymentNavigationHelper.contactPaymentErrorDescription(error) { + app.toast(type: .error, title: t("slashtags__error_pay_title"), description: description) + } } } diff --git a/Bitkit/Views/Contacts/ContactDetailView.swift b/Bitkit/Views/Contacts/ContactDetailView.swift index b68b271e4..2f53f01ea 100644 --- a/Bitkit/Views/Contacts/ContactDetailView.swift +++ b/Bitkit/Views/Contacts/ContactDetailView.swift @@ -65,7 +65,7 @@ struct ContactDetailView: View { } else if hasResolvedContactFromContacts { hasResolvedContactFromContacts = false profile = nil - navigation.path = [.contacts] + navigation.returnToContactsAfterRemoving(publicKey: publicKey) } } .alert( @@ -266,7 +266,9 @@ struct ContactDetailView: View { title: t("contacts__delete_success"), accessibilityIdentifier: "ContactDeletedToast" ) - navigation.path = [.contacts] + if !contactsManager.contacts.contains(where: { PubkyPublicKeyFormat.matches($0.publicKey, publicKey) }) { + navigation.returnToContactsAfterRemoving(publicKey: publicKey) + } } catch let PubkyServiceError.activeSubscription(endsAt) { let description = endsAt.map { t("subscriptions__expires_date", variables: ["date": $0.formatted(date: .long, time: .omitted)]) diff --git a/Bitkit/Views/Contacts/EditContactView.swift b/Bitkit/Views/Contacts/EditContactView.swift index 4e30eb5df..34f4bc9a3 100644 --- a/Bitkit/Views/Contacts/EditContactView.swift +++ b/Bitkit/Views/Contacts/EditContactView.swift @@ -126,7 +126,9 @@ struct EditContactView: View { title: t("contacts__delete_success"), accessibilityIdentifier: "ContactDeletedToast" ) - navigation.path = [.contacts] + if !contactsManager.contacts.contains(where: { PubkyPublicKeyFormat.matches($0.publicKey, publicKey) }) { + navigation.returnToContactsAfterRemoving(publicKey: publicKey) + } } catch let PubkyServiceError.activeSubscription(endsAt) { let description = endsAt.map { t("subscriptions__expires_date", variables: ["date": $0.formatted(date: .long, time: .omitted)]) diff --git a/Bitkit/Views/Offline/OfflineSheetScreen.swift b/Bitkit/Views/Offline/OfflineSheetScreen.swift index 6531f52e6..093145d8f 100644 --- a/Bitkit/Views/Offline/OfflineSheetScreen.swift +++ b/Bitkit/Views/Offline/OfflineSheetScreen.swift @@ -26,9 +26,10 @@ private struct OfflineSheetOverlayModifier: ViewModifier { let title: String var forceShow = false + var isEnabled = true private var isShowing: Bool { - !network.isConnected || forceShow + isEnabled && (!network.isConnected || forceShow) } func body(content: Content) -> some View { @@ -54,7 +55,7 @@ extension View { /// Overlays a `OfflineSheetScreen` when the device is offline, or whenever `forceShow` is true /// (e.g. connection issues beyond device connectivity, like an unreachable Lightning peer). /// The underlying content remains mounted so navigation state and inputs are preserved. - func offlineSheetOverlay(title: String, forceShow: Bool = false) -> some View { - modifier(OfflineSheetOverlayModifier(title: title, forceShow: forceShow)) + func offlineSheetOverlay(title: String, forceShow: Bool = false, isEnabled: Bool = true) -> some View { + modifier(OfflineSheetOverlayModifier(title: title, forceShow: forceShow, isEnabled: isEnabled)) } } diff --git a/Bitkit/Views/PaymentRequests/CreatePaymentRequestView.swift b/Bitkit/Views/PaymentRequests/CreatePaymentRequestView.swift index 0338dc6c1..df40d3de3 100644 --- a/Bitkit/Views/PaymentRequests/CreatePaymentRequestView.swift +++ b/Bitkit/Views/PaymentRequests/CreatePaymentRequestView.swift @@ -541,7 +541,9 @@ struct PaymentRequestSentView: View { var body: some View { VStack(alignment: .leading, spacing: 0) { - SheetHeader(title: t("wallet__payment_request_sent_title")) + SheetHeader(title: t(request.deliveryStatus == .sent + ? "wallet__payment_request_sent_title" + : "subscriptions__proposal_queued_title")) Spacer(minLength: 8) diff --git a/Bitkit/Views/PaymentRequests/PaymentRequestsView.swift b/Bitkit/Views/PaymentRequests/PaymentRequestsView.swift index fe27739ba..110080ba2 100644 --- a/Bitkit/Views/PaymentRequests/PaymentRequestsView.swift +++ b/Bitkit/Views/PaymentRequests/PaymentRequestsView.swift @@ -528,6 +528,7 @@ struct PaymentRequestDetailView: View { counterparty(request) if isActionable(request) { tags + .disabled(paymentRequests.requestedPresentationId == request.id) } if let note = request.note, !note.isEmpty { @@ -670,7 +671,8 @@ struct PaymentRequestDetailView: View { CustomButton( title: t("wallet__payment_request_dismiss"), variant: .secondary, - icon: Image("x-mark").resizable().frame(width: 16, height: 16) + icon: Image("x-mark").resizable().frame(width: 16, height: 16), + isDisabled: paymentRequests.requestedPresentationId == request.id ) { do { try await paymentRequests.dismiss(request) @@ -682,12 +684,13 @@ struct PaymentRequestDetailView: View { CustomButton( title: t("common__pay"), - icon: Image("coins").resizable().frame(width: 16, height: 16) + icon: Image("coins").resizable().frame(width: 16, height: 16), + isLoading: paymentRequests.requestedPresentationId == request.id ) { guard paymentRequests.requestPresentation(request) else { return } tagManager.preserveSelectedTags(for: request.id) - navigation.navigateBack() } + .accessibilityIdentifier("PaymentRequestDetailsPay") } .padding(.bottom, 16) } diff --git a/Bitkit/Views/Profile/EditProfileView.swift b/Bitkit/Views/Profile/EditProfileView.swift index b38021c5d..650c678a1 100644 --- a/Bitkit/Views/Profile/EditProfileView.swift +++ b/Bitkit/Views/Profile/EditProfileView.swift @@ -148,8 +148,13 @@ struct EditProfileView: View { guard !isDeleting else { return } isDeleting = true - defer { isDeleting = false } + await PrivatePaykitService.shared.beginProfileDeletion() + await deleteProfileWithSessionRetry() + await PrivatePaykitService.shared.endProfileDeletion() + isDeleting = false + } + private func deleteProfileWithSessionRetry() async { do { try await performDeleteProfile() } catch { @@ -169,8 +174,9 @@ struct EditProfileView: View { } private func performDeleteProfile() async throws { + await PubkyProfileManager.removePrivatePaykitEndpointsBestEffort(context: "EditProfileView.deleteProfile") await contactsManager.deleteAllContactsBestEffort() - try await pubkyProfile.deleteProfile() + try await pubkyProfile.deleteProfile(cleanPrivatePaykitEndpoints: false) navigation.path = [app.hasSeenProfileIntro ? .pubkyChoice : .profileIntro] } diff --git a/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/HwSendSignView.swift b/Bitkit/Views/Wallets/Send/HwSendSignView.swift index 79fae2edf..7b5703696 100644 --- a/Bitkit/Views/Wallets/Send/HwSendSignView.swift +++ b/Bitkit/Views/Wallets/Send/HwSendSignView.swift @@ -3,6 +3,7 @@ import SwiftUI struct HwSendSignView: View { @EnvironmentObject private var app: AppViewModel + @EnvironmentObject private var pubkyProfile: PubkyProfileManager @EnvironmentObject private var tagManager: TagManager @EnvironmentObject private var wallet: WalletViewModel @Environment(HwWalletManager.self) private var hwWalletManager @@ -14,6 +15,8 @@ struct HwSendSignView: View { let completeContactPayment: (ContactPaymentContext?, String) async -> Void let cancelContactPayment: (ContactPaymentContext?, PrivatePaymentListSendOutcome) async -> Void @State private var signingTask: Task? + @State private var signingAttempt = 0 + @State private var isCompletingPayment = false @State private var passphraseTask: Task? var body: some View { @@ -54,8 +57,8 @@ struct HwSendSignView: View { CustomButton( title: hwSend.hasPendingBroadcast ? t("common__retry") : vendor.sendSignButtonTitle, - isDisabled: hwSend.isSigning, - isLoading: hwSend.isSigning + isDisabled: hwSend.isSigning || isCompletingPayment, + isLoading: hwSend.isSigning || isCompletingPayment ) { startSigning() } @@ -73,6 +76,9 @@ struct HwSendSignView: View { onCancel: dismissPassphrase ) } + .onChange(of: app.paykitOnchainPaymentResolution, initial: true) { _, resolution in + resolvePayment(resolution) + } .onDisappear { guard !hwSend.isBroadcastUnresolved else { return } signingTask?.cancel() @@ -101,9 +107,11 @@ struct HwSendSignView: View { } private func startSigning() { - guard signingTask == nil else { return } + guard signingTask == nil, !isCompletingPayment else { return } + signingAttempt += 1 + let attempt = signingAttempt signingTask = Task { @MainActor in - defer { signingTask = nil } + defer { if signingAttempt == attempt { signingTask = nil } } guard let invoice = app.scannedOnchainInvoice, let amount = wallet.sendAmountSats, let feeRate = wallet.selectedFeeRateSatsPerVByte, @@ -113,7 +121,8 @@ struct HwSendSignView: View { return } let contactPaymentContext = app.contactPaymentContext - let contactPublicKey = contactPaymentContext?.publicKey + let paymentActivity = PaykitPaymentActivity.shared.begin() + defer { PaykitPaymentActivity.shared.end(paymentActivity) } do { let result = try await hwSend.signAndBroadcast( @@ -121,6 +130,9 @@ struct HwSendSignView: View { address: invoice.address, sats: amount, satsPerVByte: UInt64(feeRate), + paymentDeadline: contactPaymentContext?.incomingPaymentRequest?.paymentDeadline, + paykitRequestId: contactPaymentContext?.incomingPaymentRequest?.id, + paykitIdentity: pubkyProfile.publicKey, beforeFirstBroadcast: { try await prepareContactPayment(contactPaymentContext) }, beforeBroadcastAttempt: { try await authorizeContactPayment(contactPaymentContext) }, afterBroadcast: { result in @@ -130,25 +142,77 @@ struct HwSendSignView: View { await cancelContactPayment(contactPaymentContext, outcome) } ) - await recordSentPayment( + guard signingAttempt == attempt, !Task.isCancelled else { return } + await finishPayment( result, walletId: walletId, address: invoice.address, amount: amount, - contactPublicKey: contactPublicKey + context: contactPaymentContext ) - hwSend.completeBroadcast() - navigationPath.append(.success(paymentId: result.txId, walletId: walletId)) } catch is CancellationError { return - } catch is HwPassphraseError { - hwSend.requestPassphrase() - } catch let error as HwTransferError { - app.toast(error) } catch { - showHardwareError(error) + guard signingAttempt == attempt, !Task.isCancelled else { return } + if error is HwPassphraseError { + hwSend.requestPassphrase() + } else if let error = error as? HwTransferError { + app.toast(error) + } else { + showHardwareError(error) + } + } + } + } + + private func resolvePayment(_ resolution: PaykitOnchainPaymentResolution?) { + guard !isCompletingPayment, let resolution, + let context = app.contactPaymentContext, + context.incomingPaymentRequest?.id == resolution.requestId, + let invoice = app.scannedOnchainInvoice, + let amount = wallet.sendAmountSats, + let walletId = hwSend.walletId, + let result = hwSend.resolvePayment(resolution, identity: pubkyProfile.publicKey, walletId: walletId) + else { return } + + signingTask?.cancel() + signingAttempt += 1 + let attempt = signingAttempt + isCompletingPayment = true + signingTask = Task { @MainActor in + defer { + if signingAttempt == attempt { + signingTask = nil + isCompletingPayment = false + } } + await completeContactPayment(context, result.txId) + guard !Task.isCancelled else { return } + await finishPayment(result, walletId: walletId, address: invoice.address, amount: amount, context: context) + } + } + + private func finishPayment( + _ result: HwFundingBroadcastResult, + walletId: String, + address: String, + amount: UInt64, + context: ContactPaymentContext? + ) async { + isCompletingPayment = true + defer { isCompletingPayment = false } + await recordSentPayment(result, walletId: walletId, address: address, amount: amount, contactPublicKey: context?.publicKey) + guard !Task.isCancelled else { return } + hwSend.completeBroadcast() + if let resolution = app.paykitOnchainPaymentResolution, + resolution.requestId == context?.incomingPaymentRequest?.id, + resolution.transactionId == result.txId, + resolution.walletId == walletId, + PubkyPublicKeyFormat.matches(resolution.identity, pubkyProfile.publicKey) + { + app.consumePaykitOnchainPaymentResolution(resolution) } + navigationPath.append(.success(paymentId: result.txId, walletId: walletId)) } private func reconnectWithPassphrase(_ passphrase: String) { diff --git a/Bitkit/Views/Wallets/Send/LnurlPayConfirm.swift b/Bitkit/Views/Wallets/Send/LnurlPayConfirm.swift index 114e4e073..5da9557c6 100644 --- a/Bitkit/Views/Wallets/Send/LnurlPayConfirm.swift +++ b/Bitkit/Views/Wallets/Send/LnurlPayConfirm.swift @@ -11,6 +11,7 @@ struct LnurlPayConfirm: View { @EnvironmentObject var settings: SettingsViewModel @Binding var navigationPath: [SendRoute] + @Binding var isSubmittingPayment: Bool let requestPinCheck: () async -> Bool let prepareIncomingPaymentRequest: () async throws -> Void let routingCacheResetAttempted: Bool @@ -40,13 +41,11 @@ struct LnurlPayConfirm: View { return try await send() } - var uri: String { - app.lnurlPayData!.uri - } - var body: some View { ZStack { - confirmationContent + if let lnurlPayData = app.lnurlPayData { + confirmationContent(lnurlPayData: lnurlPayData) + } if app.contactPaymentContext?.isInitialSubscriptionPayment == true { InitialSubscriptionPaymentProgress() } @@ -80,7 +79,7 @@ struct LnurlPayConfirm: View { } } - private var confirmationContent: some View { + private func confirmationContent(lnurlPayData: LnurlPayData) -> some View { VStack { SheetHeader( title: reviewTitle, @@ -90,7 +89,7 @@ struct LnurlPayConfirm: View { VStack(alignment: .leading) { MoneyStack( - sats: Int(wallet.sendAmountSats ?? app.lnurlPayData!.minSendableSat), + sats: Int(wallet.sendAmountSats ?? lnurlPayData.minSendableSat), showSymbol: true, testIdPrefix: "ReviewAmount" ) @@ -100,7 +99,7 @@ struct LnurlPayConfirm: View { VStack(alignment: .leading) { CaptionMText(t("wallet__send_invoice")) .padding(.bottom, 8) - BodySSBText(uri) + BodySSBText(lnurlPayData.uri) .lineLimit(1) .truncationMode(.middle) } @@ -127,7 +126,7 @@ struct LnurlPayConfirm: View { Divider() - if let commentAllowed = app.lnurlPayData?.commentAllowed, commentAllowed > 0 { + if let commentAllowed = lnurlPayData.commentAllowed, commentAllowed > 0 { VStack(alignment: .leading) { CaptionMText(t("wallet__lnurl_pay_confirm__comment")) .padding(.bottom, 8) @@ -178,7 +177,8 @@ struct LnurlPayConfirm: View { @MainActor private func startAutomaticPaymentIfNeeded() async { - guard app.contactPaymentContext?.isInitialSubscriptionPayment == true, + guard let lnurlPayData = app.lnurlPayData, + app.contactPaymentContext?.isInitialSubscriptionPayment == true, !hasStartedAutomaticPayment else { return } hasStartedAutomaticPayment = true @@ -197,7 +197,7 @@ struct LnurlPayConfirm: View { error: error, retryRoute: .lnurlPayConfirm, routingCacheResetAttempted: routingCacheResetAttempted, - paymentRequest: "LNURL: \(uri)", + paymentRequest: "LNURL: \(lnurlPayData.uri)", contactPaymentContext: app.contactPaymentContext ))) } @@ -259,6 +259,8 @@ struct LnurlPayConfirm: View { } private func performPayment() async throws { + isSubmittingPayment = true + defer { isSubmittingPayment = false } guard let lnurlPayData = app.lnurlPayData else { throw NSError(domain: "LNURL", code: -1, userInfo: [NSLocalizedDescriptionKey: "Missing LNURL pay data"]) } @@ -266,6 +268,8 @@ struct LnurlPayConfirm: View { let amountMsats = lnurlPayData.callbackAmountMsats(userSats: wallet.sendAmountSats) let contactPaymentContext = app.contactPaymentContext let incomingPaymentRequest = contactPaymentContext?.incomingPaymentRequest + let paymentActivity = PaykitPaymentActivity.shared.begin() + defer { PaykitPaymentActivity.shared.end(paymentActivity) } var bolt11Invoice: String? var lightningPaymentHash: String? var shouldCancelPaymentProof = false @@ -276,8 +280,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 ) @@ -318,6 +326,7 @@ struct LnurlPayConfirm: View { try await wallet.sendWithTimeout( bolt11: bolt11, sats: nil, + paymentDeadline: incomingPaymentRequest?.paymentDeadline, afterListening: { _ in lightningPaymentSubmitted = true }, onTimeout: { timedOutHash in app.addPendingPaymentHash(timedOutHash, contactPaymentContext: contactPaymentContext) @@ -383,7 +392,9 @@ struct LnurlPayConfirm: View { private func validateIncomingPaymentRequest(_ context: ContactPaymentContext?, amountMsats: UInt64) throws { guard let context, let request = context.incomingPaymentRequest else { return } - guard !request.isExpired(at: Date()) else { throw PaykitPaymentRequestError.requestExpired } + guard !request.isExpired(at: Date()) || paykitPaymentRequestManager.isApprovedForPayment(request) else { + throw PaykitPaymentRequestError.requestExpired + } guard app.ownsContactPaymentContext(context) else { throw PaykitPaymentRequestError.requestUnavailable } guard let amountSats = wallet.sendAmountSats, request.acceptsPaymentAmount(amountSats), diff --git a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift index 83644194f..aa045cd61 100644 --- a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift +++ b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift @@ -16,10 +16,12 @@ struct SendConfirmationView: View { @Environment(HwWalletManager.self) private var hwWalletManager @Binding var navigationPath: [SendRoute] + @Binding var isSubmittingPayment: Bool let hwSend: HwSendCoordinator let requestPinCheck: () async -> Bool let prepareIncomingPaymentRequest: () async throws -> Void let routingCacheResetAttempted: Bool + var preparingRequest: PaykitPaymentRequest? @State private var showDetails = false @State private var showingBiometricError = false @@ -140,6 +142,7 @@ struct SendConfirmationView: View { } private var canEditAmount: Bool { + guard preparingRequest == nil else { return false } guard app.contactPaymentContext?.incomingPaymentRequest == nil else { return false } guard app.selectedWalletToPayFrom == .lightning else { return true } guard let invoice = app.scannedLightningInvoice else { return true } @@ -148,7 +151,7 @@ struct SendConfirmationView: View { } private var contactPaymentContact: PubkyContact? { - guard let publicKey = app.contactPaymentContext?.publicKey else { + guard let publicKey = preparingRequest?.counterparty ?? app.contactPaymentContext?.publicKey else { return nil } @@ -156,7 +159,7 @@ struct SendConfirmationView: View { } private var shouldAutomaticallyPay: Bool { - app.contactPaymentContext?.isInitialSubscriptionPayment == true && app.selectedWalletToPayFrom == .lightning && + preparingRequest == nil && app.contactPaymentContext?.isInitialSubscriptionPayment == true && app.selectedWalletToPayFrom == .lightning && !hwSend.isActive && !requiresPaymentConfirmation } @@ -174,11 +177,13 @@ struct SendConfirmationView: View { SheetHeader( title: reviewTitle, showBackButton: !navigationPath.isEmpty, - action: AnyView(SendContactHeaderAvatar()) + action: AnyView(SendContactHeaderAvatar(publicKey: preparingRequest?.counterparty)) ) VStack(alignment: .leading, spacing: 0) { - if app.selectedWalletToPayFrom == .lightning, let invoice = app.scannedLightningInvoice { + if let preparingRequest { + MoneyStack(sats: Int(preparingRequest.amountSats), showSymbol: true, testIdPrefix: "ReviewAmount") + } else if app.selectedWalletToPayFrom == .lightning, let invoice = app.scannedLightningInvoice { MoneyStack( sats: Int(wallet.sendAmountSats ?? invoice.amountSatoshis), showSymbol: true, @@ -235,10 +240,11 @@ struct SendConfirmationView: View { } .frame(maxWidth: .infinity, alignment: .center) .padding(.bottom, 62) + .disabled(preparingRequest != nil) .accessibilityIdentifier("SendConfirmToggleDetails") } - if isFeeRateMissing, wallet.feeRateLoadFailed { + if preparingRequest == nil, isFeeRateMissing, wallet.feeRateLoadFailed { CustomButton(title: t("common__try_again"), size: .small) { await retryFeeRate() } @@ -256,7 +262,8 @@ struct SendConfirmationView: View { isLoading: Self.isSwipeLoading( hasStartedAutomaticPayment: hasStartedAutomaticPayment, isFeeRateMissing: isFeeRateMissing, - feeRateLoadFailed: wallet.feeRateLoadFailed + feeRateLoadFailed: wallet.feeRateLoadFailed, + isPreparingRequest: preparingRequest != nil ), swipeProgress: $swipeProgress ) { @@ -268,8 +275,10 @@ struct SendConfirmationView: View { .sheetBackground() .frame(maxWidth: .infinity, maxHeight: .infinity) .accessibilityElement(children: .contain) - .accessibilityIdentifier(app.contactPaymentContext?.incomingPaymentRequest == nil ? "SendConfirm" : "PaymentRequestConfirm") - .task { + .accessibilityIdentifier(preparingRequest == nil && app.contactPaymentContext? + .incomingPaymentRequest == nil ? "SendConfirm" : "PaymentRequestConfirm") + .task(id: preparingRequest?.id) { + guard preparingRequest == nil else { return } ensureSendAmountFromScannedInvoicesIfNeeded() if app.contactPaymentContext?.isInitialSubscriptionPayment == true, !shouldAutomaticallyPay { requiresPaymentConfirmation = true @@ -282,16 +291,18 @@ struct SendConfirmationView: View { Task { @MainActor in await startAutomaticPaymentIfNeeded() } } .onChange(of: wallet.selectedFeeRateSatsPerVByte) { + guard preparingRequest == nil else { return } Task { await calculateTransactionFee() } } .onChange(of: wallet.feeRateLoadFailed, initial: true) { _, failed in - if failed, isFeeRateMissing { + if preparingRequest == nil, failed, isFeeRateMissing { showFeeRateUnavailableToast() } } .onChange(of: app.selectedWalletToPayFrom) { + guard preparingRequest == nil else { return } Task { if app.selectedWalletToPayFrom == .lightning { await calculateTransactionFee() @@ -301,6 +312,7 @@ struct SendConfirmationView: View { } } .onChange(of: hwSend.walletId) { + guard preparingRequest == nil else { return } Task { await calculateTransactionFee() } } .alert( @@ -335,7 +347,9 @@ struct SendConfirmationView: View { } private var reviewTitle: String { - paykitPaymentReviewTitle(context: app.contactPaymentContext, fallback: t("wallet__send_review")) + preparingRequest == nil + ? paykitPaymentReviewTitle(context: app.contactPaymentContext, fallback: t("wallet__send_review")) + : t("wallet__payment_request") } @MainActor @@ -636,6 +650,7 @@ struct SendConfirmationView: View { } private func submitPayment(isAutomatic: Bool = false) async throws { + guard preparingRequest == nil else { throw CancellationError() } if isFeeRateMissing { try await wallet.setFeeRate(speed: settings.defaultTransactionSpeed) } @@ -764,9 +779,13 @@ struct SendConfirmationView: View { walletType == .onchain && !isHardwarePayment && feeRate == nil } - /// The swipe shows its spinner while a payment starts or the fee rate is still loading; once the load gave up it stops so the user can retry. - static func isSwipeLoading(hasStartedAutomaticPayment: Bool, isFeeRateMissing: Bool, feeRateLoadFailed: Bool) -> Bool { - hasStartedAutomaticPayment || (isFeeRateMissing && !feeRateLoadFailed) + static func isSwipeLoading( + hasStartedAutomaticPayment: Bool, + isFeeRateMissing: Bool, + feeRateLoadFailed: Bool, + isPreparingRequest: Bool = false + ) -> Bool { + isPreparingRequest || hasStartedAutomaticPayment || (isFeeRateMissing && !feeRateLoadFailed) } private func showFeeRateUnavailableToast() { @@ -791,9 +810,10 @@ struct SendConfirmationView: View { walletType: WalletType, isHardwarePayment: Bool, isHardwareConfirmationUnavailable: Bool, - feeRate: UInt32? + feeRate: UInt32?, + isPreparingRequest: Bool = false ) -> Bool { - isHardwareConfirmationUnavailable + isPreparingRequest || isHardwareConfirmationUnavailable || isFeeRateMissing(walletType: walletType, isHardwarePayment: isHardwarePayment, feeRate: feeRate) } @@ -810,7 +830,8 @@ struct SendConfirmationView: View { walletType: app.selectedWalletToPayFrom, isHardwarePayment: hwSend.isActive, isHardwareConfirmationUnavailable: isHardwareConfirmationUnavailable, - feeRate: wallet.selectedFeeRateSatsPerVByte + feeRate: wallet.selectedFeeRateSatsPerVByte, + isPreparingRequest: preparingRequest != nil ) } @@ -840,6 +861,7 @@ struct SendConfirmationView: View { } private var oneOffPaymentRequest: PaykitPaymentRequest? { + if let preparingRequest { return preparingRequest } guard let request = app.contactPaymentContext?.incomingPaymentRequest, request.billingPeriod == nil else { return nil } @@ -919,6 +941,10 @@ struct SendConfirmationView: View { } private func performPayment(isAutomatic: Bool) async throws { + let paymentActivity = PaykitPaymentActivity.shared.begin() + defer { PaykitPaymentActivity.shared.end(paymentActivity) } + isSubmittingPayment = true + defer { isSubmittingPayment = false } var createdMetadataPaymentId: String? = nil let contactPaymentContext = app.contactPaymentContext let contactPublicKey = contactPaymentContext?.publicKey @@ -934,8 +960,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 ) @@ -985,6 +1015,7 @@ struct SendConfirmationView: View { try await wallet.sendWithTimeout( bolt11: invoice.bolt11, sats: paymentSats, + paymentDeadline: incomingPaymentRequest?.paymentDeadline, afterListening: { _ in lightningPaymentSubmitted = true }, onTimeout: { timedOutHash in app.addPendingPaymentHash(timedOutHash, contactPaymentContext: contactPaymentContext) @@ -1056,6 +1087,7 @@ struct SendConfirmationView: View { address: invoice.address, sats: amount, isMaxAmount: useMaxAmount, + paymentDeadline: incomingPaymentRequest?.paymentDeadline, beforeBroadcastAttempt: beforeBroadcastAttempt ) } @@ -1064,9 +1096,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 ) } @@ -1162,7 +1198,9 @@ struct SendConfirmationView: View { private func validateIncomingPaymentRequestContext(_ context: ContactPaymentContext?) throws { guard let context, let request = context.incomingPaymentRequest else { return } - guard !request.isExpired(at: Date()) else { throw PaykitPaymentRequestError.requestExpired } + guard !request.isExpired(at: Date()) || paykitPaymentRequestManager.isApprovedForPayment(request) else { + throw PaykitPaymentRequestError.requestExpired + } guard app.ownsContactPaymentContext(context) else { throw PaykitPaymentRequestError.requestUnavailable } } diff --git a/Bitkit/Views/Wallets/Send/SendContactHeaderAvatar.swift b/Bitkit/Views/Wallets/Send/SendContactHeaderAvatar.swift index 301d2d6d0..497ab09b2 100644 --- a/Bitkit/Views/Wallets/Send/SendContactHeaderAvatar.swift +++ b/Bitkit/Views/Wallets/Send/SendContactHeaderAvatar.swift @@ -4,6 +4,8 @@ struct SendContactHeaderAvatar: View { @EnvironmentObject private var app: AppViewModel @EnvironmentObject private var contactsManager: ContactsManager + var publicKey: String? + var body: some View { if let contact { PubkyContactAvatar(contact: contact, size: 32) @@ -14,7 +16,7 @@ struct SendContactHeaderAvatar: View { } private var contact: PubkyContact? { - guard let publicKey = app.contactPaymentContext?.publicKey else { + guard let publicKey = publicKey ?? app.contactPaymentContext?.publicKey else { return nil } diff --git a/Bitkit/Views/Wallets/Send/SendContactSelectView.swift b/Bitkit/Views/Wallets/Send/SendContactSelectView.swift index a1cfab6c8..df2647d3a 100644 --- a/Bitkit/Views/Wallets/Send/SendContactSelectView.swift +++ b/Bitkit/Views/Wallets/Send/SendContactSelectView.swift @@ -80,7 +80,9 @@ struct SendContactSelectView: View { } } catch { Logger.error("Failed to pay contact \(PubkyPublicKeyFormat.redacted(contact.publicKey)): \(error)", context: "SendContactSelectView") - app.toast(type: .error, title: t("slashtags__error_pay_title"), description: error.localizedDescription) + if let description = PaymentNavigationHelper.contactPaymentErrorDescription(error) { + app.toast(type: .error, title: t("slashtags__error_pay_title"), description: description) + } } } diff --git a/Bitkit/Views/Wallets/Send/SendPendingScreen.swift b/Bitkit/Views/Wallets/Send/SendPendingScreen.swift index 26025b59a..996ab12fd 100644 --- a/Bitkit/Views/Wallets/Send/SendPendingScreen.swift +++ b/Bitkit/Views/Wallets/Send/SendPendingScreen.swift @@ -88,6 +88,7 @@ struct SendPendingScreen: View { } .onReceive(PaykitPaymentProofService.onchainPaymentResolutionPublisher) { resolution in guard resolution.requestId == paykitPaymentRequestId, + resolution.walletId == WalletScope.default, let identity = pubkyProfile.publicKey, PubkyPublicKeyFormat.matches(resolution.identity, identity) else { return } diff --git a/Bitkit/Views/Wallets/Send/SendSheet.swift b/Bitkit/Views/Wallets/Send/SendSheet.swift index a9d77823b..dc23eaa66 100644 --- a/Bitkit/Views/Wallets/Send/SendSheet.swift +++ b/Bitkit/Views/Wallets/Send/SendSheet.swift @@ -78,10 +78,19 @@ enum SendRoute: Hashable { struct SendConfig { let initialRoute: SendRoute let hardwareWalletId: String? + let preparation: IncomingPaykitPaymentRequestPreparation? + let onDismiss: (() -> Void)? - init(view: SendRoute = .options, hardwareWalletId: String? = nil) { + init( + view: SendRoute = .options, + hardwareWalletId: String? = nil, + preparation: IncomingPaykitPaymentRequestPreparation? = nil, + onDismiss: (() -> Void)? = nil + ) { initialRoute = view self.hardwareWalletId = hardwareWalletId + self.preparation = preparation + self.onDismiss = onDismiss } } @@ -90,10 +99,12 @@ struct SendSheetItem: SheetItem { let size: SheetSize = .large let initialRoute: SendRoute let hardwareWalletId: String? + let preparation: IncomingPaykitPaymentRequestPreparation? - init(initialRoute: SendRoute = .options, hardwareWalletId: String? = nil) { + init(initialRoute: SendRoute = .options, hardwareWalletId: String? = nil, preparation: IncomingPaykitPaymentRequestPreparation? = nil) { self.initialRoute = initialRoute self.hardwareWalletId = hardwareWalletId + self.preparation = preparation } } @@ -118,6 +129,7 @@ struct SendSheet: View { @State private var quickPaySession = 0 @State private var hasValidatedAfterSync = false @State private var incomingPaymentRequest: PaykitPaymentRequest? + @State private var isSubmittingPayment = false @State private var pendingEmbeddedRetryRoute: SendRoute? @State private var routingCacheResetAttempted = false @State private var syncTimedOut = false @@ -136,6 +148,31 @@ struct SendSheet: View { rootOverride ?? rootRoute } + private var isPreparingRequest: Bool { + config.preparation != nil && incomingPaymentRequest == nil + } + + private var shouldDismissUnavailableRequest: Bool { + guard let request = incomingPaymentRequest else { return false } + return Self.shouldDismissUnavailableRequest( + root: currentRoot, + path: navigationPath, + isSubmittingPayment: isSubmittingPayment, + isAvailable: paykitPaymentRequestManager.pendingRequests.contains(where: { $0.id == request.id }) || + paykitPaymentRequestManager.isApprovedForPayment(request) + ) + } + + static func shouldDismissUnavailableRequest(root: SendRoute, path: [SendRoute], isSubmittingPayment: Bool, isAvailable: Bool) -> Bool { + guard !isAvailable, !isSubmittingPayment else { return false } + switch path.last ?? root { + case .confirm, .lnurlPayConfirm, .amount, .utxoSelection, .feeRate, .feeCustom, .tag, .pin: + return true + default: + return false + } + } + /// How long the sync overlay may wait for channels to become usable before falling back private static let syncTimeoutSeconds: TimeInterval = 20 @@ -144,7 +181,7 @@ struct SendSheet: View { /// If there are no channels at all, we should NOT wait behind the sync UI – that's a capacity issue, not a sync issue. /// For onchain: only need node running. private var shouldShowSyncOverlay: Bool { - if hwSend.isActive { + if isPreparingRequest || hwSend.isActive { return false } @@ -201,76 +238,27 @@ struct SendSheet: View { .sheet(isPresented: reconnectPairingBinding) { HardwarePairingSheet(config: HardwarePairingSheetItem()) } - .offlineSheetOverlay(title: t("wallet__send_bitcoin"), forceShow: syncTimedOut) + .offlineSheetOverlay(title: t("wallet__send_bitcoin"), forceShow: syncTimedOut, isEnabled: !isPreparingRequest) + .onChange(of: shouldDismissUnavailableRequest) { _, shouldDismiss in + guard shouldDismiss else { return } + sheets.hideSheetIfActive(isEmbedded ? .subscription : .send, reason: "Incoming payment request is no longer available") + } .onChange(of: shouldShowSyncOverlay, initial: true) { _, isShowing in Logger.debug("shouldShowSyncOverlay: \(isShowing) (node: \(wallet.nodeLifecycleState))", context: "SendSheet") } - .onAppear { - wallet.resetSendState(speed: settings.defaultTransactionSpeed) - if let walletId = config.hardwareWalletId { - let balance = hwWalletManager.fundingBalance(walletId: walletId) - let reserve = HwFundingSigner.feeReserve( - balanceSats: balance, - satsPerVByte: wallet.selectedFeeRateSatsPerVByte.map(UInt64.init) - ) - hwSend.seedAvailable( - walletId: walletId, - availableSats: balance > reserve ? balance - reserve : 0 - ) - } - if let request = app.contactPaymentContext?.incomingPaymentRequest { - incomingPaymentRequest = request - if !tagManager.consumePreservedTags(for: request.id) { - tagManager.clearSelectedTags() - } - guard paykitPaymentRequestManager.markPresentedIfPending(request) else { - app.resetSendState() - sheets.hideSheetIfActive( - isEmbedded ? .subscription : .send, - reason: "Incoming payment request is no longer available" - ) - return - } - if PaykitSubscriptionNotificationTargetStore.load()?.matches(request) == true { - PaykitSubscriptionNotificationTargetStore.clear() - } - wallet.sendAmountSats = request.amountSats - } else { - tagManager.clearSelectedTags() - } - hasValidatedAfterSync = false - syncTimedOut = false - - // A plain Send open (TabBar) must not inherit invoice state from an earlier scan, - // e.g. one abandoned behind the sync overlay. Invoice-carrying opens use other routes. - if currentRoot == .options { - app.resetSendState() - } - - setupTask?.cancel() - setupTask = Task { - do { - try await wallet.loadFeeRateWithRetry(speed: settings.defaultTransactionSpeed) - } catch is CancellationError { - return - } catch { - Logger.error("Failed to set default fee rate: \(error)") - } - - if let request = incomingPaymentRequest { - guard isCurrentIncomingRequest(request.id) else { return } - guard await selectHardwareFundingSourceIfNeeded( - amountSats: request.amountSats, - requestId: request.id - ) else { return } - guard isCurrentIncomingRequest(request.id) else { return } - if !shouldShowSyncOverlay { - validatePaymentAfterSync() - } - } + .task(id: config.preparation?.resolvedRoute) { + if let preparation = config.preparation { + guard let route = preparation.resolvedRoute, preparation.ownsSheet(sheets), + let request = preparation.request, + app.contactPaymentContext == preparation.paymentContext, + app.contactPaymentContext?.incomingPaymentRequest?.id == request.id + else { return } + rootRoute = route } + setUpSend() } .onChange(of: wallet.nodeLifecycleState) { _, state in + guard !isPreparingRequest else { return } // When the node becomes running and we have a scanned invoice, run deferred validation. // This covers: // - Pure onchain invoices (node was not running at scan time) @@ -286,6 +274,7 @@ struct SendSheet: View { } } .onChange(of: wallet.hasUsableChannels) { _, hasUsable in + guard !isPreparingRequest else { return } // Only validate if channels just became usable and we have a scanned invoice // (Validation already happened in AppViewModel if channels were already usable) guard app.hasSendPaymentTarget else { return } @@ -320,7 +309,9 @@ struct SendSheet: View { @ViewBuilder private var content: some View { - if shouldShowSyncOverlay { + if isPreparingRequest, config.preparation?.request == nil { + Color.clear + } else if shouldShowSyncOverlay { SendSyncScreen() .transition(.opacity) } else { @@ -339,6 +330,9 @@ struct SendSheet: View { setupTask?.cancel() setupTask = nil hwSend.cancel() + if isPreparingRequest { + return + } if let request = incomingPaymentRequest ?? app.contactPaymentContext?.incomingPaymentRequest { Task { await paykitPaymentRequestManager.finishPayment(request) } } @@ -349,6 +343,58 @@ struct SendSheet: View { QuickPayPaymentCoordinator.shared.detach() } + private func setUpSend() { + wallet.resetSendState(speed: settings.defaultTransactionSpeed) + if let walletId = config.hardwareWalletId { + let balance = hwWalletManager.fundingBalance(walletId: walletId) + let reserve = HwFundingSigner.feeReserve( + balanceSats: balance, + satsPerVByte: wallet.selectedFeeRateSatsPerVByte.map(UInt64.init) + ) + hwSend.seedAvailable(walletId: walletId, availableSats: balance > reserve ? balance - reserve : 0) + } + if let request = app.contactPaymentContext?.incomingPaymentRequest { + if !tagManager.consumePreservedTags(for: request.id) { + tagManager.clearSelectedTags() + } + guard paykitPaymentRequestManager.markPresentedIfPending(request) else { + app.resetSendState() + sheets.hideSheetIfActive(isEmbedded ? .subscription : .send, reason: "Incoming payment request is no longer available") + return + } + if PaykitSubscriptionNotificationTargetStore.load()?.matches(request) == true { + PaykitSubscriptionNotificationTargetStore.clear() + } + wallet.sendAmountSats = request.amountSats + incomingPaymentRequest = request + } else { + tagManager.clearSelectedTags() + } + hasValidatedAfterSync = false + syncTimedOut = false + if currentRoot == .options { + app.resetSendState() + } + setupTask?.cancel() + setupTask = Task { + do { + try await wallet.loadFeeRateWithRetry(speed: settings.defaultTransactionSpeed) + } catch is CancellationError { + return + } catch { + Logger.error("Failed to set default fee rate: \(error)") + } + if let request = incomingPaymentRequest { + guard isCurrentIncomingRequest(request.id) else { return } + guard await selectHardwareFundingSourceIfNeeded(amountSats: request.amountSats, requestId: request.id) else { return } + guard isCurrentIncomingRequest(request.id) else { return } + if !shouldShowSyncOverlay { + validatePaymentAfterSync() + } + } + } + } + /// Called when the sync overlay has been visible for `syncTimeoutSeconds` without channels becoming usable. /// Unified invoices fall back to onchain; everything else shows the connection-issues screen /// (via the forced `offlineSheetOverlay`) and keeps waiting for the peer. @@ -685,10 +731,12 @@ struct SendSheet: View { case .confirm: SendConfirmationView( navigationPath: $navigationPath, + isSubmittingPayment: $isSubmittingPayment, hwSend: hwSend, requestPinCheck: requestPinCheck, prepareIncomingPaymentRequest: prepareIncomingPaymentRequest, - routingCacheResetAttempted: routingCacheResetAttempted + routingCacheResetAttempted: routingCacheResetAttempted, + preparingRequest: isPreparingRequest ? config.preparation?.request : nil ) case .hardwareSign: HwSendSignView( @@ -754,6 +802,7 @@ struct SendSheet: View { case .lnurlPayConfirm: LnurlPayConfirm( navigationPath: $navigationPath, + isSubmittingPayment: $isSubmittingPayment, requestPinCheck: requestPinCheck, prepareIncomingPaymentRequest: prepareIncomingPaymentRequest, routingCacheResetAttempted: routingCacheResetAttempted @@ -799,18 +848,23 @@ struct SendSheet: View { } let endpointIdentifier = PublicPaykitService.onchainMethodId(for: address).rawValue + guard let privateContext = context?.privatePaymentContext, let walletId = hwSend.walletId else { + throw PaykitPaymentRequestError.requestUnavailable + } try await PaykitPaymentProofService.shared.prepare( request: request, + paymentAppId: privateContext.paymentAppId(for: endpointIdentifier), paymentEndpointIdentifier: endpointIdentifier, - kind: .onchain + kind: .onchain, + walletId: walletId ) do { try await prepareIncomingPaymentRequest(context: context) - try await PaykitPaymentProofService.shared.markOnchainPaymentStarted(request, address: address) + try await PaykitPaymentProofService.shared.markOnchainPaymentStarted(request, address: address, walletId: walletId) } catch { _ = await paykitPaymentRequestManager.paymentRequestForRetry(request.id) await context?.resolvePrivatePaymentListConsumption(.definitePreBroadcastFailure) - await PaykitPaymentProofService.shared.cancelPreparation(request) + await PaykitPaymentProofService.shared.cancelPreparation(request, walletId: walletId) throw error } } @@ -822,19 +876,26 @@ struct SendSheet: View { private func completeHardwareContactPayment(_ context: ContactPaymentContext?, txid: String) async { guard let request = context?.incomingPaymentRequest, - let address = app.scannedOnchainInvoice?.address + let walletId = hwSend.walletId, + 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, - paymentEndpointIdentifier: PublicPaykitService.onchainMethodId(for: address).rawValue + paymentAppId: paymentAppId, + paymentEndpointIdentifier: PublicPaykitService.onchainMethodId(for: address).rawValue, + walletId: walletId ) } private func cancelHardwareContactPayment(_ context: ContactPaymentContext?, outcome: PrivatePaymentListSendOutcome) async { - await Self.cancelHardwareContactPayment(context, outcome: outcome, app: app, manager: paykitPaymentRequestManager) + guard let walletId = hwSend.walletId else { return } + await Self.cancelHardwareContactPayment(context, outcome: outcome, app: app, manager: paykitPaymentRequestManager, walletId: walletId) } @MainActor @@ -843,14 +904,15 @@ struct SendSheet: View { outcome: PrivatePaymentListSendOutcome, app: AppViewModel, manager: PaykitPaymentRequestManager, + walletId: String = WalletScope.default, privatePaykitService: PrivatePaykitService = .shared, paymentProofService: PaykitPaymentProofService = .shared ) async { guard let request = context?.incomingPaymentRequest else { return } await context?.resolvePrivatePaymentListConsumption(outcome, service: privatePaykitService) guard outcome == .definitePreBroadcastFailure else { return } - await paymentProofService.failOnchainPayment(request) - await paymentProofService.cancelPreparation(request) + await paymentProofService.failOnchainPayment(request, walletId: walletId) + await paymentProofService.cancelPreparation(request, walletId: walletId) if let context { await restoreHardwareContactPaymentForRetry(context, app: app, manager: manager) } 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/BackupServiceTests.swift b/BitkitTests/BackupServiceTests.swift new file mode 100644 index 000000000..1f1110f71 --- /dev/null +++ b/BitkitTests/BackupServiceTests.swift @@ -0,0 +1,95 @@ +@testable import Bitkit +import Paykit +import XCTest + +@MainActor +final class BackupServiceTests: XCTestCase { + func testCancelledDeferredWalletBackupRemainsRequiredAndRetryCompletes() async throws { + let suite = "BackupServiceTests.\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suite)) + defer { defaults.removePersistentDomain(forName: suite) } + let exportStarted = expectation(description: "Wallet export requested") + let cancelledBackupFinished = expectation(description: "Cancelled wallet backup finished") + let recorder = BackupRecorder() + let sdk = WalletBackupSdk(noPointer: .init()) + sdk.onExport = { await recorder.recordExport() } + let paykit = PaykitSdkService(sdkFactory: { sdk }) + let service = BackupService( + defaults: defaults, + backupData: { category in + XCTAssertEqual(category, .wallet) + if await recorder.startExport() == 1 { exportStarted.fulfill() } + return try await Data(paykit.exportBackupState().utf8) + }, + uploadBackup: { key, data in await recorder.recordUpload(key: key, data: data) } + ) + let payment = PaykitPaymentActivity.shared.begin() + defer { PaykitPaymentActivity.shared.end(payment) } + let backup = Task { + await service.triggerBackup(category: .wallet) + cancelledBackupFinished.fulfill() + } + await fulfillment(of: [exportStarted], timeout: 2) + let running = service.getBackupStatus(category: .wallet) + XCTAssertTrue(running.running) + XCTAssertTrue(running.isRequired) + + backup.cancel() + await fulfillment(of: [cancelledBackupFinished], timeout: 2) + let cancelled = service.getBackupStatus(category: .wallet) + XCTAssertFalse(cancelled.running) + XCTAssertTrue(cancelled.isRequired) + XCTAssertEqual(cancelled.required, running.required) + XCTAssertEqual(cancelled.synced, running.synced) + let cancelledExports = await recorder.exports + let cancelledUploads = await recorder.uploads + XCTAssertEqual(cancelledExports, 0) + XCTAssertTrue(cancelledUploads.isEmpty) + + PaykitPaymentActivity.shared.end(payment) + await backup.value + await service.triggerBackup(category: .wallet) + + let completed = service.getBackupStatus(category: .wallet) + XCTAssertFalse(completed.running) + XCTAssertFalse(completed.isRequired) + XCTAssertGreaterThan(completed.synced, cancelled.synced) + let exports = await recorder.exports + let uploads = await recorder.uploads + XCTAssertEqual(exports, 1) + XCTAssertEqual(uploads.map(\.key), [BackupCategory.wallet.rawValue]) + XCTAssertEqual(uploads.map(\.data), [Data("wallet-backup".utf8)]) + } +} + +private actor BackupRecorder { + private var attempts = 0 + private(set) var exports = 0 + private(set) var uploads: [(key: String, data: Data)] = [] + + func startExport() -> Int { + attempts += 1 + return attempts + } + + func recordExport() { + exports += 1 + } + + func recordUpload(key: String, data: Data) { + uploads.append((key, data)) + } +} + +private final class WalletBackupSdk: PaykitSdk, @unchecked Sendable { + var onExport: () async -> Void = {} + + override func identityStatus() async throws -> IdentityStatus? { + IdentityStatus(publicKey: nil, capability: .privateLinkCapable) + } + + override func exportBackupString() async throws -> String { + await onExport() + return "wallet-backup" + } +} diff --git a/BitkitTests/ContactPaymentsServiceTests.swift b/BitkitTests/ContactPaymentsServiceTests.swift index 961004fe7..c805fa991 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 @@ -276,7 +511,7 @@ final class ContactPaymentsServiceTests: XCTestCase { defaults: defaults ) } - await fulfillment(of: [loadStarted], timeout: 2) + await fulfillment(of: [loadStarted], timeout: 10) var runningSignOut: (task: Task, release: AsyncStream.Continuation)? switch testCase.signOut { @@ -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,23 @@ 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 + case privateBeforeLock, publicBeforeLock, publicHoldingLock, publicCapabilitySync } - 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"] + ), + ( + "public capability sync holding its lock", .publicCapabilitySync, + ["private:removed", "public:removed", "public:published", "private:published"] ), ] let ownerKey = try useLocalPubkySecretKey() @@ -610,7 +821,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,11 +833,16 @@ 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 == .publicCapabilitySync { + try await endpoints.removePublic(isChangeCurrent: isChangeCurrent) { + reachedHeldPoint.fulfill() + for await _ in gate {} + } + return + } if !publish, held == .publicBeforeLock { reachedHeldPoint.fulfill() for await _ in gate {} @@ -647,18 +863,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 +877,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, [true, false], testCase.name) + XCTAssertEqual(operations.privateCleanupValues.last, false, testCase.name) XCTAssertTrue(defaults.bool(forKey: PublicPaykitService.publishingEnabledKey), testCase.name) XCTAssertTrue(defaults.bool(forKey: PrivatePaykitService.publishingEnabledKey), testCase.name) XCTAssertTrue(defaults.bool(forKey: ContactPaymentsService.confirmedPreferenceKey), testCase.name) @@ -675,9 +886,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 +917,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 +1010,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 ) } @@ -872,6 +1084,16 @@ final class ContactPaymentsServiceTests: XCTestCase { entries.append(entry) } } + + func removePublic(isChangeCurrent: @escaping ContactPaymentsService.ChangeCheck, syncApp: () async throws -> Void) async throws { + try await PublicPaykitService.syncPublishedEndpoints( + publish: false, + isSessionCurrent: isChangeCurrent, + buildEndpoints: { [] }, + syncApp: syncApp, + applyEndpoints: { _ in self.entries.append("public:removed") } + ) + } } private final class OperationsSpy { @@ -893,12 +1115,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 +1146,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 f899d6cdc..b41f151ad 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,50 +26,31 @@ 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)) } func testImportSavesPreparedContactsWithoutNetworkAndSkipsDuplicates() async throws { let manager = ContactsManager() let prepared = (0 ..< 62).map { makeContact(publicKey: "pubky-contact-\($0)") } - var saved: [String] = [] - try await manager.importContacts(contacts: prepared + prepared) { key, label in - XCTAssertEqual(label, "Alice") - saved.append(key) + var batches: [[ContactUpdate]] = [] + try await manager.importContacts(contacts: prepared + prepared) { updates, _ in + XCTAssertTrue(updates.allSatisfy { $0.label == "Alice" }) + batches.append(updates) } - XCTAssertEqual(saved, prepared.map(\.publicKey)) + XCTAssertEqual(batches.count, 1) + XCTAssertEqual(batches.first?.map(\.publicKey), prepared.map(\.publicKey)) XCTAssertEqual(Set(manager.contacts), Set(prepared)) XCTAssertEqual(manager.contacts.count, 62) } @@ -96,52 +77,62 @@ final class ContactsManagerTests: XCTestCase { XCTAssertEqual(manager.pendingImportContacts, expected) var saved: [String] = [] - try await manager.importContacts(contacts: manager.pendingImportContacts) { publicKey, _ in - XCTAssertFalse(PubkyPublicKeyFormat.matches(publicKey, ownPublicKey)) - saved.append(publicKey) + try await manager.importContacts(contacts: manager.pendingImportContacts) { updates, _ in + XCTAssertFalse(updates.contains { PubkyPublicKeyFormat.matches($0.publicKey, ownPublicKey) }) + saved.append(contentsOf: updates.map(\.publicKey)) } XCTAssertEqual(saved, expected.map(\.publicKey)) XCTAssertEqual(manager.contacts, expected) } } - func testImportPreservesSavedContactsOnFailureAndRetriesMissingContacts() async throws { + func testImportPreservesExistingContactsOnBatchFailureAndRetriesSelectedContacts() async throws { let manager = ContactsManager() + let existing = makeContact(publicKey: "pubky-existing") + manager.contacts = [existing] let alice = makeContact(publicKey: "pubky-alice") let bob = makeContact(publicKey: "pubky-bob") - var saved: [String] = [] + var attempted: [[String]] = [] do { - try await manager.importContacts(contacts: [alice, bob]) { key, _ in - if key == bob.publicKey { - throw CocoaError(.fileWriteUnknown) - } - saved.append(key) + try await manager.importContacts(contacts: [existing, alice, bob]) { updates, _ in + attempted.append(updates.map(\.publicKey)) + throw CocoaError(.fileWriteUnknown) } XCTFail("Import should report the failed save") } catch { - XCTAssertEqual(manager.contacts, [alice]) + XCTAssertEqual(manager.contacts, [existing]) } - try await manager.importContacts(contacts: [alice, bob]) { key, _ in saved.append(key) } - XCTAssertEqual(saved, [alice.publicKey, bob.publicKey]) - XCTAssertEqual(Set(manager.contacts), Set([alice, bob])) + try await manager.importContacts(contacts: [existing, alice, bob]) { updates, _ in + attempted.append(updates.map(\.publicKey)) + } + XCTAssertEqual(attempted, Array(repeating: [alice.publicKey, bob.publicKey], count: 2)) + XCTAssertEqual(Set(manager.contacts), Set([existing, alice, bob])) } func testCancelledImportStopsSavingAndDoesNotPublishStaleResults() async throws { - let manager = ContactsManager() let prepared = (0 ..< 3).map { makeContact(publicKey: "pubky-contact-\($0)") } - var attempted: [String] = [] - do { - try await manager.importContacts(contacts: prepared) { key, _ in - attempted.append(key) - if key == prepared[1].publicKey { - throw CancellationError() + for heldSaveSucceeds in [true, false] { + let manager = ContactsManager() + let saves = HeldProfileLookups(profiles: heldSaveSucceeds ? [prepared[0].publicKey: "Alice"] : [:]) + await saves.hold() + let importTask = Task { + try await manager.importContacts(contacts: prepared) { updates, _ in + XCTAssertEqual(updates.map(\.publicKey), prepared.map(\.publicKey)) + _ = try await saves.fetch(XCTUnwrap(updates.first).publicKey) } } - XCTFail("Import should propagate cancellation") - } catch is CancellationError { - XCTAssertEqual(attempted, Array(prepared.prefix(2)).map(\.publicKey)) - XCTAssertTrue(manager.contacts.isEmpty) + while await saves.heldCount < 1 { + await Task.yield() + } + importTask.cancel() + await saves.release() + do { + try await importTask.value + XCTFail("Import should propagate cancellation") + } catch is CancellationError { + XCTAssertTrue(manager.contacts.isEmpty) + } } } @@ -275,19 +266,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 ) } @@ -770,7 +761,7 @@ final class ContactsManagerTests: XCTestCase { XCTAssertEqual(changes.count, 2, "An import announces its new contacts once") XCTAssertEqual(changes.last, Set([contactProfileKey] + imported.map(\.publicKey))) - try await manager.importContacts(contacts: imported) { _, _ in } + try await manager.importContacts(contacts: imported) { _, _ in XCTFail("Already saved contacts must not be saved again") } XCTAssertEqual(changes.count, 2, "An import that adds nothing announces nothing") } @@ -1821,8 +1812,9 @@ final class ContactsManagerTests: XCTestCase { let saves = HeldProfileLookups(profiles: [contactProfileKey: "Alice"]) await saves.hold() let importTask = Task { - try await manager.importContacts(contacts: [Bitkit.PubkyContact(publicKey: contactProfileKey, profile: published)]) { key, _ in - _ = try await saves.fetch(key) + try await manager.importContacts(contacts: [Bitkit.PubkyContact(publicKey: contactProfileKey, profile: published)]) { updates, identity in + XCTAssertEqual(identity, "owner") + _ = try await saves.fetch(XCTUnwrap(updates.first).publicKey) } } while await saves.heldCount < 1 { @@ -1843,17 +1835,18 @@ final class ContactsManagerTests: XCTestCase { await manager.waitForProfileRefreshForTesting() } - func testImportThatAResetOvertakesStopsSavingAndReportsNothing() async throws { + func testImportThatAResetOvertakesDoesNotPublishOrReportFailure() async throws { let prepared = (0 ..< 3).map { makeContact(publicKey: "pubky-contact-\($0)") } - // The save running when the user signs out may land first or fail after the sign-out; later saves would fail. for heldSaveSucceeds in [true, false] { let manager = ContactsManager() try await manager.loadContacts(for: "owner", fetchContactRecords: { [] }, fetchRemoteProfile: { _ in nil }) let saves = HeldProfileLookups(profiles: heldSaveSucceeds ? [prepared[0].publicKey: "Alice"] : [:]) await saves.hold() let importTask = Task { - try await manager.importContacts(contacts: prepared) { key, _ in - _ = try await saves.fetch(key) + try await manager.importContacts(contacts: prepared) { updates, identity in + XCTAssertEqual(identity, "owner") + XCTAssertEqual(updates.map(\.publicKey), prepared.map(\.publicKey)) + _ = try await saves.fetch(XCTUnwrap(updates.first).publicKey) } } while await saves.heldCount < 1 { @@ -1869,7 +1862,7 @@ final class ContactsManagerTests: XCTestCase { } let attempted = await saves.fetchedKeys - XCTAssertEqual(attempted, [prepared[0].publicKey], "A reset stops the import before its next save") + XCTAssertEqual(attempted, [prepared[0].publicKey], "The import makes one batch save") XCTAssertTrue(manager.contacts.isEmpty, "An import a reset overtook adds nothing to the cleared list") } } @@ -1932,7 +1925,10 @@ final class ContactsManagerTests: XCTestCase { let blockingRead = try await holdTheOnlyReadSlot(slot) let lookups = HeldProfileLookups(profiles: [contactProfileKey: "Alice", unresolvedFollowKey: "Bob"]) let records = [unprofiledRecord(key: contactProfileKey, label: "First"), unprofiledRecord(key: unresolvedFollowKey, label: "Second")] - let manager = ContactsManager(contactRecords: { records }, removeContactRecord: { _ in }, forgetRemovedContacts: { _ in }) + let manager = ContactsManager(contactRecords: { records }, removeContactRecords: { keys in + XCTAssertEqual(Set(keys), Set(records.map(\.publicKey))) + return records + }, forgetRemovedContacts: { _ in }) try await manager.loadContacts( for: "owner", fetchContactRecords: { records }, @@ -1952,6 +1948,42 @@ final class ContactsManagerTests: XCTestCase { XCTAssertTrue(manager.contacts.isEmpty) } + func testDeleteAllContactsPreservesContactsNotRemovedByTheBulkOperation() async throws { + snapshotAppDefaults("pubkyContactProfileOverrides") + let removed = contactRecord(key: contactProfileKey, name: "Removed") + let retained = ["Subscribed", "Busy", "Public marker"].enumerated().map { index, name in + contactRecord(key: "pubky" + String(repeating: ["y", "z", "r"][index], count: 52), name: name) + } + let records = [removed] + retained + let contacts = records.map { makeContact(publicKey: $0.publicKey) } + ContactsManager.restoreContactProfileOverrides(Dictionary(uniqueKeysWithValues: contacts.map { + ($0.publicKey, PubkyProfileData.from(profile: $0.profile)) + })) + let forgotten = expectation(description: "Only removed contacts are forgotten") + forgotten.assertForOverFulfill = true + let manager = ContactsManager(contactRecords: { records }, removeContactRecords: { keys in + XCTAssertEqual(Set(keys), Set(records.map(\.publicKey))) + return [removed] + }, forgetRemovedContacts: { keys in + XCTAssertEqual(keys, [removed.publicKey]) + forgotten.fulfill() + }) + manager.contacts = contacts + + do { + try await manager.deleteAllContacts() + XCTFail("Partial removal must report that contacts remain") + } catch PrivatePaykitError.privateUnavailable { + } catch { + XCTFail("Unexpected removal error: \(error)") + } + + await fulfillment(of: [forgotten], timeout: 2) + XCTAssertEqual(Set(manager.contacts.map(\.publicKey)), Set(retained.map(\.publicKey))) + let overrides = try XCTUnwrap(ContactsManager.backupContactProfileOverrides()) + XCTAssertEqual(Set(overrides.keys), Set(retained.map(\.publicKey))) + } + /// Removing a contact stopped only the background refresh's lookup of it, so its screen's lookup stayed queued for a read /// slot and still read the profile of a contact that no longer existed. func testRemovingAContactStopsItsScreensQueuedProfileLookup() async throws { @@ -1999,7 +2031,12 @@ final class ContactsManagerTests: XCTestCase { ("deleting all contacts", { try await $0.deleteAllContacts() }), ] for deletion in deletions { - let manager = ContactsManager(contactRecords: { records }, removeContactRecord: { _ in }, forgetRemovedContacts: { _ in }) + let manager = ContactsManager( + contactRecords: { records }, + removeContactRecord: { _ in }, + removeContactRecords: { _ in records }, + forgetRemovedContacts: { _ in } + ) let bulk = HeldProfileLookups(profiles: [:]) await bulk.hold() try await manager.loadContacts(for: "owner", fetchContactRecords: { records }, fetchRemoteProfile: { try await bulk.fetch($0) }) diff --git a/BitkitTests/HwFundingSignerTests.swift b/BitkitTests/HwFundingSignerTests.swift index a803b54d3..596c0e07b 100644 --- a/BitkitTests/HwFundingSignerTests.swift +++ b/BitkitTests/HwFundingSignerTests.swift @@ -323,7 +323,292 @@ final class HwFundingSignerTests: XCTestCase { } } - func testCoordinatorBroadcastFailureRemainsUncertainAfterPendingPaymentIsCleared() async { + func testPaymentDeadlineRejectionRetainsPriorAttemptWithoutBlockingDismissal() async throws { + enum ExpiryPhase { case beforeRetry, authorization, queuedBroadcast } + for hadPriorAttempt in [false, true] { + for phase in [ExpiryPhase.beforeRetry, .authorization, .queuedBroadcast] { + let funding = MockHwFunding() + let manager = HwWalletManager() + let coordinator = HwSendCoordinator( + walletId: "trezor:wallet", + signerFactory: { [self] _, address, satsPerVByte in + makeSigner(funding: funding, connecting: MockHwConnecting(), feeRate: satsPerVByte, address: address) + } + ) + if hadPriorAttempt { + funding.broadcastError = HwTransferError.broadcastUncertain + await assertThrowsAsync { + _ = try await coordinator.signAndBroadcast(manager: manager, address: "bc1qtest", sats: 42000, satsPerVByte: 2) + } + funding.broadcastError = nil + } + let deadline = PaykitPreciseInstant(date: Date().addingTimeInterval(phase == .beforeRetry ? -60 : 60)) + funding.broadcastNow = { deadline.date.addingTimeInterval(1) } + var outcomes: [PrivatePaymentListSendOutcome] = [] + do { + _ = try await coordinator.signAndBroadcast( + manager: manager, address: "bc1qtest", sats: 42000, satsPerVByte: 2, + paymentDeadline: deadline, + beforeBroadcastAttempt: { + if phase == .authorization { throw PaykitPaymentRequestError.requestExpired } + }, + afterFailure: { outcomes.append($0) } + ) + XCTFail("Expired payment must not broadcast") + } catch { + XCTAssertEqual(error as? PaykitPaymentRequestError, .requestExpired) + } + XCTAssertEqual(funding.broadcastCalls, hadPriorAttempt ? 1 : 0) + XCTAssertEqual(outcomes, [hadPriorAttempt ? .uncertain : .definitePreBroadcastFailure]) + XCTAssertEqual(coordinator.hasPendingBroadcast, hadPriorAttempt) + XCTAssertFalse(coordinator.isBroadcastUnresolved) + XCTAssertTrue(coordinator.canLeave) + XCTAssertEqual(funding.signCalls, 1) + coordinator.cancel() + XCTAssertFalse(coordinator.hasPendingBroadcast) + XCTAssertTrue(coordinator.canLeave) + XCTAssertEqual(outcomes, [hadPriorAttempt ? .uncertain : .definitePreBroadcastFailure]) + } + } + } + + func testHardwareSubmissionRejectsExpiredDeadlineBeforeCallingElectrum() async throws { + do { + _ = try await OnChainHwService.shared.broadcastRawTx( + serializedTx: "invalid", electrumUrl: "invalid", + paymentDeadline: PaykitPreciseInstant(date: Date().addingTimeInterval(-1)) + ) + XCTFail("Expired payment must not reach Electrum") + } catch { + XCTAssertEqual((error as? Bitkit.AppError)?.underlyingError as? PaykitPaymentRequestError, .requestExpired) + } + } + + func testResolvedHardwarePaymentFinishesExpiredRetryWithoutRebroadcasting() async throws { + let funding = MockHwFunding() + funding.broadcastError = HwTransferError.broadcastUncertain + let manager = HwWalletManager() + let coordinator = makeCoordinator(walletId: "trezor:wallet", funding: funding, connecting: MockHwConnecting()) + let identity = "pubky" + String(repeating: "y", count: 52) + let requestId = PaykitPaymentRequest.ID(paymentRequestId: "request", counterparty: "peer") + var failures: [PrivatePaymentListSendOutcome] = [] + + for deadline in [nil, PaykitPreciseInstant(date: Date().addingTimeInterval(-60))] { + await assertThrowsAsync { + _ = try await coordinator.signAndBroadcast( + manager: manager, address: "bc1qtest", sats: 42000, satsPerVByte: 2, + paymentDeadline: deadline, paykitRequestId: requestId, paykitIdentity: identity, + afterFailure: { failures.append($0) } + ) + } + } + + XCTAssertFalse(coordinator.isBroadcastUnresolved) + XCTAssertTrue(coordinator.hasPendingBroadcast) + XCTAssertTrue(coordinator.canLeave) + XCTAssertEqual(failures, [.uncertain, .uncertain]) + let resolution = PaykitOnchainPaymentResolution( + identity: identity, requestId: requestId, transactionId: "resolved-tx", walletId: "trezor:wallet" + ) + let result = try XCTUnwrap(coordinator.resolvePayment(resolution, identity: identity, walletId: "trezor:wallet")) + + XCTAssertEqual(result.txId, resolution.transactionId) + XCTAssertEqual(result.miningFeeSats, funding.signedTx.miningFeeSats) + XCTAssertEqual(result.feeRate, UInt64(funding.signedTx.feeRate.rounded(.up))) + XCTAssertEqual(result.totalSpent, funding.signedTx.totalSpent) + XCTAssertEqual(funding.signCalls, 1) + XCTAssertEqual(funding.broadcastCalls, 1) + XCTAssertTrue(coordinator.hasPendingBroadcast) + XCTAssertFalse(coordinator.canLeave) + XCTAssertEqual(coordinator.resolvePayment(resolution, identity: identity, walletId: "trezor:wallet"), result) + await assertThrowsAsync { + _ = try await coordinator.signAndBroadcast( + manager: manager, address: "bc1qtest", sats: 42000, satsPerVByte: 2, + paykitRequestId: requestId, paykitIdentity: identity + ) + } + XCTAssertEqual(funding.broadcastCalls, 1) + coordinator.completeBroadcast() + XCTAssertFalse(coordinator.hasPendingBroadcast) + XCTAssertTrue(coordinator.canLeave) + XCTAssertNil(coordinator.resolvePayment(resolution, identity: identity, walletId: "trezor:wallet")) + } + + func testHardwareResolutionRequiresAttemptedRequestIdentityAndWallet() async { + let funding = MockHwFunding() + funding.broadcastError = HwTransferError.broadcastUncertain + let manager = HwWalletManager() + let coordinator = makeCoordinator(walletId: "trezor:wallet", funding: funding, connecting: MockHwConnecting()) + let identity = "pubky" + String(repeating: "y", count: 52) + let otherIdentity = "pubky" + String(repeating: "b", count: 52) + let requestId = PaykitPaymentRequest.ID(paymentRequestId: "request", counterparty: "peer") + let resolution = PaykitOnchainPaymentResolution( + identity: identity, requestId: requestId, transactionId: "resolved-tx", walletId: "trezor:wallet" + ) + var preparationStarted = false + let preparation = AsyncGate() + let payment = Task { + try await coordinator.signAndBroadcast( + manager: manager, address: "bc1qtest", sats: 42000, satsPerVByte: 2, + paykitRequestId: requestId, paykitIdentity: identity, + beforeFirstBroadcast: { + preparationStarted = true + await preparation.wait() + } + ) + } + await waitUntil { preparationStarted } + XCTAssertTrue(preparationStarted) + XCTAssertNil(coordinator.resolvePayment(resolution, identity: identity, walletId: "trezor:wallet")) + preparation.open() + await assertThrowsAsync { _ = try await payment.value } + + let unrelatedIds = [ + PaykitPaymentRequest.ID(paymentRequestId: "other", counterparty: "peer"), + PaykitPaymentRequest.ID(paymentRequestId: "request", counterparty: "other-peer"), + PaykitPaymentRequest.ID(paymentRequestId: "request", counterparty: "peer", billingPeriodStartsAt: Date()), + ] + for id in unrelatedIds { + let unrelated = PaykitOnchainPaymentResolution( + identity: identity, requestId: id, transactionId: "other-tx", walletId: "trezor:wallet" + ) + XCTAssertNil(coordinator.resolvePayment(unrelated, identity: identity, walletId: "trezor:wallet")) + } + let unrelated = PaykitOnchainPaymentResolution( + identity: otherIdentity, requestId: requestId, transactionId: "other-tx", walletId: "trezor:wallet" + ) + XCTAssertNil(coordinator.resolvePayment(unrelated, identity: otherIdentity, walletId: "trezor:wallet")) + let unrelatedWallet = PaykitOnchainPaymentResolution( + identity: identity, requestId: requestId, transactionId: "other-tx", walletId: "trezor:other" + ) + XCTAssertNil(coordinator.resolvePayment(unrelatedWallet, identity: identity, walletId: "trezor:wallet")) + XCTAssertNil(coordinator.resolvePayment(resolution, identity: otherIdentity, walletId: "trezor:wallet")) + XCTAssertNil(coordinator.resolvePayment(resolution, identity: nil, walletId: "trezor:wallet")) + XCTAssertNil(coordinator.resolvePayment(resolution, identity: identity, walletId: "other-wallet")) + XCTAssertTrue(coordinator.hasPendingBroadcast) + XCTAssertEqual(funding.broadcastCalls, 1) + XCTAssertNotNil(coordinator.resolvePayment(resolution, identity: String(identity.dropFirst(5)), walletId: "trezor:wallet")) + } + + func testHardwareResolutionCancelsRetryBeforeLateAuthorizationCanBroadcast() async { + for authorizationFails in [false, true] { + let funding = MockHwFunding() + funding.broadcastError = HwTransferError.broadcastUncertain + let manager = HwWalletManager() + let coordinator = makeCoordinator(walletId: "trezor:wallet", funding: funding, connecting: MockHwConnecting()) + let identity = "pubky" + String(repeating: "y", count: 52) + let requestId = PaykitPaymentRequest.ID(paymentRequestId: "request", counterparty: "peer") + await assertThrowsAsync { + _ = try await coordinator.signAndBroadcast( + manager: manager, address: "bc1qtest", sats: 42000, satsPerVByte: 2, + paykitRequestId: requestId, paykitIdentity: identity + ) + } + + let authorization = AsyncGate() + var authorizationStarted = false + var failureCalled = false + let retry = Task { + try await coordinator.signAndBroadcast( + manager: manager, address: "bc1qtest", sats: 42000, satsPerVByte: 2, + paykitRequestId: requestId, paykitIdentity: identity, + beforeBroadcastAttempt: { + authorizationStarted = true + await authorization.wait() + if authorizationFails { throw PaykitPaymentRequestError.requestExpired } + }, + afterFailure: { _ in failureCalled = true } + ) + } + await waitUntil { authorizationStarted } + XCTAssertTrue(authorizationStarted) + let resolution = PaykitOnchainPaymentResolution( + identity: identity, requestId: requestId, transactionId: "resolved-tx", walletId: "trezor:wallet" + ) + XCTAssertNotNil(coordinator.resolvePayment(resolution, identity: identity, walletId: "trezor:wallet")) + authorization.open() + await assertThrowsAsync { _ = try await retry.value } _: { XCTAssertTrue($0 is CancellationError) } + + XCTAssertEqual(funding.broadcastCalls, 1) + XCTAssertFalse(failureCalled) + XCTAssertFalse(coordinator.isSigning) + XCTAssertTrue(coordinator.hasPendingBroadcast) + coordinator.completeBroadcast() + XCTAssertFalse(coordinator.hasPendingBroadcast) + XCTAssertTrue(coordinator.canLeave) + } + } + + func testHardwareResolutionIgnoresLateBroadcastSuccessAndFailure() async { + for broadcastFails in [false, true] { + let funding = MockHwFunding() + let broadcast = AsyncGate() + funding.broadcastGate = broadcast + let manager = HwWalletManager() + let coordinator = makeCoordinator(walletId: "trezor:wallet", funding: funding, connecting: MockHwConnecting()) + let identity = "pubky" + String(repeating: "y", count: 52) + let requestId = PaykitPaymentRequest.ID(paymentRequestId: "request", counterparty: "peer") + var completionCalled = false + var failureCalled = false + let payment = Task { + try await coordinator.signAndBroadcast( + manager: manager, address: "bc1qtest", sats: 42000, satsPerVByte: 2, + paykitRequestId: requestId, paykitIdentity: identity, + afterBroadcast: { _ in completionCalled = true }, + afterFailure: { _ in failureCalled = true } + ) + } + await waitUntil { funding.broadcastCalls == 1 } + XCTAssertEqual(funding.broadcastCalls, 1) + let resolution = PaykitOnchainPaymentResolution( + identity: identity, requestId: requestId, transactionId: "resolved-tx", walletId: "trezor:wallet" + ) + XCTAssertNotNil(coordinator.resolvePayment(resolution, identity: identity, walletId: "trezor:wallet")) + funding.broadcastError = broadcastFails ? MockHwFunding.TestError() : nil + broadcast.open() + await assertThrowsAsync { _ = try await payment.value } _: { XCTAssertTrue($0 is CancellationError) } + + XCTAssertFalse(completionCalled) + XCTAssertFalse(failureCalled) + XCTAssertFalse(coordinator.isSigning) + XCTAssertTrue(coordinator.hasPendingBroadcast) + coordinator.completeBroadcast() + XCTAssertFalse(coordinator.hasPendingBroadcast) + XCTAssertTrue(coordinator.canLeave) + } + } + + func testConfirmedBroadcastCompletesProofBeforeReturningToCancelledCaller() async throws { + let funding = MockHwFunding() + let manager = HwWalletManager() + let coordinator = makeCoordinator(walletId: "trezor:wallet", funding: funding, connecting: MockHwConnecting()) + let completion = AsyncGate() + var completionStarted = false + var completedTransactionId: String? + let payment = Task { + try await coordinator.signAndBroadcast( + manager: manager, address: "bc1qtest", sats: 42000, satsPerVByte: 2, + afterBroadcast: { result in + completionStarted = true + await completion.wait() + completedTransactionId = result.txId + } + ) + } + await waitUntil { completionStarted } + XCTAssertTrue(completionStarted) + payment.cancel() + coordinator.cancel() + completion.open() + + let result = try await payment.value + XCTAssertEqual(completedTransactionId, result.txId) + XCTAssertEqual(result.txId, funding.broadcastTxId) + XCTAssertEqual(funding.broadcastCalls, 1) + coordinator.completeBroadcast() + } + + func testCoordinatorUnclassifiedBroadcastFailureRetainsSignedPaymentForRetry() async throws { let funding = MockHwFunding() let manager = HwWalletManager() let coordinator = HwSendCoordinator( @@ -350,26 +635,60 @@ final class HwFundingSignerTests: XCTestCase { ) } - XCTAssertFalse(coordinator.hasPendingBroadcast) + XCTAssertTrue(coordinator.hasPendingBroadcast) XCTAssertEqual(funding.broadcastCalls, 1) XCTAssertEqual(failureOutcomes, [.uncertain]) funding.broadcastError = nil - await assertThrowsAsync { - _ = try await coordinator.signAndBroadcast( - manager: manager, - address: "bc1qtest", - sats: 42000, - satsPerVByte: 2, - beforeFirstBroadcast: { throw PaykitPaymentRequestError.operationInProgress }, - afterFailure: { failureOutcomes.append($0) } - ) - } + _ = try await coordinator.signAndBroadcast( + manager: manager, + address: "bc1qtest", + sats: 42000, + satsPerVByte: 2, + beforeFirstBroadcast: { XCTFail("An uncertain attempt must not prepare another payment") }, + afterFailure: { failureOutcomes.append($0) } + ) - XCTAssertEqual(funding.broadcastCalls, 1) + XCTAssertEqual(funding.broadcastCalls, 2) + XCTAssertEqual(funding.signCalls, 1) XCTAssertEqual(failureOutcomes, [.uncertain]) } + func testCoordinatorInvalidTransactionPreservesOnlyEarlierUncertainAttempts() async { + for hadPriorAttempt in [false, true] { + let funding = MockHwFunding() + let manager = HwWalletManager() + let coordinator = HwSendCoordinator( + walletId: "trezor:wallet", + signerFactory: { [self] _, address, satsPerVByte in + makeSigner( + funding: funding, connecting: MockHwConnecting(), + feeRate: satsPerVByte, address: address + ) + } + ) + var outcomes: [PrivatePaymentListSendOutcome] = [] + if hadPriorAttempt { + funding.broadcastError = BroadcastError.ElectrumError(errorDetails: "offline") + await assertThrowsAsync { + _ = try await coordinator.signAndBroadcast( + manager: manager, address: "bc1qtest", sats: 42000, satsPerVByte: 2 + ) + } + } + funding.broadcastError = BroadcastError.InvalidTransaction(errorDetails: "invalid transaction") + await assertThrowsAsync { + _ = try await coordinator.signAndBroadcast( + manager: manager, address: "bc1qtest", sats: 42000, satsPerVByte: 2, + afterFailure: { outcomes.append($0) } + ) + } + XCTAssertEqual(outcomes, [hadPriorAttempt ? .uncertain : .definitePreBroadcastFailure]) + XCTAssertEqual(coordinator.hasPendingBroadcast, hadPriorAttempt) + XCTAssertTrue(coordinator.canLeave) + } + } + func testCoordinatorCancelDropsSignedPaymentAfterFailedBroadcast() async throws { let funding = MockHwFunding() let connecting = MockHwConnecting() diff --git a/BitkitTests/HwTransferMocks.swift b/BitkitTests/HwTransferMocks.swift index 31a4f5ebc..6f8ab6305 100644 --- a/BitkitTests/HwTransferMocks.swift +++ b/BitkitTests/HwTransferMocks.swift @@ -23,6 +23,7 @@ final class MockHwFunding: HwTransferFunding { var signGate: AsyncGate? var broadcastError: Error? var broadcastDelay: Double = 0 + var broadcastNow: () -> Date = Date.init /// Holds every broadcast until opened, so a test can act while its outcome is unknown. var broadcastGate: AsyncGate? var funding = HwFundingTransaction(psbt: "psbt", miningFeeSats: 141, feeRate: 1, totalSpent: 43186, satsPerVByte: 1) @@ -114,7 +115,8 @@ final class MockHwFunding: HwTransferFunding { return signedTx } - func broadcastFunding(serializedTx: String) async throws -> String { + func broadcastFunding(serializedTx: String, paymentDeadline: PaykitPreciseInstant? = nil) async throws -> String { + try PaykitPaymentRequest.checkPaymentDeadline(paymentDeadline, at: broadcastNow()) broadcastCalls += 1 broadcastTransactions.append(serializedTx) if let broadcastGate { diff --git a/BitkitTests/LnurlPayConfirmTests.swift b/BitkitTests/LnurlPayConfirmTests.swift new file mode 100644 index 000000000..75f06b318 --- /dev/null +++ b/BitkitTests/LnurlPayConfirmTests.swift @@ -0,0 +1,74 @@ +@testable import Bitkit +import BitkitCore +import SwiftUI +import XCTest + +@MainActor +final class LnurlPayConfirmTests: XCTestCase { + func testResetWhileMountedDoesNotCrashOrPreparePayment() async { + snapshotAppDefaultsDomain() + snapshotAppGroupDefaults("home_screen_display_currency_code_v1", "home_screen_display_currency_symbol_v1") + let app = AppViewModel() + let wallet = WalletViewModel() + let lnurlPayData = LnurlPayData( + uri: "https://example.com/pay", callback: "https://example.com/callback", + minSendable: 1_000_000, maxSendable: 5_000_000, + metadataStr: "[]", commentAllowed: nil, allowsNostr: false, nostrPubkey: nil + ) + app.lnurlPayData = lnurlPayData + var navigationPath: [SendRoute] = [] + var isSubmittingPayment = false + let view = LnurlPayConfirm( + navigationPath: Binding(get: { navigationPath }, set: { navigationPath = $0 }), + isSubmittingPayment: Binding(get: { isSubmittingPayment }, set: { isSubmittingPayment = $0 }), + requestPinCheck: { + XCTFail("Rendering confirmation must not request payment authorization") + return false + }, + prepareIncomingPaymentRequest: { + XCTFail("Rendering confirmation must not prepare a payment") + throw CancellationError() + }, + routingCacheResetAttempted: false + ) + .environment(PaykitPaymentRequestManager()) + .environmentObject(app) + .environmentObject(wallet) + .environmentObject(SheetViewModel()) + .environmentObject(CurrencyViewModel(currencyService: OfflineCurrencyService())) + .environmentObject(SettingsViewModel.shared) + .environmentObject(ContactsManager()) + let hostingController = UIHostingController(rootView: view) + let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 390, height: 844)) + window.rootViewController = hostingController + window.makeKeyAndVisible() + defer { + window.isHidden = true + window.rootViewController = nil + } + + hostingController.view.layoutIfNeeded() + XCTAssertGreaterThan(hostingController.sizeThatFits(in: window.bounds.size).height, 0) + await Task.yield() + + // Retry clears the send state before fresh payment details arrive or the sheet is dismissed. + app.resetSendState() + wallet.resetSendState(speed: SettingsViewModel.shared.defaultTransactionSpeed) + XCTAssertNil(app.lnurlPayData) + await Task.yield() + hostingController.view.setNeedsLayout() + hostingController.view.layoutIfNeeded() + _ = hostingController.sizeThatFits(in: window.bounds.size) + XCTAssertNotNil(hostingController.view.window) + XCTAssertTrue(navigationPath.isEmpty) + XCTAssertFalse(isSubmittingPayment) + + app.lnurlPayData = lnurlPayData + await Task.yield() + hostingController.view.setNeedsLayout() + hostingController.view.layoutIfNeeded() + XCTAssertGreaterThan(hostingController.sizeThatFits(in: window.bounds.size).height, 0) + XCTAssertTrue(navigationPath.isEmpty) + XCTAssertFalse(isSubmittingPayment) + } +} diff --git a/BitkitTests/PaykitBackupStateTrackingTests.swift b/BitkitTests/PaykitBackupStateTrackingTests.swift index 17c096bcb..0d9e0fd1c 100644 --- a/BitkitTests/PaykitBackupStateTrackingTests.swift +++ b/BitkitTests/PaykitBackupStateTrackingTests.swift @@ -3,7 +3,7 @@ import XCTest @MainActor final class PaykitBackupStateTrackingTests: XCTestCase { - private enum Failure: Error, Equatable { case revision, operation } + private enum Failure: Error, Equatable { case revision, admission, operation } func testBackupDecisionUsesContentAndTreatsUnreadableRevisionsConservatively() async throws { let cases: [(String?, String?, Int)] = [ @@ -49,30 +49,130 @@ final class PaykitBackupStateTrackingTests: XCTestCase { XCTAssertEqual(changes, 1) } + func testRejectedAdmissionDoesNotRunOrInvalidateBackup() async { + var reads = 0 + do { + try await PaykitSdkService.withBackupStateRevisionTracking( + readRevision: { reads += 1; return "before" }, + onSnapshot: { _ in XCTFail("Admission failure must not replace the backup snapshot") }, + onChange: { XCTFail("Admission failure must not request a backup") }, + beforeOperation: { throw Failure.admission }, + operation: { XCTFail("The write must not run after admission failed") } + ) + XCTFail("Expected admission failure") + } catch { + XCTAssertEqual(error as? Failure, .admission) + } + XCTAssertEqual(reads, 1) + } + func testCancellationAfterMutationStillRequestsBackup() async { - var revision = "before" + for throwsCancellation in [false, true] { + var revision = "before" + var snapshot: PaykitSdkService.BackupStateSnapshot? = .init(stateRevision: "state", backupRevision: revision) + var reads = 0 + var changes = 0 + let task = Task { + try await PaykitSdkService.withBackupStateRevisionTracking( + readRevision: { reads += 1; return revision }, + readStateRevision: { "state" }, + readObservedSnapshot: { .init(stateRevision: "state", backupRevision: revision) }, + cachedSnapshot: snapshot, + onSnapshot: { snapshot = $0 }, + onChange: { changes += 1 }, + operation: { + revision = "after" + withUnsafeCurrentTask { $0?.cancel() } + if throwsCancellation { try Task.checkCancellation() } + } + ) + } + do { + try await task.value + XCTAssertFalse(throwsCancellation) + } catch { + XCTAssertTrue(throwsCancellation) + XCTAssertTrue(error is CancellationError) + } + XCTAssertEqual(revision, "after") + XCTAssertEqual(changes, 1) + XCTAssertEqual(reads, 0) + XCTAssertNil(snapshot) + } + } + + func testUnchangedOperationsReuseBackupFingerprintWithoutRemoteReads() async throws { + var snapshot: PaykitSdkService.BackupStateSnapshot? + var state = "initial" + var reads = 0 var changes = 0 - let task = Task { + for index in 0 ..< 3 { try await PaykitSdkService.withBackupStateRevisionTracking( - readRevision: { - try Task.checkCancellation() - return revision + readRevision: { reads += 1; return "content" }, + readStateRevision: { state }, + readObservedSnapshot: { .init(stateRevision: state, backupRevision: "content") }, + cachedSnapshot: snapshot, + onSnapshot: { snapshot = $0 }, + onChange: { changes += 1 }, + operation: { state = "lease-\(index)" } + ) + } + XCTAssertEqual(reads, 1) + XCTAssertEqual(changes, 0) + XCTAssertEqual(snapshot?.stateRevision, "lease-2") + XCTAssertEqual(snapshot?.backupRevision, "content") + } + + func testObservedRevisionsCompareWithCachedBackupContent() async throws { + for (observedState, finalContent, expectedReads, expectedChanges) in [ + ("current", "content", 0, 0), + ("current", "changed", 0, 1), + ("unavailable", "changed", 1, 1), + ("stale", "changed", 1, 1), + ("unreadable", "changed", 1, 1), + ] { + var reads = 0 + var changes = 0 + var snapshot: PaykitSdkService.BackupStateSnapshot? + try await PaykitSdkService.withBackupStateRevisionTracking( + readRevision: { reads += 1; return finalContent }, + readStateRevision: { "current" }, + readObservedSnapshot: { + if observedState == "unavailable" { return nil } + if observedState == "unreadable" { throw Failure.revision } + return .init(stateRevision: observedState, backupRevision: finalContent) }, + cachedSnapshot: .init(stateRevision: observedState == "current" ? "previous" : "current", backupRevision: "content"), + onSnapshot: { snapshot = $0 }, onChange: { changes += 1 }, - operation: { - revision = "after" - withUnsafeCurrentTask { $0?.cancel() } - try Task.checkCancellation() - } + operation: {} ) + XCTAssertEqual(reads, expectedReads) + XCTAssertEqual(changes, expectedChanges) + XCTAssertEqual(snapshot?.stateRevision, "current") + 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 task.value - XCTFail("Expected cancellation") + 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 { - XCTAssertTrue(error is CancellationError) + XCTAssertEqual(error as? Failure, .operation) } - XCTAssertEqual(revision, "after") + XCTAssertEqual(reads, 0) XCTAssertEqual(changes, 1) + XCTAssertNil(snapshot) } } diff --git a/BitkitTests/PaykitContactLifecycleTests.swift b/BitkitTests/PaykitContactLifecycleTests.swift index 474f9ee26..8685017e4 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, @@ -50,6 +71,8 @@ final class PaykitContactLifecycleTests: XCTestCase { } catch let PubkyServiceError.activeSubscription(endsAt) { XCTAssertEqual(endsAt, testCase.endsAt.flatMap(PaykitPaymentRequest.parseDate)) } + let skipped = try await service.removeContacts(publicKeys: [sdk.publicKey]) + XCTAssertTrue(skipped.isEmpty) XCTAssertNotNil(sdk.record) XCTAssertTrue(sdk.events.isEmpty) if testCase.endsAt != nil { @@ -59,6 +82,10 @@ final class PaykitContactLifecycleTests: XCTestCase { } _ = try await service.removeContact(publicKey: sdk.publicKey) XCTAssertNil(sdk.record) + _ = try await sdk.saveContact(update: ContactUpdate(publicKey: sdk.publicKey, label: "Contact")) + let removed = try await service.removeContacts(publicKeys: [sdk.publicKey]) + XCTAssertEqual(removed.map(\.publicKey), [sdk.publicKey]) + XCTAssertNil(sdk.record) } } @@ -72,14 +99,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 +152,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) @@ -133,45 +162,214 @@ final class PaykitContactLifecycleTests: XCTestCase { } catch {} XCTAssertNil(sdk.record) XCTAssertTrue(sdk.peers.allSatisfy { $0.state == .blocked }) - _ = try await service.saveContact(publicKey: sdk.publicKey, label: "Readded", restorePrivateConnection: true) - XCTAssertTrue(sdk.peers.allSatisfy { $0.state == .notLinked }) + var unselectedPeer = try XCTUnwrap(sdk.peers.first) + unselectedPeer.counterparty = "pubky" + String(repeating: "z", count: 52) + sdk.peers.append(unselectedPeer) + _ = try await service.saveContacts(updates: [ContactUpdate(publicKey: sdk.publicKey, label: "Readded")]) + XCTAssertEqual(sdk.peers.first?.state, .notLinked) + XCTAssertEqual(sdk.peers.last?.state, .blocked) XCTAssertEqual(sdk.record?.label, "Readded") } func testFailedPrivateConnectionRestoreCanBeRetriedWithoutASavedContact() async throws { - let failures: [(peerLookup: Bool, unblockPath: String?, saveContact: Bool)] = [ - (true, nil, false), - (false, PaykitReceiverPath.server, false), - (false, nil, true), - ] - for failure in failures { + for bulk in [false, true] { let sdk = ContactLifecycleSdk(noPointer: .init()) let service = PaykitSdkService(sdkFactory: { sdk }) _ = try await service.removeContact(publicKey: sdk.publicKey) - sdk.failLinkedPeers = failure.peerLookup - sdk.failUnblockPath = failure.unblockPath - sdk.failSaveContact = failure.saveContact + sdk.restoreError = PubkyServiceError.profileNotFound + sdk.events.removeAll() + let save = { + if bulk { + _ = try await service.saveContacts(updates: [ContactUpdate(publicKey: sdk.publicKey, label: "Contact")]) + } else { + _ = try await service.saveContact(publicKey: sdk.publicKey, label: "Contact", restorePrivateConnection: true) + } + } do { - _ = try await service.saveContact(publicKey: sdk.publicKey, label: "Contact", restorePrivateConnection: true) + try await save() XCTFail("Expected restoration to fail") } catch {} XCTAssertNil(sdk.record) XCTAssertTrue(sdk.peers.allSatisfy { $0.state == .blocked }) - sdk.failLinkedPeers = false - sdk.failUnblockPath = nil - sdk.failSaveContact = false - _ = try await service.saveContact(publicKey: sdk.publicKey, label: "Contact", restorePrivateConnection: true) + XCTAssertEqual(sdk.events, ["save-and-unblock"]) + sdk.restoreError = nil + try await save() XCTAssertNotNil(sdk.record) XCTAssertTrue(sdk.peers.allSatisfy { $0.state == .notLinked }) + XCTAssertEqual(sdk.events, ["save-and-unblock", "save-and-unblock"]) } } + func testLabelUpdateDoesNotUnblockAnExistingContact() async throws { + let sdk = ContactLifecycleSdk(noPointer: .init()) + sdk.peers[0].state = .blocked + let service = PaykitSdkService(sdkFactory: { sdk }) + + let saved = try await service.saveContact(publicKey: sdk.publicKey, label: "Updated") + + XCTAssertEqual(saved.label, "Updated") + XCTAssertEqual(sdk.peers[0].state, .blocked) + XCTAssertEqual(sdk.events, ["save"]) + XCTAssertEqual(sdk.contactReads, 1) + XCTAssertEqual(sdk.peerReads, 0) + XCTAssertTrue(sdk.restoredBatches.isEmpty) + } + + func testExplicitReaddUsesAtomicSaveWithoutContactOrPeerPreflight() async throws { + let sdk = ContactLifecycleSdk(noPointer: .init()) + sdk.record = nil + sdk.peers[0].state = .blocked + let service = PaykitSdkService(sdkFactory: { sdk }) + + let saved = try await service.saveContact(publicKey: sdk.publicKey, label: "Readded", restorePrivateConnection: true) + + XCTAssertEqual(saved.publicKey, sdk.publicKey) + XCTAssertEqual(saved.label, "Readded") + XCTAssertEqual(sdk.peers[0].state, .notLinked) + XCTAssertEqual(sdk.events, ["save-and-unblock"]) + XCTAssertEqual(sdk.restoredBatches.map { $0.map(\.publicKey) }, [[sdk.publicKey]]) + XCTAssertEqual(sdk.contactReads, 0) + XCTAssertEqual(sdk.peerReads, 0) + } + + func testBulkReaddForwardsOneAtomicSaveWithoutPreflight() async throws { + let sdk = ContactLifecycleSdk(noPointer: .init()) + let updates = [ + ContactUpdate(publicKey: sdk.publicKey, label: "Alice"), + ContactUpdate(publicKey: "pubky5rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg", label: "Bob"), + ] + let service = PaykitSdkService(sdkFactory: { sdk }) + + let saved = try await service.saveContacts(updates: updates) + + XCTAssertEqual(saved.map(\.publicKey), updates.map(\.publicKey)) + XCTAssertEqual(saved.map(\.label), updates.map(\.label)) + XCTAssertEqual(sdk.restoredBatches.map { $0.map(\.publicKey) }, [updates.map(\.publicKey)]) + XCTAssertEqual(sdk.events, ["save-and-unblock"]) + XCTAssertEqual(sdk.contactReads, 0) + XCTAssertEqual(sdk.peerReads, 0) + } + + func testEmptyImportDoesNotAccessSdk() async throws { + let service = PaykitSdkService(sdkFactory: { + XCTFail("An empty import must not access the SDK") + return ContactLifecycleSdk(noPointer: .init()) + }) + + let saved = try await service.saveContacts(updates: []) + + XCTAssertTrue(saved.isEmpty) + } + + func testImportCancelledAfterAtomicCommitDoesNotPublishOrReblockContacts() async throws { + let sdk = ContactLifecycleSdk(noPointer: .init()) + sdk.record = nil + sdk.peers[0].state = .blocked + sdk.cancelAfterRestore = true + let service = PaykitSdkService(sdkFactory: { sdk }) + let manager = ContactsManager() + let contact = Bitkit.PubkyContact( + publicKey: sdk.publicKey, + profile: Bitkit.PubkyProfile(publicKey: sdk.publicKey, name: "Readded", bio: "", imageUrl: nil, links: [], status: nil) + ) + + let importTask = Task { + try await manager.importContacts(contacts: [contact]) { updates, identity in + _ = try await service.saveContacts(updates: updates, expectedIdentity: identity) + } + } + do { + try await importTask.value + XCTFail("Expected cancellation to discard the import result") + } catch is CancellationError {} + + XCTAssertTrue(manager.contacts.isEmpty) + XCTAssertEqual(sdk.record?.label, "Readded") + XCTAssertEqual(sdk.peers[0].state, .notLinked) + XCTAssertEqual(sdk.events, ["save-and-unblock"]) + } + func testBlockedPeerCleanupDoesNotAttemptNetworkDelivery() async throws { 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) } } @@ -181,59 +379,123 @@ private final class ContactLifecycleSdk: PaykitSdk, @unchecked Sendable { var requests: [PaymentRequestRecord] = [] var failWithdrawal = false var failBlock = false - var failLinkedPeers = false - var failUnblockPath: String? - var failSaveContact = false + var failRecovery = false + var restoreError: Error? + var cancelAfterRestore = false + var restoredBatches: [[ContactUpdate]] = [] + var contactReads = 0 + var peerReads = 0 + var identityReads = 0 + var registryReads = 0 + 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 observedBackupStateRevision() throws -> ObservedBackupStateRevision? { + nil + } + + override func stateRevision() throws -> String? { + nil + } + override func paymentRequests() async throws -> [PaymentRequestRecord] { requests } override func contactRecord(publicKey: String) async throws -> ContactRecord? { - record + contactReads += 1 + return record } override func linkedPeers() async throws -> [LinkedPeerRecord] { - if failLinkedPeers { throw PubkyServiceError.profileNotFound } + peerReads += 1 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 { + XCTFail("Explicit re-add must save and unblock atomically") + let index = try XCTUnwrap(peers.firstIndex { PubkyPublicKeyFormat.matches($0.counterparty, counterparty) }) peers[index].state = .notLinked return peers[index] } @@ -244,17 +506,47 @@ private final class ContactLifecycleSdk: PaykitSdk, @unchecked Sendable { return record } + override func removeContactsAndBlockPeers(publicKeys: [String]) async throws -> [ContactRecord] { + guard publicKeys.contains(publicKey), let record else { return [] } + events.append("block-and-remove") + self.record = nil + peers[0].state = .blocked + return [record] + } + override func saveContact(update: ContactUpdate) async throws -> ContactRecord { - if failSaveContact { - throw PubkyServiceError.profileNotFound + events.append("save") + let saved = Self.contactRecord(update: update) + record = saved + return saved + } + + override func saveContactsAndUnblockPeers(updates: [ContactUpdate]) async throws -> [ContactRecord] { + events.append("save-and-unblock") + restoredBatches.append(updates) + if let restoreError { throw restoreError } + let saved = updates.map(Self.contactRecord) + for index in peers.indices where peers[index].state == .blocked { + if updates.contains(where: { PubkyPublicKeyFormat.matches($0.publicKey, peers[index].counterparty) }) { + peers[index].state = .notLinked + } } - let saved = ContactRecord( - publicKey: update.publicKey, receiverPaths: update.receiverPaths, label: update.label, profile: nil, + record = saved.last ?? record + if cancelAfterRestore { withUnsafeCurrentTask { $0?.cancel() } } + return saved + } + + private static func contactRecord(update: ContactUpdate) -> ContactRecord { + ContactRecord( + publicKey: update.publicKey, label: update.label, profile: nil, profileFetchedAt: nil, createdAt: "2026-09-29T00:00:00Z", updatedAt: "2026-09-29T00:00:00Z", - publicContactMarkerStatus: .notPublished, publicContactMarkerReceiverPath: nil, + publicContactMarkerStatus: .notPublished, publicContactPublishedAt: nil, publicContactRemovedAt: nil, publicContactLastError: nil ) - record = saved - return saved + } + + override func saveContacts(updates: [ContactUpdate]) async throws -> [ContactRecord] { + XCTFail("Explicit re-add must save and unblock atomically") + return updates.map(Self.contactRecord) } } diff --git a/BitkitTests/PaykitIssuerInteropTests.swift b/BitkitTests/PaykitIssuerInteropTests.swift index f04ed035e..0754b5bfc 100644 --- a/BitkitTests/PaykitIssuerInteropTests.swift +++ b/BitkitTests/PaykitIssuerInteropTests.swift @@ -10,7 +10,7 @@ final class PaykitIssuerInteropTests: XCTestCase { XCTAssertEqual(fixtures.schemaVersion, 1) for fixture in fixtures.requestFixtures { - let record = try paymentRequestRecord( + var record = try paymentRequestRecord( asset: fixture.asset, endpointIdentifiers: fixture.acceptedPaymentEndpointIdentifiers ) @@ -18,6 +18,10 @@ final class PaykitIssuerInteropTests: XCTestCase { XCTAssertEqual(request != nil, fixture.accepted, fixture.name) XCTAssertEqual(request?.acceptedPaymentEndpointIdentifiers ?? [], fixture.expectedIdentifiers, fixture.name) + record.terms?.paymentDeadline = .at(timestamp: "2099-01-01T00:00:00.123456789Z") + let deadlineRequest = PaykitPaymentRequest(record: record, now: Date(), network: fixture.network.ldkNetwork) + XCTAssertEqual(deadlineRequest != nil, fixture.accepted, fixture.name) + XCTAssertEqual(deadlineRequest?.acceptedPaymentEndpointIdentifiers ?? [], fixture.expectedIdentifiers, fixture.name) } } @@ -88,7 +92,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 +99,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/PaykitPaymentActivityTests.swift b/BitkitTests/PaykitPaymentActivityTests.swift new file mode 100644 index 000000000..ba6ca2791 --- /dev/null +++ b/BitkitTests/PaykitPaymentActivityTests.swift @@ -0,0 +1,63 @@ +@testable import Bitkit +import XCTest + +@MainActor +final class PaykitPaymentActivityTests: XCTestCase { + func testDeferredProofRefreshCoalescesUntilEveryPaymentEnds() async { + let activity = PaykitPaymentActivity() + let first = activity.begin() + let second = activity.begin() + var refreshes: [Int] = [] + activity.runWhenIdle(.proofRefresh) { refreshes.append(1) } + let refresh = activity.runWhenIdle(.proofRefresh) { refreshes.append(2) } + await Task.yield() + XCTAssertTrue(refreshes.isEmpty) + + activity.end(first) + await Task.yield() + XCTAssertTrue(activity.isActive) + XCTAssertTrue(refreshes.isEmpty) + + activity.end(second) + await refresh.value + XCTAssertFalse(activity.isActive) + XCTAssertEqual(refreshes, [2]) + } + + func testCancelledIdleWaitDoesNotLoseLaterWork() async throws { + let activity = PaykitPaymentActivity() + let payment = activity.begin() + let wait = Task { try await activity.waitUntilIdle() } + await Task.yield() + wait.cancel() + do { + try await wait.value + XCTFail("Cancelled work must not run") + } catch is CancellationError {} + + var didRun = false + let pending = activity.runWhenIdle(.proofRefresh) { didRun = true } + activity.end(payment) + await pending.value + XCTAssertTrue(didRun) + } + + func testAnotherPaymentStartingBeforeWaiterResumesKeepsWorkDeferred() async throws { + let activity = PaykitPaymentActivity() + let first = activity.begin() + var didRun = false + let work = Task { + try await activity.waitUntilIdle() + didRun = true + } + await Task.yield() + activity.end(first) + let second = activity.begin() + await Task.yield() + XCTAssertFalse(didRun) + + activity.end(second) + try await work.value + XCTAssertTrue(didRun) + } +} diff --git a/BitkitTests/PaykitPaymentProofServiceTests.swift b/BitkitTests/PaykitPaymentProofServiceTests.swift index b7b7ddd4a..d91d8472b 100644 --- a/BitkitTests/PaykitPaymentProofServiceTests.swift +++ b/BitkitTests/PaykitPaymentProofServiceTests.swift @@ -1,4 +1,5 @@ @testable import Bitkit +import BitkitCore import LDKNode import Paykit import XCTest @@ -53,9 +54,12 @@ final class PaykitPaymentProofServiceTests: XCTestCase { } } - func testCompletedLightningPaymentRetriesAfterRestart() async throws { - let record = try paymentRequestRecord() - let request = try XCTUnwrap(PaykitPaymentRequest(record: record, now: Date())) + func testCompletedLightningPaymentRetriesAfterRestartPastPaymentDeadline() async throws { + var record = try paymentRequestRecord() + let deadline = Date().addingTimeInterval(-60) + record.terms?.paymentDeadline = .at(timestamp: ISO8601DateFormatter().string(from: deadline)) + let request = try XCTUnwrap(PaykitPaymentRequest(record: record, now: deadline.addingTimeInterval(-1))) + XCTAssertTrue(request.isPaymentDeadlineExpired(at: Date())) let store = PaymentProofMemoryStore() let sdk = PaymentProofSdkMock(identity: identity, records: [record]) await sdk.setSubmissionFailure(true) @@ -63,6 +67,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 +112,7 @@ final class PaykitPaymentProofServiceTests: XCTestCase { try await service.prepare( request: request, + paymentAppId: "bitkit", paymentEndpointIdentifier: PublicPaykitService.MethodId.bitcoinLightningBolt11.rawValue, kind: .lightning ) @@ -133,6 +139,7 @@ final class PaykitPaymentProofServiceTests: XCTestCase { try await service.prepare( request: request, + paymentAppId: "bitkit", paymentEndpointIdentifier: PublicPaykitService.MethodId.bitcoinLightningBolt11.rawValue, kind: .lightning ) @@ -154,6 +161,7 @@ final class PaykitPaymentProofServiceTests: XCTestCase { try await service.prepare( request: request, + paymentAppId: "bitkit", paymentEndpointIdentifier: PublicPaykitService.MethodId.bitcoinLightningBolt11.rawValue, kind: .lightning ) @@ -175,6 +183,7 @@ final class PaykitPaymentProofServiceTests: XCTestCase { try await service.prepare( request: request, + paymentAppId: "bitkit", paymentEndpointIdentifier: PublicPaykitService.MethodId.bitcoinLightningBolt11.rawValue, kind: .lightning ) @@ -207,6 +216,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 ) @@ -233,6 +243,8 @@ final class PaykitPaymentProofServiceTests: XCTestCase { func testDefiniteLightningSubmissionFailureClearsProof() async throws { let errors: [Error] = [ + PaykitPaymentRequestError.requestExpired, + Bitkit.AppError(error: PaykitPaymentRequestError.requestExpired), Bitkit.CustomServiceError.nodeNotSetup, Bitkit.AppError(serviceError: .nodeNotStarted), NodeError.NotRunning(message: "stopped"), @@ -248,6 +260,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 +294,7 @@ final class PaykitPaymentProofServiceTests: XCTestCase { try await service.prepare( request: request, + paymentAppId: "bitkit", paymentEndpointIdentifier: PublicPaykitService.MethodId.bitcoinLightningBolt11.rawValue, kind: .lightning ) @@ -312,7 +326,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 +346,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 +360,7 @@ final class PaykitPaymentProofServiceTests: XCTestCase { try await service.prepare( request: request, + paymentAppId: "bitkit", paymentEndpointIdentifier: PublicPaykitService.MethodId.bitcoinLightningBolt11.rawValue, kind: .lightning ) @@ -368,16 +384,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 +414,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 +433,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 +459,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) @@ -448,22 +484,33 @@ final class PaykitPaymentProofServiceTests: XCTestCase { let sdk = PaymentProofSdkMock(identity: identity, records: [record]) let txid = String(repeating: "ab", count: 32) let service = paymentProofService(sdk: sdk, store: store, onchainTxids: [txid]) + let app = AppViewModel() + app.contactPaymentContext = ContactPaymentContext(publicKey: request.counterparty, incomingPaymentRequest: request) let resolutionExpectation = expectation(description: "On-chain payment resolution published") let resolution = PaykitPaymentProofService.onchainPaymentResolutionPublisher .filter { $0.requestId == request.id } .first() - .sink { - XCTAssertEqual($0.identity, self.identity) - XCTAssertEqual($0.transactionId, txid) - resolutionExpectation.fulfill() + .sink { resolution in + Task { @MainActor in + XCTAssertEqual(resolution.identity, self.identity) + XCTAssertEqual(resolution.transactionId, txid) + app.retainPaykitOnchainPaymentResolution(resolution, identity: self.identity) + await service.consumeOnchainPaymentResolution(resolution) + 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) + let unresolvedProofs = await store.snapshot() + XCTAssertTrue(try XCTUnwrap(unresolvedProofs.first).paymentStarted) + XCTAssertNil(unresolvedProofs.first?.proofData) await service.reconcile() await sdk.waitForSubmissionStart() await fulfillment(of: [resolutionExpectation, proofRemoved], timeout: 1) withExtendedLifetime(resolution) {} + XCTAssertEqual(app.paykitOnchainPaymentResolution?.requestId, request.id) + XCTAssertEqual(app.paykitOnchainPaymentResolution?.transactionId, txid) let submittedProof = await sdk.lastSubmission() let submission = try XCTUnwrap(submittedProof) @@ -490,7 +537,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() @@ -502,6 +549,153 @@ final class PaykitPaymentProofServiceTests: XCTestCase { XCTAssertEqual(storedProof.onchainMatchingTransactionIdsBeforeAttempt, [oldTransactionId]) } + func testHardwareProofReconcilesFromWalletActivityAfterBackupRestore() async throws { + try await withHardwareProofWallet { walletId in + let endpoint = PublicPaykitService.MethodId.regtestOnchainP2wpkh.rawValue + var record = try paymentRequestRecord(endpoints: [endpoint]) + let deadline = Date().addingTimeInterval(-60) + record.terms?.paymentDeadline = .at(timestamp: ISO8601DateFormatter().string(from: deadline)) + let request = try XCTUnwrap(PaykitPaymentRequest(record: record, now: deadline.addingTimeInterval(-1))) + let oldTxid = String(repeating: "ab", count: 32) + let txid = String(repeating: "cd", count: 32) + try await storeHardwareProofTransaction(walletId: walletId, txid: oldTxid) + let store = PaymentProofMemoryStore() + let sdk = PaymentProofSdkMock(identity: identity, records: [record]) + await sdk.setSubmissionFailure(true) + let service = PaykitPaymentProofService(sdk: sdk, store: store) + + try await service.prepare( + request: request, paymentAppId: "bitkit", paymentEndpointIdentifier: endpoint, kind: .onchain, walletId: walletId + ) + try await service.markOnchainPaymentStarted(request, address: onchainAddress, walletId: walletId) + let prepared = await store.snapshot() + XCTAssertEqual(prepared.first?.onchainWalletId, walletId) + XCTAssertEqual(prepared.first?.onchainMatchingTransactionIdsBeforeAttempt, [oldTxid]) + XCTAssertNil(prepared.first?.proofData) + await service.cancelPreparation(request, walletId: walletId) + let afterDismissal = await store.snapshot() + XCTAssertEqual(afterDismissal, prepared) + + let backup = try await service.backupSnapshot() + let restoredBackup = try JSONDecoder().decode([PaykitPaymentStateBackup.Proof].self, from: JSONEncoder().encode(backup)) + let restoredStore = PaymentProofMemoryStore() + let restoredService = PaykitPaymentProofService(sdk: sdk, store: restoredStore) + try await restoredService.restoreBackup(restoredBackup) + let restored = await restoredStore.snapshot() + XCTAssertEqual(restored, prepared) + + try await storeHardwareProofTransaction(walletId: "trezor:other", txid: txid) + await restoredService.reconcile() + let beforeMatchingActivity = await restoredStore.snapshot() + XCTAssertEqual(beforeMatchingActivity, prepared) + let inFlightRequests = await restoredService.inFlightRequestIds(identity: identity) + XCTAssertEqual(inFlightRequests, [request.id]) + let submissionsBeforeMatchingActivity = await sdk.submissionCount() + XCTAssertEqual(submissionsBeforeMatchingActivity, 0) + + let app = AppViewModel() + app.contactPaymentContext = ContactPaymentContext(publicKey: counterparty, incomingPaymentRequest: request) + let delivered = expectation(description: "Wallet-scoped resolution retained before source consumption") + let subscriber = PaykitPaymentProofService.onchainPaymentResolutionPublisher + .filter { $0.requestId == request.id && $0.walletId == walletId } + .prefix(1) + .sink { [identity] resolution in + Task { @MainActor in + app.retainPaykitOnchainPaymentResolution(resolution, identity: identity) + await restoredService.consumeOnchainPaymentResolution(resolution) + delivered.fulfill() + } + } + try await storeHardwareProofTransaction(walletId: walletId, txid: txid) + await restoredService.reconcile() + await sdk.waitForSubmissionStart() + await fulfillment(of: [delivered], timeout: 2) + withExtendedLifetime(subscriber) {} + let completed = await restoredStore.snapshot() + XCTAssertEqual(completed.first?.proofData, txid) + XCTAssertEqual(completed.first?.onchainWalletId, walletId) + XCTAssertEqual(app.paykitOnchainPaymentResolution?.transactionId, txid) + XCTAssertEqual(app.paykitOnchainPaymentResolution?.walletId, walletId) + XCTAssertTrue(request.isPaymentDeadlineExpired(at: Date())) + } + } + + func testDefaultHardwareProofLookupRequiresWalletDirectionAndExactOutput() async throws { + try await withHardwareProofWallet { walletId in + let lookup = PaykitOnchainPaymentProofLookup() + let txid = String(repeating: "ab", count: 32) + try await storeHardwareProofTransaction(walletId: "trezor:other", txid: txid) + try await storeHardwareProofTransaction(walletId: walletId, txid: txid, outputAddress: "different-address") + try await storeHardwareProofTransaction(walletId: walletId, txid: "wrong-amount", outputAmount: 999) + try await storeHardwareProofTransaction(walletId: walletId, txid: "received", txType: .received) + try await storeHardwareProofTransaction(walletId: walletId, txid: "removed", doesExist: false) + let unmatched = try await lookup.existingTransactionIds(address: onchainAddress, amountSats: 1000, walletId: walletId) + XCTAssertTrue(unmatched.isEmpty) + + try await storeHardwareProofTransaction(walletId: walletId, txid: txid) + let matching = try await lookup.existingTransactionIds(address: onchainAddress, amountSats: 1000, walletId: walletId) + XCTAssertEqual(matching, [txid]) + let excluded = try await lookup.transactionId(address: onchainAddress, amountSats: 1000, walletId: walletId, excluding: [txid]) + XCTAssertNil(excluded) + HwKnownDeviceStorage.remove(walletId: walletId) + XCTAssertFalse(lookup.supportsWallet(walletId)) + do { + _ = try await lookup.transactionId(address: onchainAddress, amountSats: 1000, walletId: walletId, excluding: []) + XCTFail("An unpaired wallet must not resolve through another wallet's activity") + } catch let error as PaykitPaymentRequestError { + XCTAssertEqual(error, .requestUnavailable) + } + } + } + + private func withHardwareProofWallet(_ body: (String) async throws -> Void) async throws { + let walletId = "trezor:proof-\(UUID().uuidString)" + let directory = FileManager.default.temporaryDirectory.appendingPathComponent("HardwareProof-\(UUID().uuidString)") + _ = CoreService.shared + await drainCoreServiceQueue() + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + _ = try initDb(basePath: directory.path) + HwKnownDeviceStorage.save(HwKnownDevice( + id: walletId, name: "Proof wallet", path: "", transportType: "usb", lastConnectedAt: Date(), walletId: walletId + )) + do { + try await body(walletId) + } catch { + HwKnownDeviceStorage.remove(walletId: walletId) + await repointCoreToAppStorage() + try? FileManager.default.removeItem(at: directory) + throw error + } + HwKnownDeviceStorage.remove(walletId: walletId) + await repointCoreToAppStorage() + try FileManager.default.removeItem(at: directory) + } + + private func storeHardwareProofTransaction( + walletId: String, + txid: String, + outputAddress: String? = nil, + outputAmount: Int64 = 1000, + txType: PaymentType = .sent, + doesExist: Bool = true + ) async throws { + let activity = OnchainActivity( + walletId: walletId, id: txid, txType: txType, txId: txid, value: 1000, fee: 100, feeRate: 1, + address: onchainAddress, confirmed: false, timestamp: 1_700_000_000, isBoosted: false, boostTxIds: [], + isTransfer: false, doesExist: doesExist, confirmTimestamp: nil, channelId: nil, transferTxId: nil, + contact: nil, createdAt: nil, updatedAt: nil, seenAt: nil + ) + let details = BitkitCore.TransactionDetails( + walletId: walletId, txId: txid, amountSats: 1000, inputs: [], + outputs: [BitkitCore.TxOutput( + scriptpubkey: "", scriptpubkeyType: "v0_p2wpkh", scriptpubkeyAddress: outputAddress ?? onchainAddress, value: outputAmount, n: 0 + )] + ) + try await CoreService.shared.activity.replaceHwSnapshot( + walletId: walletId, activities: [.onchain(activity)], transactionDetails: [details], pruneMissing: false + ) + } + func testForeignWalletOnchainProofStaysInertAndBlocksDuplicatePayment() async throws { let endpoint = PublicPaykitService.MethodId.regtestOnchainP2wpkh.rawValue let record = try paymentRequestRecord(endpoints: [endpoint]) @@ -509,6 +703,7 @@ final class PaykitPaymentProofServiceTests: XCTestCase { let proof = PendingPaykitPaymentProof( identity: identity, requestId: request.id, + paymentAppId: "bitkit", paymentEndpointIdentifier: endpoint, kind: .onchain, paymentStarted: true, @@ -532,12 +727,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 +761,7 @@ final class PaykitPaymentProofServiceTests: XCTestCase { let proof = PendingPaykitPaymentProof( identity: identity, requestId: request.id, + paymentAppId: "bitkit", paymentEndpointIdentifier: endpoint, kind: .onchain, paymentStarted: true, @@ -608,6 +805,7 @@ final class PaykitPaymentProofServiceTests: XCTestCase { let proof = PendingPaykitPaymentProof( identity: identity, requestId: request.id, + paymentAppId: "bitkit", paymentEndpointIdentifier: endpoint, kind: .onchain, paymentStarted: true, @@ -659,6 +857,7 @@ final class PaykitPaymentProofServiceTests: XCTestCase { let proof = PendingPaykitPaymentProof( identity: identity, requestId: request.id, + paymentAppId: "bitkit", paymentEndpointIdentifier: endpoint, kind: .onchain, billingPeriod: request.billingPeriod, @@ -694,6 +893,7 @@ final class PaykitPaymentProofServiceTests: XCTestCase { let originalProof = PendingPaykitPaymentProof( identity: identity, requestId: request.id, + paymentAppId: "bitkit", paymentEndpointIdentifier: endpoint, kind: .onchain, paymentStarted: true, @@ -704,6 +904,7 @@ final class PaykitPaymentProofServiceTests: XCTestCase { let replacementProof = PendingPaykitPaymentProof( identity: identity, requestId: request.id, + paymentAppId: "bitkit", paymentEndpointIdentifier: endpoint, kind: .onchain, paymentStarted: true, @@ -735,6 +936,7 @@ final class PaykitPaymentProofServiceTests: XCTestCase { let proof = PendingPaykitPaymentProof( identity: identity, requestId: request.id, + paymentAppId: "bitkit", paymentEndpointIdentifier: endpoint, kind: .onchain, paymentStarted: true, @@ -776,6 +978,7 @@ final class PaykitPaymentProofServiceTests: XCTestCase { let proof = PendingPaykitPaymentProof( identity: identity, requestId: request.id, + paymentAppId: "bitkit", paymentEndpointIdentifier: endpoint, kind: .onchain, billingPeriod: request.billingPeriod, @@ -809,6 +1012,7 @@ final class PaykitPaymentProofServiceTests: XCTestCase { let foreignProof = PendingPaykitPaymentProof( identity: identity, requestId: request.id, + paymentAppId: "bitkit", paymentEndpointIdentifier: endpoint, kind: .onchain, paymentStarted: false, @@ -826,11 +1030,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 +1061,7 @@ final class PaykitPaymentProofServiceTests: XCTestCase { let onchainProof = PendingPaykitPaymentProof( identity: identity, requestId: onchainRequest.id, + paymentAppId: "bitkit", paymentEndpointIdentifier: onchainEndpoint, kind: .onchain, paymentStarted: true, @@ -868,6 +1074,7 @@ final class PaykitPaymentProofServiceTests: XCTestCase { let lightningProof = PendingPaykitPaymentProof( identity: identity, requestId: lightningRequest.id, + paymentAppId: "bitkit", paymentEndpointIdentifier: lightningEndpoint, kind: .lightning, paymentStarted: true, @@ -910,7 +1117,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 +1132,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 +1149,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 +1178,7 @@ final class PaykitPaymentProofServiceTests: XCTestCase { try await service.prepare( request: request, + paymentAppId: "bitkit", paymentEndpointIdentifier: PublicPaykitService.MethodId.bitcoinLightningBolt11.rawValue, kind: .lightning ) @@ -1011,6 +1219,7 @@ final class PaykitPaymentProofServiceTests: XCTestCase { try await service.prepare( request: request, + paymentAppId: "bitkit", paymentEndpointIdentifier: PublicPaykitService.MethodId.bitcoinLightningBolt11.rawValue, kind: .lightning ) @@ -1032,6 +1241,7 @@ final class PaykitPaymentProofServiceTests: XCTestCase { try await service.prepare( request: request, + paymentAppId: "bitkit", paymentEndpointIdentifier: PublicPaykitService.MethodId.bitcoinLightningBolt11.rawValue, kind: .lightning ) @@ -1039,6 +1249,7 @@ final class PaykitPaymentProofServiceTests: XCTestCase { do { try await service.prepare( request: request, + paymentAppId: "bitkit", paymentEndpointIdentifier: PublicPaykitService.MethodId.bitcoinLightningBolt11.rawValue, kind: .lightning ) @@ -1054,6 +1265,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 +1285,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 +1315,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 +1342,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 +1370,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 +1430,6 @@ final class PaykitPaymentProofServiceTests: XCTestCase { ) throws -> PaymentRequestRecord { try PaymentRequestRecord( counterparty: counterparty, - counterpartyReceiverPath: PaykitReceiverPath.wallet, paymentRequestId: paymentRequestId, localRole: .payer, state: state, @@ -1223,12 +1437,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 +1481,7 @@ final class PaykitPaymentProofServiceTests: XCTestCase { streamItemId: 2, paymentReference: PaymentReference(text: "invoice-123"), billingPeriod: billingPeriod, + paymentAppId: "bitkit", paymentEndpointIdentifier: endpoint, allowanceId: nil, conversionQuoteId: nil, @@ -1337,11 +1557,11 @@ private struct PaymentProofOnchainLookup: PaykitOnchainPaymentProofLookingUp { let existingTransactionIds: Set let transactionLookupFails: Bool - func existingTransactionIds(address _: String, amountSats _: UInt64) async throws -> Set { + func existingTransactionIds(address _: String, amountSats _: UInt64, walletId _: String) async throws -> Set { existingTransactionIds } - func transactionId(address _: String, amountSats _: UInt64, excluding transactionIds: Set) async throws -> String? { + func transactionId(address _: String, amountSats _: UInt64, walletId _: String, excluding transactionIds: Set) async throws -> String? { if transactionLookupFails { throw PaymentProofLookupMockError.transactionLookup } @@ -1373,7 +1593,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 +1636,6 @@ private actor PaymentProofSdkMock: PaykitPaymentProofSdkHandling { func submitPaymentProof( counterparty: String, - counterpartyReceiverPath: String, paymentRequestId: String, proof: PaymentProofSubmission ) async throws -> PaymentRequestRecord { @@ -1434,7 +1653,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 +1664,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..b4c09207b 100644 --- a/BitkitTests/PaykitPaymentRequestServiceTests.swift +++ b/BitkitTests/PaykitPaymentRequestServiceTests.swift @@ -1,6 +1,8 @@ @testable import Bitkit import BitkitCore import Foundation +import LDKNode +import Observation import Paykit import UIKit import UserNotifications @@ -8,6 +10,715 @@ 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 { + for priority in [PaykitSdkOperationLock.Priority.ordered, .background] { + let context = "\(mode), \(priority)" + let sdk = PaymentRequestSdkMock(records: [stored]) + await sdk.setIncomingRecords([incoming]) + let manager = paymentRequestManager(sdk: sdk) + + if priority == .ordered { + await manager.refresh(mode: mode) + } else { + await manager.refresh(mode: mode, messagePriority: priority) + } + + let expectedIds = mode == .stored ? [stored.paymentRequestId] : [stored.paymentRequestId, incoming.paymentRequestId] + XCTAssertEqual(Set(manager.pendingRequests.map(\.paymentRequestId)), Set(expectedIds), context) + let snapshot = await sdk.snapshot() + XCTAssertEqual(snapshot.processCallCount, mode == .full ? 1 : 0, context) + XCTAssertEqual(snapshot.receiveCallCount, mode >= .inbox ? 1 : 0, context) + XCTAssertEqual(snapshot.paymentRequestListCallCount, 1, context) + let priorities = await sdk.operationPriorities + XCTAssertEqual(priorities["pending"] ?? [], mode == .full ? [priority] : [], context) + XCTAssertEqual(priorities["receive"] ?? [], mode >= .inbox ? [priority] : [], context) + XCTAssertEqual(priorities["requests"], [.background], context) + XCTAssertEqual(priorities["peers"], [.background], context) + } + } + } + + 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, messagePriority: .background) } + try await waitUntil { await sdk.paymentRequestListIsPaused() } + let overlappingRefreshStarted = expectation(description: context) + let overlappingRefresh = Task { + overlappingRefreshStarted.fulfill() + return 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() + let initialSucceeded = await initialRefresh.value + let overlappingSucceeded = await overlappingRefresh.value + XCTAssertTrue(initialSucceeded, context) + XCTAssertTrue(overlappingSucceeded, context) + + let upgrades = requestedMode > initialMode + let snapshot = await sdk.snapshot() + XCTAssertEqual(snapshot.processCallCount, max(initialMode, requestedMode) == .full ? 1 : 0, context) + XCTAssertEqual(snapshot.receiveCallCount, (initialMode >= .inbox ? 1 : 0) + (upgrades ? 1 : 0), context) + XCTAssertEqual(snapshot.paymentRequestListCallCount, upgrades ? 2 : 1, context) + let priorities = await sdk.operationPriorities + XCTAssertEqual( + priorities["pending"] ?? [], + initialMode == .full ? [.background] : requestedMode == .full ? [.ordered] : [], + context + ) + XCTAssertEqual( + priorities["receive"] ?? [], + (initialMode >= .inbox ? [.background] : []) + (upgrades ? [.ordered] : []), + context + ) + } + } + } + + func testStoredRefreshReportsCoalescedFailureBeforeLoadingSubscriptions() async throws { + defer { PaykitSubscriptionNotificationTargetStore.clear() } + let now = try XCTUnwrap(ISO8601DateFormatter().date(from: "2027-01-15T08:00:00Z")) + let recurrence = PaymentRequestRecurrence( + every: 1, unit: "month", startsAt: timestamp(now), anchor: timestamp(now), endsAt: nil + ) + let record = try paymentRequestRecord( + counterparty: "pubky\(String(repeating: "y", count: 52))", state: .activeRecurring, recurrence: recurrence + ) + let target = try XCTUnwrap(PaykitSubscriptionNotificationTarget(userInfo: [ + "payer_identity": "pubky\(String(repeating: "z", count: 52))", + "payment_request_id": record.paymentRequestId, + "counterparty": record.counterparty, + "billing_period_starts_at": PaykitSubscriptionTimestamp.string(from: now), + ])) + PaykitSubscriptionNotificationTargetStore.save(target) + let sdk = PaymentRequestSdkMock(records: [record]) + let manager = paymentRequestManager(sdk: sdk, clock: PaymentRequestTestClock(now)) + await sdk.setReceiveError(.receive) + await sdk.pauseNextProcess() + let fullRefresh = Task { await manager.refresh() } + try await waitUntil { await sdk.processIsPaused() } + + let storedRefreshStarted = expectation(description: "Stored subscription refresh started") + let storedRefresh = Task { + storedRefreshStarted.fulfill() + return await manager.refresh(mode: .stored) + } + await fulfillment(of: [storedRefreshStarted], timeout: 1) + await sdk.resumeProcess() + let fullSucceeded = await fullRefresh.value + let storedSucceeded = await storedRefresh.value + + XCTAssertFalse(fullSucceeded) + XCTAssertFalse(storedSucceeded) + XCTAssertEqual(PaykitSubscriptionNotificationTargetStore.load(), target) + XCTAssertTrue(manager.subscriptions.isEmpty) + let failedSnapshot = await sdk.snapshot() + XCTAssertEqual(failedSnapshot.paymentRequestListCallCount, 0) + + let retrySucceeded = await manager.refresh(mode: .stored) + XCTAssertTrue(retrySucceeded) + let request = try XCTUnwrap(manager.pendingRequests.first) + XCTAssertEqual(request.paymentRequestId, record.paymentRequestId) + XCTAssertNotNil(request.billingPeriod) + XCTAssertTrue(target.matches(request)) + XCTAssertTrue(manager.markPresentedIfPending(request)) + XCTAssertTrue(manager.requestsForPresentation().isEmpty) + XCTAssertTrue(manager.requestPresentation(request)) + XCTAssertNil(PaykitSubscriptionNotificationTargetStore.load()) + XCTAssertEqual(manager.requestsForPresentation(), [request]) + XCTAssertEqual(manager.deferPresentation(request), .retryScheduled) + PaykitSubscriptionNotificationTargetStore.save(target) + manager.dismissPreparingRequest(request) + XCTAssertNil(PaykitSubscriptionNotificationTargetStore.load()) + XCTAssertEqual(manager.pendingRequests, [request]) + XCTAssertTrue(manager.requestsForPresentation().isEmpty) + XCTAssertTrue(manager.requestPresentation(request)) + } + + func testFreshRefreshRereadsRequestsChangedAfterSnapshot() async throws { + let record = try paymentRequestRecord() + let sdk = PaymentRequestSdkMock(records: [record]) + let manager = paymentRequestManager(sdk: sdk) + await sdk.pauseNextPaymentRequestList() + let first = Task { await manager.refresh(messagePriority: .background) } + try await waitUntil { await sdk.paymentRequestListIsPaused() } + + await sdk.setRecords([]) + let refreshStarted = expectation(description: "Request state refresh started") + let fresh = Task { + refreshStarted.fulfill() + await manager.refresh(forceFresh: true, messagePriority: .background) + } + 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) + let priorities = await sdk.operationPriorities + XCTAssertEqual(priorities["pending"], [.background, .ordered]) + XCTAssertEqual(priorities["receive"], [.background, .ordered]) + } + + 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( + scheduleAcceptedRequestDelivery: { _ in }, + 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 +741,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 +779,6 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { let requestId = PaykitPaymentRequest.ID( paymentRequestId: "subscription", counterparty: "pubkypayee", - counterpartyReceiverPath: PaykitReceiverPath.server, billingPeriodStartsAt: startsAt ) @@ -234,7 +943,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 +982,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 +1054,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 +1088,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( @@ -426,6 +1135,34 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { XCTAssertTrue(app.claimContactPaymentContext(second)) } + func testOnchainResolutionRemainsAvailableForTheMatchingSendUntilConsumed() throws { + let app = AppViewModel() + let identity = "pubky" + String(repeating: "y", count: 52) + let otherIdentity = "pubky" + String(repeating: "b", count: 52) + let request = try XCTUnwrap(PaykitPaymentRequest(record: paymentRequestRecord(), now: Date())) + app.contactPaymentContext = ContactPaymentContext(publicKey: request.counterparty, incomingPaymentRequest: request) + let resolution = PaykitOnchainPaymentResolution(identity: identity, requestId: request.id, transactionId: "resolved-tx") + let unrelated = PaykitOnchainPaymentResolution( + identity: identity, + requestId: PaykitPaymentRequest.ID(paymentRequestId: "other", counterparty: request.counterparty), + transactionId: "other-tx" + ) + + app.retainPaykitOnchainPaymentResolution(resolution, identity: otherIdentity) + XCTAssertNil(app.paykitOnchainPaymentResolution) + app.retainPaykitOnchainPaymentResolution(resolution, identity: identity) + app.retainPaykitOnchainPaymentResolution(unrelated, identity: identity) + app.consumePaykitOnchainPaymentResolution(unrelated) + app.resetSendState(preservingContactPaymentContext: true) + XCTAssertEqual(app.paykitOnchainPaymentResolution, resolution) + app.consumePaykitOnchainPaymentResolution(resolution) + XCTAssertNil(app.paykitOnchainPaymentResolution) + + app.retainPaykitOnchainPaymentResolution(resolution, identity: identity) + app.resetSendState() + XCTAssertNil(app.paykitOnchainPaymentResolution) + } + func testBlockingPeerHidesRequestsFromAnEarlierSnapshot() async throws { let now = Date(timeIntervalSince1970: 1_800_000_000) let record = try paymentRequestRecord( @@ -437,8 +1174,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 +1210,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 +1227,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 +1258,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 @@ -680,9 +1417,10 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { (paymentRequestRecord(id: "missing-terms"), .missingTerms), (paymentRequestRecord(id: "wrong-asset", asset: "BTC"), .unsupportedAsset), ( - paymentRequestRecord(id: "payment-deadline", paymentDeadline: .at(timestamp: timestamp(now.addingTimeInterval(3600)))), + paymentRequestRecord(id: "payment-deadline", paymentDeadline: .periodStart(seconds: 3600)), .unsupportedPaymentDeadline ), + (paymentRequestRecord(id: "invalid-deadline", paymentDeadline: .at(timestamp: "invalid")), .invalidPaymentDeadline), (paymentRequestRecord(id: "invalid-amount", amount: "not-bitcoin"), .invalidAmount), (paymentRequestRecord(id: "amount-out-of-range", amount: "184467440737.09551615"), .amountOutOfRange), (paymentRequestRecord(id: "unsupported-endpoint", endpoints: ["btc-unsupported-method"]), .noSupportedEndpoint), @@ -706,47 +1444,245 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { } } - func testSynchronizeLogsRedactedParseFailuresWithoutRequestData() async throws { - let counterparty = "pubky\(String(repeating: "y", count: 52))" - let outgoingCounterparty = "pubky\(String(repeating: "p", count: 52))" - let unknownRoleCounterparty = "pubky\(String(repeating: "u", count: 52))" - let secretNote = "do-not-log-this-note" - let records = try [ - paymentRequestRecord( - id: "do-not-log-outgoing-id", - counterparty: outgoingCounterparty, - role: .payee - ), - paymentRequestRecord( - id: "do-not-log-unknown-role-id", - counterparty: unknownRoleCounterparty, - role: .unknown - ), - paymentRequestRecord( - id: "do-not-log-this-id", - counterparty: counterparty, - asset: "BTC", - metadata: "{\"note\":\"\(secretNote)\"}" - ), - paymentRequestRecord( - id: "do-not-log-this-endpoint-id", - counterparty: counterparty, - endpoints: ["btc-private-unsupported-endpoint"] - ), - paymentRequestRecord( - id: "do-not-log-invalid-counterparty-id", - counterparty: "do-not-log-invalid-counterparty", - asset: "BTC" - ), + func testAbsolutePaymentDeadlineIsInclusiveAndIndependentOfProposalExpiry() throws { + let now = try XCTUnwrap(PaykitPaymentRequest.parseDate("2027-01-15T08:00:00.500Z")) + let cases: [(String, Bool)] = [ + ("2027-01-15T08:00:01Z", true), + ("2027-01-15T08:00:00.500000001Z", true), + ("2027-01-15T08:00:00.500Z", true), + ("2027-01-15T08:00:00.499999999Z", false), + ("2027-01-15T08:00:00Z", false), + ("2027-01-15T08:00:01+00:00", false), + ("not-a-timestamp", false), ] - let recorder = PaymentRequestLogRecorder() - let service = PaykitPaymentRequestService( - sdk: PaymentRequestSdkMock(records: records), - logWarning: { recorder.append($0) } - ) + for (deadline, isTimely) in cases { + for state in [PaymentRequestLifecycleState.proposed, .accepted] { + let record = try paymentRequestRecord( + state: state, + expiresAt: timestamp(now.addingTimeInterval(state == .accepted ? -60 : 60)), + paymentDeadline: .at(timestamp: deadline) + ) + let request = PaykitPaymentRequest(record: record, now: now) + XCTAssertEqual(request != nil, isTimely, "\(state), \(deadline)") + if let request { + XCTAssertFalse(request.isExpired(at: now)) + XCTAssertEqual(request.paymentDeadline, PaykitPreciseInstant(timestamp: deadline)) + XCTAssertEqual(request.updatingLifecycleState(.accepted).paymentDeadline, request.paymentDeadline) + } + } + } + } - let snapshot = try await service.synchronize() - let firstMessages = recorder.messages + func testPaymentDeadlineCrossingDuringPreparationDoesNotApprovePayment() async throws { + for stage in ["before", "consume", "accept"] { + let now = Date(timeIntervalSince1970: 1_800_000_000) + let clock = PaymentRequestTestClock(now) + let record = try paymentRequestRecord(paymentDeadline: .at(timestamp: timestamp(now.addingTimeInterval(1)))) + let sdk = PaymentRequestSdkMock(records: [record]) + let manager = paymentRequestManager(sdk: sdk, clock: clock) + await manager.refresh() + let request = try XCTUnwrap(manager.pendingRequests.first) + if stage == "before" { clock.advance(by: 2) } + if stage == "accept" { await sdk.pauseNextAccept() } + + let preparation = Task { + try await manager.prepareForPayment(request) { + if stage == "consume" { clock.advance(by: 2) } + } + } + if stage == "accept" { + try await waitUntil { await sdk.acceptIsPaused() } + clock.advance(by: 2) + await sdk.resumeAccept() + } + do { + try await preparation.value + XCTFail("Expired preparation must not approve payment: \(stage)") + } catch { + XCTAssertEqual(error as? PaykitPaymentRequestError, .requestExpired, stage) + } + XCTAssertFalse(manager.isApprovedForPayment(request), stage) + let accepted = await sdk.snapshot().acceptedRequests + XCTAssertEqual(accepted.count, stage == "accept" ? 1 : 0, stage) + } + } + + func testPaymentDeadlineIsRecheckedAfterAuthorizationInEverySoftwareSendRoute() async throws { + for route in ["lightning", "lnurl", "onchain"] { + for expiryStage in ["timely", "preparation", "authorization"] { + let crossesDeadline = expiryStage != "timely" + let now = Date(timeIntervalSince1970: 1_800_000_000) + let clock = PaymentRequestTestClock(now) + let record = try paymentRequestRecord( + expiresAt: timestamp(now.addingTimeInterval(1)), + paymentDeadline: .at(timestamp: timestamp(now.addingTimeInterval(10))) + ) + let sdk = PaymentRequestSdkMock(records: [record]) + let manager = paymentRequestManager(sdk: sdk, clock: clock) + await manager.refresh() + let request = try XCTUnwrap(manager.pendingRequests.first) + try await manager.prepareForPayment(request) + clock.advance(by: 10) + XCTAssertTrue(manager.isApprovedForPayment(request), "Acceptance survives proposal expiry") + // Fee calculation, PIN confirmation and LNURL retrieval all finish before this authorization. + if expiryStage == "preparation" { clock.advance(by: 0.001) } + else { await sdk.pauseNextLinkedPeers() } + + var sent = false + var authorizationFailed = false + let authorize: (PaykitPaymentRequest) async throws -> Void = { try await manager.ensurePaymentAllowed($0) } + let onFailure: (Error) async -> Void = { _ in authorizationFailed = true } + let execution = Task { + switch route { + case "lightning": + try await SendConfirmationView.sendLightningPayment( + request: request, authorize: authorize, onAuthorizationFailure: onFailure + ) { sent = true } + case "lnurl": + try await LnurlPayConfirm.sendLightningPayment( + request: request, authorize: authorize, onAuthorizationFailure: onFailure + ) { sent = true } + default: + try await SendConfirmationView.sendOnchainPayment( + request: request, prepareBroadcast: { _ in }, authorize: authorize, + onAuthorizationFailure: onFailure, onAuthorized: { _ in }, + send: { beforeBroadcastAttempt in + try await beforeBroadcastAttempt() + sent = true + } + ) + } + } + if expiryStage != "preparation" { + try await waitUntil { await sdk.linkedPeersIsPaused() } + if expiryStage == "authorization" { clock.advance(by: 0.001) } + await sdk.resumeLinkedPeers() + } + do { + try await execution.value + XCTAssertFalse(crossesDeadline, route) + } catch { + XCTAssertTrue(crossesDeadline, route) + XCTAssertEqual(error as? PaykitPaymentRequestError, .requestExpired, route) + } + XCTAssertEqual(sent, !crossesDeadline, route) + XCTAssertEqual(authorizationFailed, crossesDeadline, route) + } + } + } + + func testPaymentDeadlineIsCheckedInsideTheLdkSubmissionQueue() async throws { + for crossesDeadline in [false, true] { + let clock = PaymentRequestTestClock(Date(timeIntervalSince1970: 1_800_000_000)) + let deadline = PaykitPreciseInstant(date: clock.now().addingTimeInterval(1)) + let blocked = expectation(description: "LDK queue blocked") + let submissionStarted = expectation(description: "submission queued") + let release = DispatchSemaphore(value: 0) + defer { release.signal() } + let blocker = Task { + try await Bitkit.ServiceQueue.background(.ldk) { + blocked.fulfill() + XCTAssertEqual(release.wait(timeout: .now() + 5), .success) + } + } + await fulfillment(of: [blocked], timeout: 2) + let submission = Task { + submissionStarted.fulfill() + return try await Bitkit.LightningService.submitPayment(beforeSubmission: { + try PaykitPaymentRequest.checkPaymentDeadline(deadline, at: clock.now()) + }) { "submitted" } + } + await fulfillment(of: [submissionStarted], timeout: 2) + clock.advance(by: crossesDeadline ? 1.001 : 1) + release.signal() + try await blocker.value + do { + let result = try await submission.value + XCTAssertFalse(crossesDeadline) + XCTAssertEqual(result, "submitted") + } catch { + XCTAssertTrue(crossesDeadline) + XCTAssertEqual((error as? Bitkit.AppError)?.underlyingError as? PaykitPaymentRequestError, .requestExpired) + XCTAssertTrue(PaykitPaymentProofService.isDefiniteOnchainPreBroadcastFailure(error)) + XCTAssertEqual(SendConfirmationView.privatePaymentListOutcomeAfterFailure( + currentOutcome: .uncertain, walletType: .onchain, onchainPaymentStarted: true, error: error + ), .definitePreBroadcastFailure) + } + } + } + + func testExpiredAcceptedRequestIsNotReinsertedAfterSdkLookup() async throws { + for isRetry in [false, true] { + let now = Date(timeIntervalSince1970: 1_800_000_000) + let clock = PaymentRequestTestClock(now) + var record = try paymentRequestRecord(paymentDeadline: .at(timestamp: timestamp(now.addingTimeInterval(1)))) + let sdk = PaymentRequestSdkMock(records: [record]) + let manager = paymentRequestManager(sdk: sdk, clock: clock) + await manager.refresh() + let request = try XCTUnwrap(manager.pendingRequests.first) + try await manager.prepareForPayment(request) + record.state = .accepted + await sdk.setRecords([record]) + XCTAssertTrue(manager.pendingRequests.isEmpty) + await sdk.pauseNextPaymentRequestList() + let lookup = Task { + if isRetry { + let retried = await manager.paymentRequestForRetry(request.id) + XCTAssertNil(retried) + } else { + await manager.finishPayment(request) + } + } + try await waitUntil { await sdk.paymentRequestListIsPaused() } + clock.advance(by: 2) + await sdk.resumePaymentRequestList() + await lookup.value + XCTAssertTrue(manager.pendingRequests.isEmpty) + XCTAssertFalse(manager.isApprovedForPayment(request)) + } + } + + func testSynchronizeLogsRedactedParseFailuresWithoutRequestData() async throws { + let counterparty = "pubky\(String(repeating: "y", count: 52))" + let outgoingCounterparty = "pubky\(String(repeating: "p", count: 52))" + let unknownRoleCounterparty = "pubky\(String(repeating: "u", count: 52))" + let secretNote = "do-not-log-this-note" + let records = try [ + paymentRequestRecord( + id: "do-not-log-outgoing-id", + counterparty: outgoingCounterparty, + role: .payee + ), + paymentRequestRecord( + id: "do-not-log-unknown-role-id", + counterparty: unknownRoleCounterparty, + role: .unknown + ), + paymentRequestRecord( + id: "do-not-log-this-id", + counterparty: counterparty, + asset: "BTC", + metadata: "{\"note\":\"\(secretNote)\"}" + ), + paymentRequestRecord( + id: "do-not-log-this-endpoint-id", + counterparty: counterparty, + endpoints: ["btc-private-unsupported-endpoint"] + ), + paymentRequestRecord( + id: "do-not-log-invalid-counterparty-id", + counterparty: "do-not-log-invalid-counterparty", + asset: "BTC" + ), + ] + let recorder = PaymentRequestLogRecorder() + let service = PaykitPaymentRequestService( + sdk: PaymentRequestSdkMock(records: records), + logWarning: { recorder.append($0) } + ) + + let snapshot = try await service.synchronize() + let firstMessages = recorder.messages _ = try await service.synchronize() XCTAssertTrue(snapshot.incoming.isEmpty) @@ -754,9 +1690,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)) @@ -809,11 +1745,11 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { paymentRequestRecord(id: "outgoing", state: .proposed, role: .payee), paymentRequestRecord(id: "recurring", state: .activeRecurring), paymentRequestRecord(id: "unsupported", state: .canceled, endpoints: ["btc-unsupported-method"]), - paymentRequestRecord(id: "deadline-proposed", paymentDeadline: .at(timestamp: timestamp(now))), - paymentRequestRecord(id: "deadline-accepted", state: .accepted, paymentDeadline: .at(timestamp: timestamp(now))), - paymentRequestRecord(id: "deadline-paid", state: .proofSubmitted, paymentDeadline: .at(timestamp: timestamp(now))), - paymentRequestRecord(id: "deadline-canceled", state: .canceled, paymentDeadline: .at(timestamp: timestamp(now))), - paymentRequestRecord(id: "deadline-rejected", state: .rejected, paymentDeadline: .at(timestamp: timestamp(now))), + paymentRequestRecord(id: "deadline-proposed", paymentDeadline: .at(timestamp: timestamp(now.addingTimeInterval(-1)))), + paymentRequestRecord(id: "deadline-accepted", state: .accepted, paymentDeadline: .at(timestamp: timestamp(now.addingTimeInterval(-1)))), + paymentRequestRecord(id: "deadline-paid", state: .proofSubmitted, paymentDeadline: .at(timestamp: timestamp(now.addingTimeInterval(-1)))), + paymentRequestRecord(id: "deadline-canceled", state: .canceled, paymentDeadline: .at(timestamp: timestamp(now.addingTimeInterval(-1)))), + paymentRequestRecord(id: "deadline-rejected", state: .rejected, paymentDeadline: .at(timestamp: timestamp(now.addingTimeInterval(-1)))), ] let manager = paymentRequestManager( sdk: PaymentRequestSdkMock(records: records), @@ -822,7 +1758,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 +1776,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 +1791,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 +1810,129 @@ 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( + scheduleAcceptedRequestDelivery: { _ in }, + 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 +2086,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 +2137,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 +2188,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 +2209,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 +2240,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 +2410,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 +2525,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 +2549,6 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { let second = try paymentRequestRecord( id: "shared", counterparty: "second", - counterpartyReceiverPath: PaykitReceiverPath.wallet, recurrence: recurrence ) let manager = paymentRequestManager( @@ -1505,7 +2562,6 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { let dueRequest = try XCTUnwrap(acceptedRequest) XCTAssertEqual(dueRequest.counterparty, "second") - XCTAssertEqual(dueRequest.counterpartyReceiverPath, PaykitReceiverPath.wallet) } func testAcceptingSubscriptionRejectsTermsChangedAfterReview() async throws { @@ -1598,11 +2654,13 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { ) let sdk = try PaymentRequestSdkMock(records: [paymentRequestRecord(recurrence: recurrence)]) let manager = PaykitPaymentRequestManager( + scheduleAcceptedRequestDelivery: { _ in }, service: PaykitPaymentRequestService( sdk: sdk, logWarning: { _ in } ), presentationStore: PaymentRequestPresentationMemoryStore(), + acceptanceStore: PaymentRequestPresentationMemoryStore(), subscriptionStateStore: subscriptionStore, isAvailable: { true }, logWarning: { _ in } @@ -1635,8 +2693,10 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { paymentRequestRecord(id: "schedule-end", recurrence: recurrence), ]) let manager = PaykitPaymentRequestManager( + scheduleAcceptedRequestDelivery: { _ in }, service: PaykitPaymentRequestService(sdk: sdk, logWarning: { _ in }), presentationStore: PaymentRequestPresentationMemoryStore(), + acceptanceStore: PaymentRequestPresentationMemoryStore(), subscriptionStateStore: PaymentRequestSubscriptionStateMemoryStore(), isAvailable: { true }, logWarning: { _ in } @@ -1662,22 +2722,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 { @@ -1698,6 +2763,16 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { XCTAssertEqual(manager.historyRequests.first?.id, request.id) XCTAssertEqual(manager.historyRequests.first?.lifecycleState, .proofSubmitted) XCTAssertEqual(manager.historyRequests.first?.paymentProofKind, proofKind) + let isAvailable = manager.pendingRequests.contains { $0.id == request.id } || manager.isApprovedForPayment(request) + XCTAssertFalse(isAvailable) + XCTAssertFalse(SendSheet.shouldDismissUnavailableRequest( + root: .confirm, path: [], isSubmittingPayment: true, isAvailable: isAvailable + )) + for result in [SendRoute.pending(paymentHash: nil, retryRoute: .confirm, paymentRequest: nil), .success(paymentId: "payment")] { + XCTAssertFalse(SendSheet.shouldDismissUnavailableRequest( + root: .confirm, path: [result], isSubmittingPayment: false, isAvailable: isAvailable + )) + } } } @@ -1832,8 +2907,10 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { let sdk = PaymentRequestSdkMock(records: [record]) let gate = PaymentProofProtectionGate() let manager = PaykitPaymentRequestManager( + scheduleAcceptedRequestDelivery: { _ in }, service: PaykitPaymentRequestService(sdk: sdk, now: { now }, logWarning: { _ in }), presentationStore: PaymentRequestPresentationMemoryStore(), + acceptanceStore: PaymentRequestPresentationMemoryStore(), subscriptionStateStore: PaymentRequestSubscriptionStateMemoryStore(), protectedRequestIdsForSubscriptionCancellation: { _, _ in await gate.wait() }, now: { now }, @@ -2220,7 +3297,7 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { let sdk = try PaymentRequestSdkMock(records: [paymentRequestRecord(id: "recurring", recurrence: recurrence)]) let manager = paymentRequestManager(sdk: sdk, clock: PaymentRequestTestClock(now)) await manager.refresh() - await sdk.setReceiveError(.receive) + await sdk.setPaymentRequestListError(PaymentRequestSdkMockError.receive) let request = try await manager.accept(XCTUnwrap(manager.subscriptions.first)) @@ -2585,12 +3662,15 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { await sdk.failNextProcess() let manager = paymentRequestManager(sdk: sdk) - await manager.refresh() + await manager.refresh(messagePriority: .background) XCTAssertEqual(manager.pendingRequests.count, 1) let snapshot = await sdk.snapshot() XCTAssertEqual(snapshot.processCallCount, 1) XCTAssertEqual(snapshot.receiveCallCount, 1) + let priorities = await sdk.operationPriorities + XCTAssertEqual(priorities["pending"], [.background]) + XCTAssertEqual(priorities["receive"], [.background]) } func testFailedRefreshKeepsPreviouslyLoadedRequests() async throws { @@ -2614,7 +3694,6 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { await sdk.setReceiveReports([ PrivateStreamCounterpartyIntakeReport( counterparty: record.counterparty, - counterpartyReceiverPath: record.counterpartyReceiverPath, report: nil, error: PaymentRequestIntakeError(noPointer: .init()) ), @@ -2631,7 +3710,9 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { let sdk = try PaymentRequestSdkMock(records: [paymentRequestRecord(expiresAt: timestamp(expiresAt))]) let now: @Sendable () -> Date = { Date() } let manager = PaykitPaymentRequestManager( + scheduleAcceptedRequestDelivery: { _ in }, service: PaykitPaymentRequestService(sdk: sdk, now: now, logWarning: { _ in }), + acceptanceStore: PaymentRequestPresentationMemoryStore(), subscriptionStateStore: PaymentRequestSubscriptionStateMemoryStore(), completedPaymentProofKinds: { _ in [:] }, now: now, @@ -2694,12 +3775,20 @@ 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( + scheduleAcceptedRequestDelivery: { _ in }, service: PaykitPaymentRequestService(sdk: sdk, logWarning: { _ in }), presentationStore: presentationStore, + acceptanceStore: acceptanceStore, subscriptionStateStore: subscriptionStore, logWarning: { _ in } ) @@ -2719,8 +3808,10 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { XCTAssertTrue(manager.requestsForPresentation().isEmpty) let restoredManager = PaykitPaymentRequestManager( + scheduleAcceptedRequestDelivery: { _ in }, service: PaykitPaymentRequestService(sdk: sdk, logWarning: { _ in }), presentationStore: presentationStore, + acceptanceStore: acceptanceStore, subscriptionStateStore: subscriptionStore, logWarning: { _ in } ) @@ -2737,18 +3828,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 +3901,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 +3927,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 { @@ -2954,12 +4060,14 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { for _ in 0 ..< 14 { XCTAssertEqual(manager.deferPresentation(request), .retryScheduled) + XCTAssertTrue(manager.isWaitingForPresentationRetry(request)) XCTAssertTrue(manager.requestsForPresentation().isEmpty) clock.advance(by: 2) XCTAssertEqual(manager.requestsForPresentation(), [request]) } XCTAssertEqual(manager.deferPresentation(request), .requestedPresentationEnded) + XCTAssertFalse(manager.isWaitingForPresentationRetry(request)) XCTAssertTrue(manager.requestsForPresentation().isEmpty) XCTAssertNil(manager.requestedPresentationId) @@ -3238,7 +4346,7 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { ).isEmpty) await notificationCenter.resumePendingRequests() - await refreshTask.value + _ = await refreshTask.value } func testRefreshPreservesUnavailableOutcomeForRequestedPresentation() async throws { @@ -3322,7 +4430,7 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { XCTAssertEqual(manager.requestedPresentationExpirationTrigger, 0) await notificationCenter.resumePendingRequests() - await refreshTask.value + _ = await refreshTask.value let dispatches = IncomingPaykitPaymentRequestPresentationDispatcher.handleStateChange( from: previous, @@ -3345,12 +4453,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) @@ -3366,7 +4476,7 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { XCTAssertTrue(manager.requestsForPresentation().isEmpty) await notificationCenter.resumePendingRequests() - await refreshTask.value + _ = await refreshTask.value XCTAssertEqual(manager.pendingRequests, [retriedRequest]) XCTAssertTrue(manager.requestsForPresentation().isEmpty) @@ -3438,7 +4548,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 +4564,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 +4588,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 +4605,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 +4617,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 +4636,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 @@ -3694,25 +4815,384 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { XCTAssertEqual(presentationCount, 2) } - func testClearInvalidatesInFlightPresentation() async throws { - let sdk = try PaymentRequestSdkMock(records: [paymentRequestRecord()]) + func testPreparingSheetRequiresCurrentPresentationSessionAndContext() async throws { + let expiresAt = Date().addingTimeInterval(60) + let sdk = try PaymentRequestSdkMock(records: [paymentRequestRecord( + expiresAt: PaykitSubscriptionTimestamp.string(from: expiresAt) + )]) let manager = paymentRequestManager(sdk: sdk) await manager.refresh() - var wasCurrentBeforeClear = false - var wasCurrentAfterClear = true + let profile = PubkyProfileManager() + profile.publicKey = "pubky\(String(repeating: "z", count: 52))" + let session = try XCTUnwrap(profile.currentSession) await manager.presentRequests { requests in guard let request = requests.first else { XCTFail("Expected an incoming payment request") return } - wasCurrentBeforeClear = manager.isCurrentPresentation(request) + let preparation = IncomingPaykitPaymentRequestPreparation(request: request, session: session) + XCTAssertEqual(preparation.visibleRequest(manager: manager, session: session, paymentContext: nil), request) + XCTAssertNil(preparation.visibleRequest(manager: manager, session: nil, paymentContext: nil)) + profile.publicKey = "pubky\(String(repeating: "y", count: 52))" + XCTAssertNil(preparation.visibleRequest(manager: manager, session: profile.currentSession, paymentContext: nil)) + XCTAssertNil(preparation.visibleRequest(manager: manager, session: session, paymentContext: nil, now: expiresAt)) + + let context = ContactPaymentContext(publicKey: request.counterparty, incomingPaymentRequest: request) + preparation.paymentContext = context + XCTAssertEqual(preparation.visibleRequest(manager: manager, session: session, paymentContext: context), request) + XCTAssertNil(preparation.visibleRequest(manager: manager, session: session, paymentContext: nil)) + XCTAssertNil(preparation.visibleRequest( + manager: manager, + session: session, + paymentContext: ContactPaymentContext(publicKey: request.counterparty, incomingPaymentRequest: request) + )) + + XCTAssertTrue(manager.isCurrentPresentation(request)) + XCTAssertEqual(manager.deferPresentation(request), .retryScheduled) + XCTAssertEqual(preparation.visibleRequest(manager: manager, session: session, paymentContext: context), request) manager.clear() - wasCurrentAfterClear = manager.isCurrentPresentation(request) + XCTAssertFalse(manager.isCurrentPresentation(request)) + XCTAssertNil(preparation.visibleRequest(manager: manager, session: session, paymentContext: context)) + } + } + + func testPreparingSheetClearsWhileCanceledPreparationIsStillSuspended() async throws { + for manuallyRequested in [false, true] { + let manager = try paymentRequestManager(sdk: PaymentRequestSdkMock(records: [paymentRequestRecord( + metadata: #"{"note":"Dinner"}"# + )])) + await manager.refresh() + let request = try XCTUnwrap(manager.pendingRequests.first) + if manuallyRequested { + XCTAssertTrue(manager.requestPresentation(request)) + } + let profile = PubkyProfileManager() + profile.publicKey = "pubky\(String(repeating: "z", count: 52))" + let session = try XCTUnwrap(profile.currentSession) + let preparation = IncomingPaykitPaymentRequestPreparation(request: request, session: session) + var continuation: CheckedContinuation? + var preparationFinished = false + let task = Task { + await manager.presentRequests { requests in + XCTAssertEqual(requests, [request]) + defer { preparation.clear() } + do { + try await preparation.whilePreparing { + await withCheckedContinuation { continuation = $0 } + } + XCTFail("Canceled preparation must not advance to confirmation") + } catch is CancellationError { + } catch { + XCTFail("Unexpected preparation error: \(error)") + } + preparationFinished = true + } + } + defer { continuation?.resume() } + try await waitUntil { continuation != nil } + + let visible = preparation.visibleRequest(manager: manager, session: session, paymentContext: nil) + XCTAssertEqual(visible, request) + XCTAssertEqual(visible?.note, "Dinner") + XCTAssertEqual(manager.requestsForPresentation(), [request]) + XCTAssertFalse(manager.isApprovedForPayment(request)) + + task.cancel() + try await waitUntil { preparation.request == nil } + XCTAssertFalse(preparationFinished) + continuation?.resume() + continuation = nil + _ = await task.value + XCTAssertNil(preparation.request) + XCTAssertEqual(manager.requestsForPresentation(), [request]) + } + } + + func testCanceledPreparationThrowsBeforeScheduledCleanup() async throws { + let manager = try paymentRequestManager(sdk: PaymentRequestSdkMock(records: [paymentRequestRecord()])) + await manager.refresh() + let request = try XCTUnwrap(manager.pendingRequests.first) + let preparation = IncomingPaykitPaymentRequestPreparation(request: request, session: nil) + let task = Task { + do { + try await preparation.whilePreparing { + withUnsafeCurrentTask { $0?.cancel() } + } + XCTFail("Canceled work returned before its scheduled UI cleanup") + } catch is CancellationError { + } catch { + XCTFail("Unexpected preparation error: \(error)") + } + } + await task.value + } + + func testClosingPreparingSheetIgnoresLateCompletionAndLeavesNextRequestAvailable() async throws { + for manuallyRequested in [false, true] { + let sdk = try PaymentRequestSdkMock(records: [paymentRequestRecord(id: "first"), paymentRequestRecord(id: "second")]) + let store = PaymentRequestPresentationMemoryStore() + let manager = paymentRequestManager(sdk: sdk, presentationStore: store) + await manager.refresh() + let request = try XCTUnwrap(manager.pendingRequests.first { $0.paymentRequestId == "first" }) + let next = try XCTUnwrap(manager.pendingRequests.first { $0.paymentRequestId == "second" }) + if manuallyRequested { XCTAssertTrue(manager.requestPresentation(request)) } + let profile = PubkyProfileManager() + profile.publicKey = "pubky\(String(repeating: "z", count: 52))" + let session = try XCTUnwrap(profile.currentSession) + let preparation = IncomingPaykitPaymentRequestPreparation(request: request, session: session) + let sheets = SheetViewModel() + let app = AppViewModel() + let identity = try XCTUnwrap(profile.publicKey) + let context = ContactPaymentContext(publicKey: request.counterparty, incomingPaymentRequest: request) + XCTAssertTrue(app.claimContactPaymentContext(context)) + preparation.paymentContext = context + sheets.showSheet(.send, data: SendConfig(view: .confirm, preparation: preparation, onDismiss: { + manager.dismissPreparingRequest(request) + preparation.clear() + })) + var continuation: CheckedContinuation? + let task = Task { + await manager.presentRequests { _ in + if manuallyRequested { + XCTAssertEqual(manager.deferPresentation(request), .retryScheduled) + } + await withCheckedContinuation { continuation = $0 } + XCTAssertFalse(preparation.complete(route: .confirm, manager: manager, session: session, app: app, sheets: sheets)) + } + } + try await waitUntil { continuation != nil } + XCTAssertEqual(preparation.visibleRequest(manager: manager, session: session, paymentContext: context), request) + + if manuallyRequested { + sheets.hideSheet() + } else { + sheets.sendSheetItem = nil + } + app.resetSendState() + XCTAssertEqual(Set(manager.pendingRequests.map(\.id)), [request.id, next.id]) + XCTAssertEqual(manager.requestsForPresentation(), [next]) + XCTAssertNil(manager.requestedPresentationId) + XCTAssertTrue(try store.load(identity: identity).isEmpty) + XCTAssertFalse(manager.isApprovedForPayment(request)) + + continuation?.resume() + _ = await task.value + await manager.presentRequests { XCTAssertEqual($0, [next]) } + await manager.refresh() + XCTAssertEqual(manager.requestsForPresentation(), [next]) + XCTAssertTrue(try store.load(identity: identity).isEmpty) + + let restored = paymentRequestManager(sdk: sdk, presentationStore: store) + await restored.refresh() + XCTAssertEqual(Set(restored.requestsForPresentation().map(\.id)), [request.id, next.id]) + XCTAssertTrue(manager.requestPresentation(request)) + XCTAssertEqual(manager.requestsForPresentation(), [request]) + await manager.presentRequests { _ in manager.dismissPreparingRequest(request) } + manager.activate(identity: "pubky\(String(repeating: "y", count: 52))") + manager.activate(identity: identity) + await manager.refresh() + XCTAssertEqual(Set(manager.requestsForPresentation().map(\.id)), [request.id, next.id]) + } + } + + func testPreparingRequestBecomesReadyInTheSameSendSheetAndPreservesExplicitRetry() async throws { + let store = PaymentRequestPresentationMemoryStore() + let clock = PaymentRequestTestClock(Date()) + let manager = try paymentRequestManager(sdk: PaymentRequestSdkMock(records: [paymentRequestRecord()]), clock: clock, presentationStore: store) + await manager.refresh() + let request = try XCTUnwrap(manager.pendingRequests.first) + let profile = PubkyProfileManager() + profile.publicKey = "pubky\(String(repeating: "z", count: 52))" + let session = try XCTUnwrap(profile.currentSession) + let preparation = IncomingPaykitPaymentRequestPreparation(request: request, session: session) + let sheets = SheetViewModel() + let app = AppViewModel() + sheets.showSheet(.send, data: SendConfig(view: .confirm, preparation: preparation, onDismiss: { + if preparation.resolvedRoute == nil, preparation.matchesSession(profile.currentSession), let request = preparation.request { + manager.dismissPreparingRequest(request) + } + preparation.clear() + })) + let presentationID = sheets.activeSheetConfiguration?.presentationID + XCTAssertTrue(sheets.sendSheetItem?.preparation === preparation) + XCTAssertNil(app.contactPaymentContext) + XCTAssertFalse(app.hasSendPaymentTarget) + + for _ in 0 ..< 2 { + await manager.presentRequests { _ in + XCTAssertEqual(manager.deferPresentation(request), .retryScheduled) + XCTAssertFalse(manager.isCurrentPresentation(request)) + XCTAssertEqual(preparation.visibleRequest(manager: manager, session: session, paymentContext: nil), request) + } + XCTAssertEqual(preparation.visibleRequest(manager: manager, session: session, paymentContext: nil), request) + XCTAssertEqual(sheets.activeSheetConfiguration?.presentationID, presentationID) + XCTAssertFalse(manager.isApprovedForPayment(request)) + XCTAssertTrue(try store.load(identity: "pubky\(String(repeating: "z", count: 52))").isEmpty) + clock.advance(by: 2) + } + + await manager.presentRequests { _ in + XCTAssertFalse(preparation.complete(route: .confirm, manager: manager, session: session, app: app, sheets: sheets)) + let context = ContactPaymentContext(publicKey: request.counterparty, incomingPaymentRequest: request) + XCTAssertTrue(app.claimContactPaymentContext(context)) + preparation.paymentContext = context + let otherProfile = PubkyProfileManager() + otherProfile.publicKey = "pubky\(String(repeating: "y", count: 52))" + XCTAssertFalse(preparation.complete(route: .confirm, manager: manager, session: otherProfile.currentSession, app: app, sheets: sheets)) + app.scannedOnchainInvoice = OnChainInvoice( + address: "bcrt1qrequest", + amountSatoshis: request.amountSats, + label: nil, + message: nil, + params: nil + ) + XCTAssertTrue(preparation.complete(route: .confirm, manager: manager, session: session, app: app, sheets: sheets)) } + XCTAssertEqual(preparation.resolvedRoute, .confirm) + XCTAssertEqual(sheets.activeSheetConfiguration?.presentationID, presentationID) + XCTAssertTrue(sheets.sendSheetItem?.preparation === preparation) + XCTAssertEqual(manager.requestsForPresentation(), [request]) + XCTAssertFalse(manager.isApprovedForPayment(request)) + XCTAssertTrue(try store.load(identity: "pubky\(String(repeating: "z", count: 52))").isEmpty) + XCTAssertTrue(manager.markPresentedIfPending(request)) + XCTAssertEqual(try store.load(identity: "pubky\(String(repeating: "z", count: 52))"), [request.id]) + + app.resetSendState() + XCTAssertTrue(manager.requestPresentation(request)) + sheets.hideSheet(reason: "Retrying incoming payment request with fresh private payment details") - XCTAssertTrue(wasCurrentBeforeClear) - XCTAssertFalse(wasCurrentAfterClear) + XCTAssertNil(preparation.request) + XCTAssertNil(sheets.activeSheetConfiguration) + XCTAssertEqual(manager.requestedPresentationId, request.id) + XCTAssertEqual(manager.requestsForPresentation(), [request]) + XCTAssertFalse(manager.isApprovedForPayment(request)) + XCTAssertEqual(try store.load(identity: "pubky\(String(repeating: "z", count: 52))"), [request.id]) + } + + func testManualPaymentRetainsUnrelatedReminderThroughConfirmationAndRetry() async throws { + defer { PaykitSubscriptionNotificationTargetStore.clear() } + let now = try XCTUnwrap(ISO8601DateFormatter().date(from: "2027-01-15T08:00:00Z")) + let manualRecord = try paymentRequestRecord(id: "manual") + let reminderRecord = try paymentRequestRecord( + id: "reminder", counterparty: "pubky\(String(repeating: "y", count: 52))", state: .activeRecurring, + recurrence: PaymentRequestRecurrence(every: 1, unit: "month", startsAt: timestamp(now), anchor: timestamp(now), endsAt: nil) + ) + let target = try XCTUnwrap(PaykitSubscriptionNotificationTarget(userInfo: [ + "payer_identity": "pubky\(String(repeating: "z", count: 52))", + "payment_request_id": reminderRecord.paymentRequestId, + "counterparty": reminderRecord.counterparty, + "billing_period_starts_at": PaykitSubscriptionTimestamp.string(from: now), + ])) + let sdk = PaymentRequestSdkMock(records: [manualRecord]) + let manager = paymentRequestManager(sdk: sdk, clock: PaymentRequestTestClock(now)) + await manager.refresh(mode: .stored) + let request = try XCTUnwrap(manager.pendingRequests.first) + let sheets = SheetViewModel() + let app = AppViewModel() + PaykitSubscriptionNotificationTargetStore.save(target) + XCTAssertTrue(manager.requestPresentation(request)) + + await IncomingPaykitPaymentRequestPresentationDispatcher.presentNextItem( + manager: manager, sheets: sheets, canRetryPreparation: false, + handleSubscriptionNotification: { XCTFail("A reminder must not block explicit Pay") }, + presentPaymentRequest: { + XCTAssertEqual(manager.requestsForPresentation(), [request]) + sheets.showSheet(.send, data: SendConfig(view: .confirm)) + } + ) + XCTAssertEqual(sheets.activeSheetConfiguration?.id, .send) + XCTAssertEqual(PaykitSubscriptionNotificationTargetStore.load(), target) + + var retriedPreparation = false + await IncomingPaykitPaymentRequestPresentationDispatcher.presentNextItem( + manager: manager, sheets: sheets, canRetryPreparation: true, + handleSubscriptionNotification: { XCTFail("The preparing payment still owns the sheet") }, + presentPaymentRequest: { retriedPreparation = true } + ) + XCTAssertTrue(retriedPreparation) + XCTAssertTrue(app.claimContactPaymentContext(ContactPaymentContext(publicKey: request.counterparty, incomingPaymentRequest: request))) + XCTAssertTrue(manager.markPresentedIfPending(request)) + + await sdk.setRecords([manualRecord, reminderRecord]) + await manager.refresh(mode: .stored) + let reminder = try XCTUnwrap(manager.pendingRequests.first(where: target.matches)) + XCTAssertFalse(IncomingPaykitPaymentRequestPresentationDispatcher.canHandleSubscriptionNotification( + manager: manager, + app: app, + sheets: sheets + )) + await IncomingPaykitPaymentRequestPresentationDispatcher.presentNextItem( + manager: manager, sheets: sheets, canRetryPreparation: false, + handleSubscriptionNotification: { XCTFail("A ready reminder must not replace confirmation") }, + presentPaymentRequest: { XCTFail("Confirmation must retain its payment") } + ) + XCTAssertEqual(PaykitSubscriptionNotificationTargetStore.load(), target) + + app.resetSendState() + XCTAssertTrue(manager.requestPresentation(request)) + sheets.hideSheet(reason: "Retrying incoming payment request with fresh private payment details") + await IncomingPaykitPaymentRequestPresentationDispatcher.presentNextItem( + manager: manager, sheets: sheets, canRetryPreparation: false, + handleSubscriptionNotification: { XCTFail("Explicit retry must keep priority over the ready reminder") }, + presentPaymentRequest: { + XCTAssertEqual(manager.requestsForPresentation(), [request]) + sheets.showSheet(.send, data: SendConfig(view: .confirm, onDismiss: { manager.dismissPreparingRequest(request) })) + } + ) + XCTAssertEqual(sheets.activeSheetConfiguration?.id, .send) + XCTAssertEqual(PaykitSubscriptionNotificationTargetStore.load(), target) + + sheets.hideSheet() + XCTAssertTrue(IncomingPaykitPaymentRequestPresentationDispatcher.canHandleSubscriptionNotification( + manager: manager, + app: app, + sheets: sheets + )) + await IncomingPaykitPaymentRequestPresentationDispatcher.presentNextItem( + manager: manager, sheets: sheets, canRetryPreparation: false, + handleSubscriptionNotification: { + XCTAssertEqual(PaykitSubscriptionNotificationTargetStore.load(), target) + XCTAssertTrue(manager.requestPresentation(reminder)) + sheets.showSheet(.send, data: SendConfig(view: .confirm)) + }, + presentPaymentRequest: { XCTFail("The reminder handler owns its presentation") } + ) + XCTAssertEqual(manager.requestedPresentationId, reminder.id) + XCTAssertNil(PaykitSubscriptionNotificationTargetStore.load()) + } + + func testSubscriptionNotificationWaitsForExplicitSelectionAndActivePaymentOwnership() async throws { + let manager = try paymentRequestManager(sdk: PaymentRequestSdkMock(records: [paymentRequestRecord()])) + await manager.refresh(mode: .stored) + let request = try XCTUnwrap(manager.pendingRequests.first) + let sheets = SheetViewModel() + let app = AppViewModel() + func canHandleNotification() -> Bool { + IncomingPaykitPaymentRequestPresentationDispatcher.canHandleSubscriptionNotification(manager: manager, app: app, sheets: sheets) + } + XCTAssertTrue(canHandleNotification()) + XCTAssertTrue(manager.requestPresentation(request)) + XCTAssertFalse(canHandleNotification()) + manager.dismissPreparingRequest(request) + XCTAssertTrue(canHandleNotification()) + + XCTAssertTrue(app.claimContactPaymentContext(ContactPaymentContext(publicKey: request.counterparty, incomingPaymentRequest: request))) + XCTAssertFalse(canHandleNotification()) + app.resetSendState() + XCTAssertTrue(canHandleNotification()) + for sheet in [SheetID.send, .scanner] { + sheets.showSheet(sheet) + XCTAssertFalse(canHandleNotification()) + await IncomingPaykitPaymentRequestPresentationDispatcher.presentNextItem( + manager: manager, sheets: sheets, canRetryPreparation: false, + handleSubscriptionNotification: { XCTFail("The active sheet must retain ownership") }, + presentPaymentRequest: { XCTFail("The active sheet must retain ownership") } + ) + XCTAssertEqual(sheets.activeSheetConfiguration?.id, sheet) + sheets.hideSheet() + XCTAssertTrue(canHandleNotification()) + } } func testManualPresentationSupersedesInFlightAutomaticPresentation() async throws { @@ -3721,8 +5201,12 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { let manager = paymentRequestManager(sdk: PaymentRequestSdkMock(records: [first, second])) await manager.refresh() let secondRequest = try XCTUnwrap(manager.pendingRequests.first { $0.paymentRequestId == "second" }) + let profile = PubkyProfileManager() + profile.publicKey = "pubky\(String(repeating: "z", count: 52))" + let session = try XCTUnwrap(profile.currentSession) var continuation: CheckedContinuation? var automaticPresentationRemainedCurrent = true + var preparation: IncomingPaykitPaymentRequestPreparation? let task = Task { await manager.presentRequests { requests in @@ -3730,13 +5214,16 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { XCTFail("Expected an incoming payment request") return } + preparation = IncomingPaykitPaymentRequestPreparation(request: automaticRequest, session: session) await withCheckedContinuation { continuation = $0 } automaticPresentationRemainedCurrent = manager.isCurrentPresentation(automaticRequest) } } try await waitUntil { continuation != nil } + XCTAssertNotNil(preparation?.visibleRequest(manager: manager, session: session, paymentContext: nil)) XCTAssertTrue(manager.requestPresentation(secondRequest)) + XCTAssertNil(preparation?.visibleRequest(manager: manager, session: session, paymentContext: nil)) continuation?.resume() _ = await task.value @@ -3807,8 +5294,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 +5309,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 +5322,349 @@ 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 testPreparedOneTimePaymentRequiresAcceptedHistory() async throws { + let cases: [(PaymentRequestRecord?, Bool)] = try [ + (paymentRequestRecord(state: .accepted), true), + (paymentRequestRecord(state: .canceled), false), + (paymentRequestRecord(state: .proofSubmitted), false), + (paymentRequestRecord(state: .rejected), false), + (nil, false), + (paymentRequestRecord(state: .recoveryRequired, acceptedEventId: "accepted"), true), + (paymentRequestRecord(state: .recoveryRequired), false), + (paymentRequestRecord(state: .recoveryRequired, acceptedEventId: "accepted", canceledEventId: "canceled"), false), + (paymentRequestRecord(state: .recoveryRequired, acceptedEventId: "accepted", rejectedEventId: "rejected"), false), + (paymentRequestRecord(state: .recoveryRequired, acceptedEventId: "accepted", paymentProofs: [ + paymentProofRecord(endpoint: "btc-lightning-bolt11", kind: .lightning), + ]), false), + ] + for (record, isAccepted) in cases { + let sdk = try PaymentRequestSdkMock(records: [paymentRequestRecord()]) + let manager = paymentRequestManager(sdk: sdk) + await manager.refresh() + let request = try XCTUnwrap(manager.pendingRequests.first) + try await manager.prepareForPayment(request) + XCTAssertTrue(manager.isApprovedForPayment(request)) + XCTAssertTrue(manager.pendingRequests.isEmpty) + await sdk.pauseNextLinkedPeers() + + let authorization = Task { try await manager.ensurePaymentAllowed(request) } + try await waitUntil { await sdk.linkedPeersIsPaused() } + let approvalChanged = isAccepted ? nil : expectation(description: "Prepared request history changed") + XCTAssertTrue(withObservationTracking { + manager.isApprovedForPayment(request) + } onChange: { + approvalChanged?.fulfill() + }) + let records = record.map { [$0] } ?? [] + await sdk.setRecords(records) + await manager.refresh(mode: .stored) + if let approvalChanged { + await fulfillment(of: [approvalChanged], timeout: 1) + } + XCTAssertTrue(manager.pendingRequests.isEmpty) + XCTAssertEqual(manager.isApprovedForPayment(request), isAccepted, "\(String(describing: record?.state))") + for root in [SendRoute.confirm, .lnurlPayConfirm] { + let isAvailable = manager.isApprovedForPayment(request) + XCTAssertEqual(SendSheet.shouldDismissUnavailableRequest( + root: root, path: [], isSubmittingPayment: false, isAvailable: isAvailable + ), !isAccepted) + XCTAssertFalse(SendSheet.shouldDismissUnavailableRequest( + root: root, path: [], isSubmittingPayment: true, isAvailable: isAvailable + )) + let retainedRoutes: [SendRoute] = [ + .pending(paymentHash: nil, retryRoute: .confirm, paymentRequest: nil), + .success(paymentId: "payment"), + .failure(SendFailureContext(error: PaykitPaymentRequestError.requestUnavailable, retryRoute: .confirm)), + .hardwareSign, + .quickpay, + ] + for result in retainedRoutes { + XCTAssertFalse(SendSheet.shouldDismissUnavailableRequest( + root: root, path: [result], isSubmittingPayment: false, isAvailable: isAvailable + )) + XCTAssertFalse(SendSheet.shouldDismissUnavailableRequest( + root: result, path: [], isSubmittingPayment: false, isAvailable: isAvailable + )) + } + } + await sdk.resumeLinkedPeers() + + do { + try await authorization.value + XCTAssertTrue(isAccepted, "Refreshed one-time history must still be accepted") + } catch { + XCTAssertFalse(isAccepted) + XCTAssertEqual(error as? PaykitPaymentRequestError, .requestUnavailable) + } + guard !isAccepted else { continue } + + let peerReads = await sdk.linkedPeersCalls() + do { + try await manager.ensurePaymentAllowed(request) + XCTFail("Unavailable history must not authorize payment") + } catch { + XCTAssertEqual(error as? PaykitPaymentRequestError, .requestUnavailable) + } + let peerReadsAfterRejection = await sdk.linkedPeersCalls() + XCTAssertEqual(peerReadsAfterRejection, peerReads) + do { + try await manager.prepareForPayment(request) { + XCTFail("Unavailable history must not consume another payment destination") + } + XCTFail("Unavailable history must not be prepared again") + } catch { + XCTAssertEqual(error as? PaykitPaymentRequestError, .requestUnavailable) + } + } + } + + func testIdentitySwitchPreventsExecutionOfPreparedProposal() async throws { + for switchDuringAuthorization in [false, true] { + let sdk = try PaymentRequestSdkMock(records: [paymentRequestRecord()]) + 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) @@ -3875,16 +5707,16 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { let manager = paymentRequestManager(sdk: sdk) await manager.refresh() let request = try XCTUnwrap(manager.pendingRequests.first) - await sdk.pauseNextProcess() + await sdk.pauseNextAccept() let acceptance = Task { try await manager.prepareForPayment(request) } - try await waitUntil { await sdk.processIsPaused() } + try await waitUntil { await sdk.acceptIsPaused() } await manager.refresh() XCTAssertEqual(manager.pendingRequests, [request]) XCTAssertFalse(manager.isApprovedForPayment(request)) - await sdk.resumeProcess() + await sdk.resumeAccept() try await acceptance.value XCTAssertTrue(manager.pendingRequests.isEmpty) XCTAssertTrue(manager.isApprovedForPayment(request)) @@ -3924,26 +5756,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() + await manager.refresh(messagePriority: .background) 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,10 +5784,11 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { snapshot.acceptedRequests, [PaymentRequestInvocation( counterparty: request.counterparty, - counterpartyReceiverPath: request.counterpartyReceiverPath, paymentRequestId: request.paymentRequestId )] ) + let priorities = await sdk.operationPriorities + XCTAssertEqual(priorities["pending"], [.background]) } func testAcceptRechecksExpirationImmediatelyBeforeAction() async throws { @@ -3985,34 +5812,46 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { XCTAssertTrue(snapshot.acceptedRequests.isEmpty) } - func testQueuedAcceptanceSucceedsWhenImmediateDeliveryFails() async throws { - let sdk = try PaymentRequestSdkMock(records: [paymentRequestRecord()]) - let manager = paymentRequestManager(sdk: sdk) - await manager.refresh() - let request = try XCTUnwrap(manager.pendingRequests.first) - await sdk.failNextProcess() - - try await manager.prepareForPayment(request) - - XCTAssertTrue(manager.pendingRequests.isEmpty) - let snapshot = await sdk.snapshot() - XCTAssertEqual(snapshot.acceptedRequests.map(\.paymentRequestId), [request.paymentRequestId]) - XCTAssertEqual(snapshot.processCallCount, 2) - } - - func testQueuedAcceptanceSucceedsWhenImmediateDeliveryIsCancelled() async throws { - let sdk = try PaymentRequestSdkMock(records: [paymentRequestRecord()]) - let manager = paymentRequestManager(sdk: sdk) - await manager.refresh() - let request = try XCTUnwrap(manager.pendingRequests.first) - await sdk.cancelNextProcess() - - try await manager.prepareForPayment(request) - - XCTAssertTrue(manager.pendingRequests.isEmpty) - let snapshot = await sdk.snapshot() - XCTAssertEqual(snapshot.acceptedRequests.map(\.paymentRequestId), [request.paymentRequestId]) - XCTAssertEqual(snapshot.processCallCount, 2) + func testAcceptanceDefersScopedDeliveryWithoutDrainingMessagesAndDropsWorkAfterIdentityChanges() async throws { + let now = try XCTUnwrap(ISO8601DateFormatter().date(from: "2027-01-15T08:00:00Z")) + let recurrence = PaymentRequestRecurrence( + every: 1, unit: "month", startsAt: "2027-01-01T08:00:00Z", anchor: "2027-01-01T08:00:00Z", endsAt: nil + ) + for (recurring, changesIdentity) in [(false, false), (false, true), (true, false), (true, true)] { + let record = try paymentRequestRecord(recurrence: recurring ? recurrence : nil) + let sdk = PaymentRequestSdkMock(records: [record]) + let activity = PaykitPaymentActivity() + let payment = activity.begin() + defer { activity.end(payment) } + var scheduledPeers: [String] = [] + let scheduled = expectation(description: "Acceptance delivery scheduled") + scheduled.isInverted = changesIdentity + let manager = paymentRequestManager(sdk: sdk, scheduleAcceptedRequestDelivery: { + scheduledPeers.append($0) + scheduled.fulfill() + }, paymentActivity: activity, clock: PaymentRequestTestClock(now)) + await manager.refresh(mode: .stored) + if recurring { + let dueRequest = try await manager.accept(XCTUnwrap(manager.subscriptions.first)) + XCTAssertNotNil(dueRequest) + XCTAssertEqual(manager.subscriptions.first?.lifecycleState, .activeRecurring) + } else { + let request = try XCTUnwrap(manager.pendingRequests.first) + try await manager.prepareForPayment(request) + XCTAssertTrue(manager.pendingRequests.isEmpty) + XCTAssertTrue(manager.isApprovedForPayment(request)) + } + let snapshot = await sdk.snapshot() + XCTAssertEqual(snapshot.acceptedRequests.map(\.paymentRequestId), [record.paymentRequestId]) + XCTAssertEqual(snapshot.processCallCount, 0) + XCTAssertTrue(snapshot.processedCounterparties.isEmpty) + XCTAssertTrue(scheduledPeers.isEmpty) + + if changesIdentity { manager.clear() } + activity.end(payment) + await fulfillment(of: [scheduled], timeout: changesIdentity ? 0.1 : 2) + XCTAssertEqual(scheduledPeers, changesIdentity ? [] : [record.counterparty]) + } } func testAcceptInvalidatesAnOverlappingRefreshSnapshot() async throws { @@ -4026,8 +5865,9 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { try await waitUntil { await sdk.paymentRequestListIsPaused() } try await manager.prepareForPayment(request) await sdk.resumePaymentRequestList() - await refreshTask.value + let refreshed = await refreshTask.value + XCTAssertFalse(refreshed) XCTAssertTrue(manager.pendingRequests.isEmpty) } @@ -4055,8 +5895,10 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { let store = PaymentRequestPresentationMemoryStore() let subscriptionStore = PaymentRequestSubscriptionStateMemoryStore() let firstManager = PaykitPaymentRequestManager( + scheduleAcceptedRequestDelivery: { _ in }, service: PaykitPaymentRequestService(sdk: sdk, logWarning: { _ in }), presentationStore: store, + acceptanceStore: PaymentRequestPresentationMemoryStore(), subscriptionStateStore: subscriptionStore, logWarning: { _ in } ) @@ -4066,8 +5908,10 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { XCTAssertTrue(firstManager.markPresentedIfPending(request)) let restoredManager = PaykitPaymentRequestManager( + scheduleAcceptedRequestDelivery: { _ in }, service: PaykitPaymentRequestService(sdk: sdk, logWarning: { _ in }), presentationStore: store, + acceptanceStore: PaymentRequestPresentationMemoryStore(), subscriptionStateStore: subscriptionStore, logWarning: { _ in } ) @@ -4093,8 +5937,10 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { let presentationStore = PaymentRequestPresentationMemoryStore() let subscriptionStore = PaymentRequestSubscriptionStateMemoryStore() let firstManager = PaykitPaymentRequestManager( + scheduleAcceptedRequestDelivery: { _ in }, service: PaykitPaymentRequestService(sdk: sdk, logWarning: { _ in }), presentationStore: presentationStore, + acceptanceStore: PaymentRequestPresentationMemoryStore(), subscriptionStateStore: subscriptionStore, logWarning: { _ in } ) @@ -4104,8 +5950,10 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { firstManager.markSubscriptionProposalPresented(subscription) let restoredManager = PaykitPaymentRequestManager( + scheduleAcceptedRequestDelivery: { _ in }, service: PaykitPaymentRequestService(sdk: sdk, logWarning: { _ in }), presentationStore: presentationStore, + acceptanceStore: PaymentRequestPresentationMemoryStore(), subscriptionStateStore: subscriptionStore, logWarning: { _ in } ) @@ -4137,19 +5985,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 +6006,90 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { XCTAssertEqual( manager.eligibleTargets, - [PaykitPaymentRequestTarget(publicKey: savedKey, receiverPath: PaykitReceiverPath.server)] + [PaykitPaymentRequestTarget(publicKey: savedKey)] ) + let priorities = await sdk.operationPriorities + XCTAssertEqual(priorities["identity"], [.background]) + XCTAssertEqual(priorities["peers"], [.background]) + } + + 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 +6103,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 +6118,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 +6130,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 +6147,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 +6167,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 +6235,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 +6253,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 +6265,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 +6282,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 +6297,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 +6314,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 +6348,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 +6364,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 +6435,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 +6448,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 +6459,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 +6473,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 +6490,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 +6502,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 +6512,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 +6523,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 +6542,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 +6556,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 +6610,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 +6621,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 +6630,7 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { XCTAssertEqual( manager.eligibleTargets, - [PaykitPaymentRequestTarget(publicKey: secondKey, receiverPath: PaykitReceiverPath.wallet)] + [PaykitPaymentRequestTarget(publicKey: secondKey)] ) } @@ -4601,13 +6639,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 +6662,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 +6672,315 @@ 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]) + let priorities = await sdk.operationPriorities + XCTAssertEqual(priorities["identity"], [.background, .interactive]) + XCTAssertEqual(priorities["peers"], [.background, .interactive]) + XCTAssertEqual(priorities["delivery"], [.interactive]) } - 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 +6988,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 +7001,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 +7021,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 +7046,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 +7081,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 +7122,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 +7135,6 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { await sdk.setProcessReports([ OutboundPrivateCounterpartySendReport( counterparty: publicKey, - counterpartyReceiverPath: PaykitReceiverPath.wallet, report: OutboundPrivateSendReport( attempted: [messageId], sent: [messageId], @@ -4865,8 +7174,10 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { let sdk = try PaymentRequestSdkMock(records: [paymentRequestRecord()]) let store = PaymentRequestPresentationMemoryStore() let manager = PaykitPaymentRequestManager( + scheduleAcceptedRequestDelivery: { _ in }, service: PaykitPaymentRequestService(sdk: sdk, logWarning: { _ in }), presentationStore: store, + acceptanceStore: PaymentRequestPresentationMemoryStore(), subscriptionStateStore: PaymentRequestSubscriptionStateMemoryStore(), isAvailable: { true }, logWarning: { _ in } @@ -4890,6 +7201,8 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { private func paymentRequestManager( sdk: PaymentRequestSdkMock, + scheduleAcceptedRequestDelivery: @escaping @MainActor (String) async -> Void = { _ in }, + paymentActivity: PaykitPaymentActivity? = nil, clock: PaymentRequestTestClock = PaymentRequestTestClock(Date()), subscriptionClock: PaymentRequestTestClock? = nil, subscriptionStateStore: PaymentRequestSubscriptionStateMemoryStore = PaymentRequestSubscriptionStateMemoryStore(), @@ -4897,11 +7210,19 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { isPrivatePaymentPublishingEnabled: Bool = true, completedPaymentProofKinds: [PaykitPaymentRequest.ID: PaykitPaymentProofKind] = [:], inFlightPaymentRequestIds: Set = [], - protectedRequestIdsForSubscriptionCancellation: Set = [] + protectedRequestIdsForSubscriptionCancellation: Set = [], + presentationStore: PaymentRequestPresentationMemoryStore = PaymentRequestPresentationMemoryStore(), + 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( + scheduleAcceptedRequestDelivery: scheduleAcceptedRequestDelivery, + paymentActivity: paymentActivity ?? PaykitPaymentActivity(), service: PaykitPaymentRequestService( sdk: sdk, now: now, @@ -4909,7 +7230,8 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { isPrivatePaymentPublishingEnabled: { isPrivatePaymentPublishingEnabled }, logWarning: { _ in } ), - presentationStore: PaymentRequestPresentationMemoryStore(), + presentationStore: presentationStore, + acceptanceStore: acceptanceStore, subscriptionStateStore: subscriptionStateStore, subscriptionNotificationScheduler: subscriptionNotificationScheduler, completedPaymentProofKinds: { _ in completedPaymentProofKinds }, @@ -4947,7 +7269,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 +7277,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 +7289,6 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { ) throws -> PaymentRequestRecord { try PaymentRequestRecord( counterparty: counterparty, - counterpartyReceiverPath: counterpartyReceiverPath, paymentRequestId: id, localRole: role, state: state, @@ -4975,12 +7296,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 +7339,7 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { streamItemId: 2, paymentReference: PaymentReference(text: "invoice-123"), billingPeriod: billingPeriod, + paymentAppId: "bitkit", paymentEndpointIdentifier: endpoint, allowanceId: nil, conversionQuoteId: nil, @@ -5027,10 +7354,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 +7370,22 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { } } -private final class PaymentRequestPresentationMemoryStore: PaykitPaymentRequestPresentationStoring { +private final class PaymentRequestPresentationMemoryStore: PaykitPaymentRequestIdStoring { private var states: [String: Set] = [:] + var shouldFailSave = false + var shouldFailLoad = false + + init(ids: Set = []) { + states["pubky\(String(repeating: "z", count: 52))"] = ids + } - func load(identity: String) -> Set { - states[identity] ?? [] + func load(identity: String) throws -> Set { + if shouldFailLoad { throw PaymentRequestSdkMockError.process } + return states[identity] ?? [] } - func save(_ ids: Set, identity: String) { + func save(_ ids: Set, identity: String) throws { + if shouldFailSave { throw PaymentRequestSdkMockError.process } states[identity] = ids } } @@ -5092,27 +7426,36 @@ private final class PaymentRequestSubscriptionStateMemoryStore: PaykitSubscripti } private actor PaymentRequestSdkMock: PaykitPaymentRequestSdkHandling, PaykitPaymentProofSdkHandling { + private(set) var operationPriorities: [String: [PaykitSdkOperationLock.Priority]] = [:] private var activeIdentity = "pubky\(String(repeating: "z", count: 52))" private var records: [PaymentRequestRecord] + private var incomingRecords: [PaymentRequestRecord] = [] + 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 +7463,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 @@ -5140,8 +7485,33 @@ private actor PaymentRequestSdkMock: PaykitPaymentRequestSdkHandling, PaykitPaym } func processPendingPrivateMessages() async throws -> [OutboundPrivateCounterpartySendReport] { + try await processPendingPrivateMessages(priority: .ordered) + } + + func processPendingPrivateMessages(priority: PaykitSdkOperationLock.Priority) async throws -> [OutboundPrivateCounterpartySendReport] { + operationPriorities["pending", default: []].append(priority) processCallCount += 1 - 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: [] + ) + } + + func processOutboundPrivateMessages(counterparty: String, priority: PaykitSdkOperationLock.Priority) async throws -> OutboundPrivateSendReport { + operationPriorities["delivery", default: []].append(priority) + return try await processOutboundPrivateMessages(counterparty: counterparty) + } + + private func processMessages(counterparty: String? = nil) async throws { + if shouldPauseNextProcess, + counterparty == nil || pausedProcessCounterparty == nil || counterparty == pausedProcessCounterparty + { shouldPauseNextProcess = false isProcessPaused = true await withCheckedContinuation { processContinuation = $0 } @@ -5151,20 +7521,28 @@ 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 } - func receivePrivateMessagesFromLinkedPeers() throws -> [PrivateStreamCounterpartyIntakeReport] { + func receivePrivateMessagesFromLinkedPeers(priority: PaykitSdkOperationLock.Priority) throws -> [PrivateStreamCounterpartyIntakeReport] { + operationPriorities["receive", default: []].append(priority) receiveCallCount += 1 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 +7553,34 @@ private actor PaymentRequestSdkMock: PaykitPaymentRequestSdkHandling, PaykitPaym return snapshot } + func sharedPaymentRequests(priority: PaykitSdkOperationLock.Priority) async -> [PaymentRequestRecord] { + operationPriorities["requests", default: []].append(priority) + return await sharedPaymentRequests() + } + + func sharedPaymentRequests(expectedIdentity: String) async throws -> [PaymentRequestRecord] { + guard PubkyPublicKeyFormat.matches(activeIdentity, expectedIdentity) else { + throw PaykitPaymentRequestError.requestUnavailable + } + 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 identityStatus(priority: PaykitSdkOperationLock.Priority) -> IdentityStatus? { + operationPriorities["identity", default: []].append(priority) + return identityStatus() } func submitPaymentProof( counterparty _: String, - counterpartyReceiverPath _: String, paymentRequestId _: String, proof _: PaymentProofSubmission ) throws -> PaymentRequestRecord { @@ -5191,9 +7590,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 +7598,26 @@ 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 linkedPeers(priority: PaykitSdkOperationLock.Priority) async throws -> [LinkedPeerRecord] { + operationPriorities["peers", default: []].append(priority) + return try await linkedPeers() + } + + func canReceivePaymentRequests(publicKey: String, priority: PaykitPublicReadPriority) async throws -> Bool { + capabilityLookupPublicKeys.append(publicKey) + capabilityLookupPriorities.append(priority) + 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 +7649,6 @@ private actor PaymentRequestSdkMock: PaykitPaymentRequestSdkHandling, PaykitPaym func proposePaymentRequest( counterparty: String, - counterpartyReceiverPath: String, terms: PaymentRequestTerms, expectedIdentity: String ) async throws -> PaymentRequestRecord { @@ -5259,12 +7665,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 +7679,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 +7701,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 +7714,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 +7731,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 +7744,6 @@ private actor PaymentRequestSdkMock: PaykitPaymentRequestSdkHandling, PaykitPaym } rejectedRequests.append(PaymentRequestInvocation( counterparty: counterparty, - counterpartyReceiverPath: counterpartyReceiverPath, paymentRequestId: paymentRequestId )) return record @@ -5340,13 +7751,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 +7764,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 +7824,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 +7844,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 +7918,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 +7929,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 +7944,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 +7954,6 @@ private struct PaymentRequestSdkSnapshot { private struct ProposedPaymentRequestInvocation { let counterparty: String - let counterpartyReceiverPath: String let amount: String let asset: String let expiresAt: String? @@ -5541,7 +7964,6 @@ private struct ProposedPaymentRequestInvocation { private struct PaymentRequestInvocation: Equatable { let counterparty: String - let counterpartyReceiverPath: String let paymentRequestId: String } @@ -5714,11 +8136,11 @@ private actor HardwarePaymentProofMemoryStore: PaykitPaymentProofStoring { } private struct HardwarePaymentProofOnchainLookup: PaykitOnchainPaymentProofLookingUp { - func existingTransactionIds(address _: String, amountSats _: UInt64) async throws -> Set { + func existingTransactionIds(address _: String, amountSats _: UInt64, walletId _: String) async throws -> Set { ["transaction-before-attempt"] } - func transactionId(address _: String, amountSats _: UInt64, excluding _: Set) async throws -> String? { + func transactionId(address _: String, amountSats _: UInt64, walletId _: String, excluding _: Set) async throws -> String? { nil } } 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..5b09d4785 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,312 @@ 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]]) + try await service.syncPaykitApp(privatePaymentsEnabled: false) + try await service.syncPaykitApp(privatePaymentsEnabled: false) + XCTAssertEqual(sdk.backupReads, 1) + + sdk.registry = PaykitAppRegistry(keyGeneration: 2, noisePublicKey: nil, apps: [], defaultAppId: nil, defaultAppsByEndpoint: [:]) + let authorizationKey = try await service.paykitKeyForAuthorization(secretKeyHex: secrets[0]) + XCTAssertEqual(authorizationKey.keyGeneration(), 2) + _ = try await service.contactRecords() + XCTAssertEqual(sdk.registryPublicKeys.count, 3) + try await service.syncPaykitApp(privatePaymentsEnabled: false) + XCTAssertEqual(sdk.backupReads, 2) + + try Keychain.upsert(key: .pubkySecretKey, data: Data(secrets[1].utf8)) + _ = try await service.contactRecords() + XCTAssertEqual(sdk.registryPublicKeys.last, publicKeys[1]) + XCTAssertEqual(sdk.registryPublicKeys.count, 4) + try await service.syncPaykitApp(privatePaymentsEnabled: false) + XCTAssertEqual(sdk.backupReads, 3) + + await service.clearState() + _ = try await service.contactRecords() + XCTAssertEqual(sdk.registryPublicKeys.count, 5) + try await service.syncPaykitApp(privatePaymentsEnabled: false) + XCTAssertEqual(sdk.backupReads, 4) + + sdk.registryError = PubkyServiceError.authFailed("Registry unavailable") + do { + 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) + sdk.registryError = nil + try await service.syncPaykitApp(privatePaymentsEnabled: false) + XCTAssertEqual(sdk.backupReads, 5) + + try Keychain.delete(key: .pubkySecretKey) + try await service.syncPaykitApp(privatePaymentsEnabled: false) + try await service.syncPaykitApp(privatePaymentsEnabled: false) + XCTAssertEqual(sdk.backupReads, 6) + } + + @MainActor + 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 +380,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 +398,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 +430,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 +446,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 +468,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 +484,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 +508,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 +521,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 +579,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 +597,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)) @@ -279,13 +613,47 @@ final class PaykitSdkClientConfigTests: XCTestCase { try await activation.value } + func testSessionImportDoesNotCreateRuntimeBeforeBootstrapSucceeds() async throws { + let savedSecret = try Keychain.load(key: .pubkySecretKey) + defer { + if let savedSecret { + try? Keychain.upsert(key: .pubkySecretKey, data: savedSecret) + } else { + try? Keychain.delete(key: .pubkySecretKey) + } + } + try Keychain.delete(key: .pubkySecretKey) + let failure = PubkyServiceError.authFailed("Session unavailable") + var didImport = false + let bootstrap = RecoveryBootstrap(noPointer: .init()) + bootstrap.importSessionOperation = { + didImport = true + throw failure + } + let service = PaykitSdkService(bootstrapFactory: { _, _ in bootstrap }) + + do { + _ = try await service.importSession(secret: "saved-session") + XCTFail("Expected session import to fail") + } catch { + XCTAssertEqual(error.localizedDescription, failure.localizedDescription) + } + + XCTAssertTrue(didImport) + let runtime = try XCTUnwrap(Mirror(reflecting: service).children.first { $0.label == "sdk" }) + XCTAssertNil(runtime.value as? PaykitSdk) + } + func testSessionRecoveryCannotReactivateCredentialsAfterForget() async throws { + let savedReference = AdoptedPubkyReference.current + let secret = String(repeating: "01", count: 32) + 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 +662,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 +676,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 +694,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 +799,98 @@ 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? + var backupReads = 0 + + 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 initialize() async throws -> InitializationReport { + 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 -> 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 { + backupReads += 1 + if let backupError { throw backupError } + return "unchanged" + } + + override func observedBackupStateRevision() throws -> ObservedBackupStateRevision? { + ObservedBackupStateRevision(stateRevision: "state", backupRevision: "unchanged") + } + + override func stateRevision() throws -> String? { + "state" } } @@ -456,12 +900,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 +918,59 @@ 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 observedBackupStateRevision() throws -> ObservedBackupStateRevision? { + nil + } + + 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 +980,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..d189ad238 100644 --- a/BitkitTests/PaykitSdkIdentityCheckTests.swift +++ b/BitkitTests/PaykitSdkIdentityCheckTests.swift @@ -38,9 +38,19 @@ final class PaykitSdkIdentityCheckTests: XCTestCase { let saved = try await service.saveContact(publicKey: contactKey, label: "Alice", expectedIdentity: bareIdentityA) _ = try await service.saveContact(publicKey: contactKey, label: "Unbound") + let imported = try await service.saveContacts( + updates: [ContactUpdate(publicKey: contactKey, label: "Imported")], + expectedIdentity: bareIdentityA + ) + let readded = try await service.saveContact( + publicKey: contactKey, label: "Readded", restorePrivateConnection: true, expectedIdentity: bareIdentityA + ) XCTAssertEqual(saved.label, "Alice", "The identity matches in either key form") - XCTAssertEqual(sdk.writes, ["save:Alice", "save:Unbound"], "A save for no identity saves for whichever one is signed in") + XCTAssertEqual(imported.first?.label, "Imported") + XCTAssertEqual(readded.label, "Readded") + XCTAssertEqual(sdk.writes, ["save:Alice", "save:Unbound", "restore:1", "restore:1"]) + XCTAssertEqual(sdk.contactReads, 2, "Only ordinary updates look up the existing contact") } func testProfilePublicationForAnIdentityThatIsNoLongerSignedInWritesNothing() async throws { @@ -132,6 +142,20 @@ final class PaykitSdkIdentityCheckTests: XCTestCase { if case .identityChanged? = error as? PubkyServiceError { return true } return false }), + ("contact batch save", { service in + _ = try await service.saveContacts(updates: [ContactUpdate(publicKey: contactKey, label: "Alice")], expectedIdentity: identityA) + }, { error in + if case .identityChanged? = error as? PubkyServiceError { return true } + return false + }), + ("explicit contact re-add", { service in + _ = try await service.saveContact( + publicKey: contactKey, label: "Alice", restorePrivateConnection: true, expectedIdentity: identityA + ) + }, { error in + if case .identityChanged? = error as? PubkyServiceError { return true } + return false + }), ("profile avatar upload", { service in _ = try await service.uploadAvatar(bytes: Data([1]), contentType: "image/jpeg", expectedIdentity: identityA) }, { error in @@ -175,6 +199,68 @@ final class PaykitSdkIdentityCheckTests: XCTestCase { } } + func testContactRestoreCancelledWhileWaitingForTheSdkLockWritesNothing() async throws { + for bulk in [false, true] { + let sdk = IdentitySwitchingSdk(noPointer: .init()) + sdk.identity = identityA + let service = PaykitSdkService(sdkFactory: { sdk }) + await sdk.contactRecordsGate.close() + let lockHolder = Task { try await service.contactRecords() } + try await sdk.contactRecordsStarted.waitForEntries(count: 1) + + let save = Task { + if bulk { + _ = try await service.saveContacts(updates: [ContactUpdate(publicKey: contactKey, label: "Alice")], expectedIdentity: identityA) + } else { + _ = try await service.saveContact( + publicKey: contactKey, label: "Alice", restorePrivateConnection: true, expectedIdentity: identityA + ) + } + } + try await Task.sleep(for: .milliseconds(50)) + save.cancel() + await sdk.contactRecordsGate.open() + _ = try await lockHolder.value + + do { + try await save.value + XCTFail("Expected queued contact restore to be cancelled") + } catch is CancellationError {} + XCTAssertTrue(sdk.writes.isEmpty) + } + } + + func testSignOutWaitsForAtomicContactRestoration() async throws { + for bulk in [false, true] { + let sdk = IdentitySwitchingSdk(noPointer: .init()) + sdk.identity = identityA + let service = PaykitSdkService(sdkFactory: { sdk }) + await sdk.restoreGate.close() + let restore = Task { + if bulk { + _ = try await service.saveContacts(updates: [ContactUpdate(publicKey: contactKey, label: "Alice")], expectedIdentity: identityA) + } else { + _ = try await service.saveContact( + publicKey: contactKey, label: "Alice", restorePrivateConnection: true, expectedIdentity: identityA + ) + } + } + try await sdk.restoreStarted.waitForEntries(count: 1) + + let signOut = Task { try await service.signOut() } + try await Task.sleep(for: .milliseconds(50)) + XCTAssertEqual(sdk.identity, identityA) + XCTAssertTrue(sdk.writes.isEmpty) + await sdk.restoreGate.open() + try await restore.value + try await signOut.value + + XCTAssertNil(sdk.identity) + XCTAssertEqual(sdk.writes, ["restore:1"]) + XCTAssertEqual(sdk.identitiesAtWrites, [identityA]) + } + } + /// A sign-out queues for the SDK lock right behind a contact save. The lock is handed over in order, so the save runs /// first and saves while its identity is signed in. Were its identity checked in a locked operation of its own, the /// sign-out would take the lock between that check and the save, and the save would land after it. @@ -269,6 +355,8 @@ private final class IdentitySwitchingSdk: PaykitSdk, @unchecked Sendable { private var recordedContactReads = 0 let contactRecordsGate = IdentityCheckGate() let contactRecordsStarted = IdentityCheckCallLog() + let restoreGate = IdentityCheckGate() + let restoreStarted = IdentityCheckCallLog() var identity: String? { get { lock.withLock { signedInIdentity } } @@ -301,16 +389,24 @@ 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 observedBackupStateRevision() throws -> ObservedBackupStateRevision? { + nil + } + + 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] { @@ -324,15 +420,18 @@ private final class IdentitySwitchingSdk: PaykitSdk, @unchecked Sendable { return Self.record(publicKey: publicKey, label: "Contact") } - override func linkedPeers() async throws -> [LinkedPeerRecord] { - [] - } - override func saveContact(update: ContactUpdate) async throws -> ContactRecord { recordWrite("save:\(update.label ?? "")") return Self.record(publicKey: update.publicKey, label: update.label) } + override func saveContactsAndUnblockPeers(updates: [ContactUpdate]) async throws -> [ContactRecord] { + await restoreStarted.record() + await restoreGate.wait() + recordWrite("restore:\(updates.count)") + return updates.map { Self.record(publicKey: $0.publicKey, label: $0.label) } + } + override func uploadProfileAvatar(bytes: Data, contentType _: String) async throws -> PaykitBlobRecord { guard identity != nil, hasLiveSession else { throw noLiveSessionError } recordWrite("upload") @@ -342,16 +441,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..69764b77d 100644 --- a/BitkitTests/PaykitSdkOperationLockTests.swift +++ b/BitkitTests/PaykitSdkOperationLockTests.swift @@ -11,6 +11,434 @@ 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 + ) + } + + func testCapabilitySyncPriorityPreservesOrderedWorkAndOnlyOvertakesQueuedBackgroundWork() async throws { + let operations: [(PaykitSdkService) async throws -> Void] = [ + { _ = try await $0.receivePrivateMessagesFromLinkedPeers(priority: .background) }, + { _ = try await $0.ensureLinkWithPeer("peer", priority: .background) }, + ] + let cases: [(priority: PaykitSdkOperationLock.Priority?, orderedBarrier: Bool, expected: [String])] = [ + (nil, false, ["active", "intake", "publish"]), + (.interactive, false, ["active", "publish", "intake"]), + (.interactive, true, ["active", "intake", "ordered", "publish"]), + ] + for operation in operations { + for testCase in cases { + let recorder = Recorder() + let (gate, release) = AsyncStream.makeStream() + defer { release.finish() } + let started = expectation(description: "Active SDK operation started") + let sdk = PublicReadSdk(noPointer: .init()) + sdk.lockedRead = { + if await recorder.events.isEmpty { + await recorder.record("active") + started.fulfill() + for await _ in gate {} + } else { + await recorder.record("ordered") + } + } + sdk.intake = { await recorder.record("intake") } + sdk.publication = { capabilities in + XCTAssertFalse(capabilities.privatePayments) + await recorder.record("publish") + } + let service = PaykitSdkService(sdkFactory: { sdk }) + let active = Task { _ = try await service.contactRecords() } + await fulfillment(of: [started], timeout: 2) + + let intake = Task { try await operation(service) } + try await Task.sleep(for: .milliseconds(50)) + let barrier = testCase.orderedBarrier ? Task { _ = try await service.contactRecords() } : nil + if barrier != nil { try await Task.sleep(for: .milliseconds(50)) } + let publication = Task { + if let priority = testCase.priority { + try await service.syncPaykitApp(privatePaymentsEnabled: false, priority: priority) + } else { + try await service.syncPaykitApp(privatePaymentsEnabled: false) + } + } + try await Task.sleep(for: .milliseconds(50)) + let heldEvents = await recorder.events + XCTAssertEqual(heldEvents, ["active"]) + + release.finish() + try await active.value + try await intake.value + try await barrier?.value + try await publication.value + let events = await recorder.events + XCTAssertEqual(events, testCase.expected) + } + } + } + + func testPaymentMutationsOvertakeBackgroundWorkWithoutCrossingOrderedWork() async throws { + for accepting in [false, true] { + for orderedBarrier in [false, true] { + let recorder = Recorder() + let (gate, release) = AsyncStream.makeStream() + defer { release.finish() } + let started = expectation(description: "Active SDK operation started") + let sdk = PublicReadSdk(noPointer: .init()) + sdk.lockedRead = { + if await recorder.events.isEmpty { + await recorder.record("active") + started.fulfill() + for await _ in gate {} + } else { + await recorder.record("ordered") + } + } + sdk.intake = { await recorder.record("intake") } + sdk.paymentMutation = { operation in + XCTAssertEqual(operation, accepting ? "claimAndAccept" : "claim") + await recorder.record("payment") + } + let service = PaykitSdkService(sdkFactory: { sdk }) + let active = Task { _ = try await service.contactRecords() } + await fulfillment(of: [started], timeout: 2) + let intake = Task { _ = try await service.receivePrivateMessagesFromLinkedPeers(priority: .background) } + try await Task.sleep(for: .milliseconds(50)) + let barrier = orderedBarrier ? Task { _ = try await service.contactRecords() } : nil + if barrier != nil { try await Task.sleep(for: .milliseconds(50)) } + let payment = Task { + do { + if accepting { + _ = try await service.acceptPaymentRequest(counterparty: "peer", paymentRequestId: "request") + } else { + _ = try await service.claimPaymentRequestForExecution(counterparty: "peer", paymentRequestId: "request") + } + XCTFail("Expected the SDK test operation to throw") + } catch PaymentMutationReached.reached {} + } + try await Task.sleep(for: .milliseconds(50)) + release.finish() + try await active.value + try await intake.value + try await barrier?.value + try await payment.value + let events = await recorder.events + XCTAssertEqual(events, orderedBarrier ? ["active", "intake", "ordered", "payment"] : ["active", "payment", "intake"]) + } + } + } + + @MainActor + func testBackgroundOutboundDeliveryWaitsWhenPaymentStartsBeforeSending() async throws { + for waitingInQueue in [true, false] { + let (gate, release) = AsyncStream.makeStream() + defer { release.finish() } + let started = expectation(description: "SDK work before outbound delivery started") + let recorder = Recorder() + let pause: () async -> Void = { + guard await recorder.events.isEmpty else { return } + await recorder.record("waiting") + started.fulfill() + for await _ in gate {} + } + let sdk = PublicReadSdk(noPointer: .init()) + if waitingInQueue { + sdk.lockedRead = pause + } else { + sdk.backupRevisionRead = pause + } + sdk.outboundDelivery = { await recorder.record("delivery") } + let service = PaykitSdkService(sdkFactory: { sdk }) + let holder = waitingInQueue ? Task { try await service.contactRecords() } : nil + if waitingInQueue { await fulfillment(of: [started], timeout: 2) } + let delivery = Task { try await service.processOutboundPrivateMessages(counterparty: "peer", priority: .background) } + defer { delivery.cancel() } + if waitingInQueue { + try await Task.sleep(for: .milliseconds(50)) + } else { + await fulfillment(of: [started], timeout: 2) + } + let payment = PaykitPaymentActivity.shared.begin() + defer { PaykitPaymentActivity.shared.end(payment) } + release.finish() + _ = try await holder?.value + // The ordered read must still complete while outbound delivery waits for payment to finish. + _ = try await service.identityStatus() + let heldEvents = await recorder.events + XCTAssertEqual(heldEvents, ["waiting"]) + PaykitPaymentActivity.shared.end(payment) + _ = try await delivery.value + let events = await recorder.events + XCTAssertEqual(events, ["waiting", "delivery"]) + } + } + + @MainActor + func testDeferredOutboundDeliveryRejectsCancellationOrReplacedWallet() async throws { + for change in ["cancel", "reset", "wipe"] { + let recorder = Recorder() + let service = PaykitSdkService(sdkFactory: { + let sdk = PublicReadSdk(noPointer: .init()) + sdk.outboundDelivery = { await recorder.record("delivery") } + return sdk + }) + let payment = PaykitPaymentActivity.shared.begin() + defer { PaykitPaymentActivity.shared.end(payment) } + let delivery = Task { try await service.processOutboundPrivateMessages(counterparty: "peer", priority: .background) } + defer { delivery.cancel() } + try await Task.sleep(for: .milliseconds(50)) + switch change { + case "cancel": delivery.cancel() + case "reset": await service.clearState() + default: try await service.withWalletWipe {} + } + PaykitPaymentActivity.shared.end(payment) + do { + _ = try await delivery.value + XCTFail("Deferred outbound delivery must not survive \(change)") + } catch is CancellationError { + XCTAssertEqual(change, "cancel") + } catch PubkyServiceError.identityChanged { + XCTAssertNotEqual(change, "cancel") + } + let events = await recorder.events + XCTAssertTrue(events.isEmpty) + } + } + + @MainActor + func testForegroundOutboundDeliveryDoesNotWaitForPaymentToFinish() async throws { + let payment = PaykitPaymentActivity.shared.begin() + defer { PaykitPaymentActivity.shared.end(payment) } + let recorder = Recorder() + let sdk = PublicReadSdk(noPointer: .init()) + sdk.outboundDelivery = { await recorder.record("delivery") } + let service = PaykitSdkService(sdkFactory: { sdk }) + for priority in [PaykitSdkOperationLock.Priority.ordered, .interactive] { + _ = try await service.processOutboundPrivateMessages(counterparty: "peer", priority: priority) + } + let events = await recorder.events + XCTAssertEqual(events, ["delivery", "delivery"]) + } + + @MainActor + func testBackupExportWaitsForPaymentEvenWhenPaymentStartsWhileExportIsQueued() async throws { + let (gate, release) = AsyncStream.makeStream() + defer { release.finish() } + let started = expectation(description: "Active SDK operation started") + let sdk = PublicReadSdk(noPointer: .init()) + sdk.lockedRead = { + started.fulfill() + for await _ in gate {} + } + let recorder = Recorder() + sdk.backupExport = { await recorder.record("export") } + let service = PaykitSdkService(sdkFactory: { sdk }) + let holder = Task { try await service.contactRecords() } + await fulfillment(of: [started], timeout: 2) + let export = Task { try await service.exportBackupState() } + try await Task.sleep(for: .milliseconds(50)) + let payment = PaykitPaymentActivity.shared.begin() + defer { PaykitPaymentActivity.shared.end(payment) } + release.finish() + _ = try await holder.value + // An ordered read completing proves the deferred export does not hold the SDK lock. + _ = try await service.identityStatus() + let heldEvents = await recorder.events + XCTAssertTrue(heldEvents.isEmpty) + PaykitPaymentActivity.shared.end(payment) + let backup = try await export.value + XCTAssertEqual(backup, "backup") + let events = await recorder.events + XCTAssertEqual(events, ["export"]) + } + + @MainActor + func testDeferredBackupRejectsCancellationOrReplacedWallet() async throws { + for change in ["cancel", "reset", "wipe"] { + let recorder = Recorder() + let service = PaykitSdkService(sdkFactory: { + let sdk = PublicReadSdk(noPointer: .init()) + sdk.backupExport = { await recorder.record("export") } + return sdk + }) + let payment = PaykitPaymentActivity.shared.begin() + defer { PaykitPaymentActivity.shared.end(payment) } + let export = Task { try await service.exportBackupState() } + try await Task.sleep(for: .milliseconds(50)) + switch change { + case "cancel": export.cancel() + case "reset": await service.clearState() + default: try await service.withWalletWipe {} + } + PaykitPaymentActivity.shared.end(payment) + do { + _ = try await export.value + XCTFail("Deferred backup must not survive \(change)") + } catch is CancellationError { + XCTAssertEqual(change, "cancel") + } catch PubkyServiceError.identityChanged { + XCTAssertNotEqual(change, "cancel") + } + let events = await recorder.events + XCTAssertTrue(events.isEmpty) + } + } + + 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 +458,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 +485,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,79 +605,97 @@ 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 - } + private func waitUntilWiping(_ lock: PaykitSdkOperationLock) async throws { + let deadline = ContinuousClock.now.advanced(by: .seconds(2)) + while (try? lock.walletGeneration()) != nil { + guard ContinuousClock.now < deadline else { + return XCTFail("The wipe never started") } + try await Task.sleep(for: .milliseconds(5)) } - 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)) +private enum PaymentMutationReached: Error { + case reached +} - lookup.cancel() - releaseHolder.yield() - try await holder.value - try await payment.value +private final class PublicReadSdk: PaykitSdk, @unchecked Sendable { + var lockedRead: () async -> Void = {} + var publicRead: () async -> Void = {} + var intake: () async -> Void = {} + var publication: (PaykitAppCapabilities) async -> Void = { _ in } + var paymentMutation: (String) async -> Void = { _ in } + var backupExport: () async -> Void = {} + var outboundDelivery: () async -> Void = {} + var backupRevisionRead: () async -> Void = {} - do { - try await lookup.value - XCTFail("Expected the cancelled lookup to throw") - } catch is CancellationError {} - let events = await recorder.events - XCTAssertEqual(events, ["payment"]) + override func processOutboundPrivateMessages(counterparty _: String) async throws -> OutboundPrivateSendReport { + await outboundDelivery() + return OutboundPrivateSendReport(attempted: [], sent: [], failed: [], reservationCleanupFailures: [], recoveryMarkerFailures: []) } - func testAlreadyCancelledReadDoesNotRunOrHoldTheLock() async throws { - let lock = PaykitSdkOperationLock() - let recorder = Recorder() + override func claimPaymentRequestForExecution(counterparty _: String, paymentRequestId _: String) async throws -> PaymentRequestRecord { + await paymentMutation("claim") + throw PaymentMutationReached.reached + } - let lookup = Task { - withUnsafeCurrentTask { $0?.cancel() } - return try await lock.withCancellableLock { - await recorder.record("lookup") - } - } + override func claimAndAcceptPaymentRequest(counterparty _: String, paymentRequestId _: String) async throws -> PaymentRequestRecord { + await paymentMutation("claimAndAccept") + throw PaymentMutationReached.reached + } - do { - try await lookup.value - XCTFail("Expected the cancelled lookup to throw") - } catch is CancellationError {} - try await lock.withLock { - await recorder.record("payment") - } - let events = await recorder.events - XCTAssertEqual(events, ["payment"]) + override func exportBackupString() async throws -> String { + await backupExport() + return "backup" } - private func waitUntilWiping(_ lock: PaykitSdkOperationLock) async throws { - let deadline = ContinuousClock.now.advanced(by: .seconds(2)) - while (try? lock.walletGeneration()) != nil { - guard ContinuousClock.now < deadline else { - return XCTFail("The wipe never started") - } - try await Task.sleep(for: .milliseconds(5)) - } + override func identityStatus() async throws -> IdentityStatus? { + IdentityStatus(publicKey: nil, capability: .privateLinkCapable) + } + + override func stateRevision() throws -> String? { + "state" + } + + override func backupStateRevision() async throws -> String { + await backupRevisionRead() + return "revision" + } + + override func observedBackupStateRevision() throws -> ObservedBackupStateRevision? { + nil + } + + override func receivePrivateMessagesFromLinkedPeers() async throws -> [PrivateStreamCounterpartyIntakeReport] { + await intake() + return [] + } + + override func ensureLinkWithPeer(counterparty: String, maxAdvanceSteps: UInt32) async throws -> LinkedPeerHandshakeReport { + XCTAssertEqual(maxAdvanceSteps, 1) + await intake() + return LinkedPeerHandshakeReport(counterparty: counterparty, state: .linking, generation: 1, handshakeRole: nil) + } + + override func publishPaykitApp(displayName _: String, capabilities: PaykitAppCapabilities) async throws -> PaykitAppRegistry { + await publication(capabilities) + return PaykitAppRegistry(keyGeneration: 1, noisePublicKey: nil, apps: [], defaultAppId: nil, defaultAppsByEndpoint: [:]) + } + + override func resolvePublicContactPayment(counterparty _: String, + amount _: PaymentAmountContext?) async throws -> PublicContactPaymentResolution + { + 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..1f0ea24ac 100644 --- a/BitkitTests/PaymentNavigationHelperTests.swift +++ b/BitkitTests/PaymentNavigationHelperTests.swift @@ -59,6 +59,21 @@ final class PaymentNavigationHelperTests: XCTestCase { XCTAssertEqual(sendRoute(for: appWithEligibleInvoice), .quickpay) } + func testContactPaymentErrorsDoNotExposeSdkDetails() { + for error: Error in [ + PaykitError.ConcurrentUpdate(code: "concurrent_update", context: "peer operation in progress"), + PaykitError.SharedStateBusy(code: "shared_state_busy", context: "pending write"), + PaykitError.Transport(code: "transport", context: "private request failed"), + ] { + XCTAssertEqual(PaymentNavigationHelper.contactPaymentErrorDescription(error), t("other__try_again")) + XCTAssertEqual(PaymentNavigationHelper.contactPaymentErrorDescription(Bitkit.AppError(error: error)), t("other__try_again")) + } + XCTAssertNil(PaymentNavigationHelper.contactPaymentErrorDescription(CancellationError())) + XCTAssertNil(PaymentNavigationHelper.contactPaymentErrorDescription(Bitkit.AppError(error: CancellationError()))) + let error = PrivatePaykitError.invalidPublicKey + XCTAssertEqual(PaymentNavigationHelper.contactPaymentErrorDescription(error), error.localizedDescription) + } + func testEligibleInvoiceUsesQuickpayUnderDailyCap() { XCTAssertEqual(sendRoute(for: appWithEligibleInvoice), .quickpay) } @@ -242,7 +257,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 +264,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..7c65476e3 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,452 @@ 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 testOverlappingLinkPreparationRetriesAfterFailureOrCancellation() async { + for cancel in [false, true] { + let service = PrivatePaykitService() + let started = expectation(description: "Link preparation started") + let (resume, continuation) = AsyncStream.makeStream() + var advances = 0 + let operations = PrivatePaykitService.PrivateMessageDrainOperations( + ensureLink: { _ in + advances += 1 + if advances == 1 { + started.fulfill() + for await _ in resume { + break + } + try Task.checkCancellation() + throw PrivatePaykitError.privateUnavailable + } + }, + pendingOutbound: { [] }, linkedPeers: { [] }, processPending: { _ in }, receive: { _ in } + ) + let first = Task { + await service.drainPendingPrivateMessages(reason: "test", advancing: ["peer"], operations: operations) + } + await fulfillment(of: [started], timeout: 2) + await service.drainPendingPrivateMessages(reason: "test", advancing: ["peer"], operations: operations) + XCTAssertEqual(advances, 1) + if cancel { first.cancel() } + continuation.finish() + await first.value + await service.drainPendingPrivateMessages(reason: "test", advancing: ["peer"], operations: operations) + XCTAssertEqual(advances, 2) + } + } + + func testInvalidatedLinkPreparationCannotReleaseNewPreparation() async { + let service = PrivatePaykitService() + let oldStarted = expectation(description: "Old preparation started") + let newStarted = expectation(description: "New preparation started") + let (oldResume, oldContinuation) = AsyncStream.makeStream() + let (newResume, newContinuation) = AsyncStream.makeStream() + var advances = 0 + let operations = PrivatePaykitService.PrivateMessageDrainOperations( + ensureLink: { _ in + advances += 1 + if advances == 1 { + oldStarted.fulfill() + for await _ in oldResume { + break + } + } else if advances == 2 { + newStarted.fulfill() + for await _ in newResume { + break + } + } + }, + pendingOutbound: { [] }, linkedPeers: { [] }, processPending: { _ in }, receive: { _ in } + ) + let old = Task { + await service.drainPendingPrivateMessages(reason: "test", advancing: ["peer"], operations: operations) + } + await fulfillment(of: [oldStarted], timeout: 2) + await service.invalidateContactPreparation() + let current = Task { + await service.drainPendingPrivateMessages(reason: "test", advancing: ["peer"], operations: operations) + } + await fulfillment(of: [newStarted], timeout: 2) + oldContinuation.finish() + await old.value + await service.drainPendingPrivateMessages(reason: "test", advancing: ["peer"], operations: operations) + XCTAssertEqual(advances, 2) + newContinuation.finish() + await current.value + } + + 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 +644,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 +652,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 +675,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 +728,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 +781,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 +800,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 +828,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 +836,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 +847,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 +876,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 +891,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 +911,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 +925,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 +934,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 +959,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 +1010,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 +1033,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 +1268,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 +1286,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 +1294,55 @@ 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 testFailedPublicationRetainsPendingLinksOnlyForCurrentIdentity() async { let publicKey = "pubky1rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" - let markerError = NSError(domain: "PrivatePaykitServiceTests", code: 1) + for changesIdentity in [false, true] { + var identity = "pubkylocal" + let service = PrivatePaykitService() + _ = await service.rememberSavedContacts([publicKey], replacing: true) + let operations = PrivatePaykitService.EndpointPublicationOperations( + currentPublicKey: { identity }, + ensureLink: { _ in .linking }, + buildEndpoints: { _ in + [PublicPaykitService.Endpoint( + methodId: .regtestOnchainP2wpkh, + value: "bcrt1qendpoint", + min: nil, + max: nil, + rawPayload: #"{"value":"bcrt1qendpoint"}"# + )] + }, + syncPaymentLists: { _ in + if changesIdentity { identity = "pubkyother" } + throw PaykitError.Transport(code: "offline", context: "Unavailable homeserver") + } + ) + + let error = await service.syncLocalEndpointPublication( + for: [publicKey], reason: "test", requireImmediatePublication: true, operations: operations + ) + + XCTAssertNotNil(error) + let pending = await service.testPendingMessageDrainRetryKeys() + XCTAssertEqual(pending, changesIdentity ? [] : [publicKey]) + await service.clearTestPendingMessageDrainRetries() + } + } + + func testDeferredPublicationRetainsEndpointPreparationFailureForRetry() async { + let publicKey = "pubky1rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" + 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 +1355,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 +1366,365 @@ 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 testProfileDeletionDropsPreparationAndAllowsNewWorkAfterDeletionEnds() async { + let service = PrivatePaykitService() + let publicKey = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" + _ = await service.rememberSavedContacts([publicKey], replacing: true) + await service.beginProfileDeletion() + await service.scheduleContactPreparation([publicKey]) { _, _ in + XCTFail("Profile deletion must not enqueue preparation") + } + let deletionTask = await service.preparationTask + XCTAssertNil(deletionTask) + + await service.endProfileDeletion() + let resumedTask = await service.preparationTask + XCTAssertNil(resumedTask) + let prepared = expectation(description: "New preparation starts") + await service.scheduleContactPreparation([publicKey]) { _, _ in prepared.fulfill() } + await fulfillment(of: [prepared], timeout: 2) + } + + 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 +1742,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 +1755,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/PubkyContactLinkTests.swift b/BitkitTests/PubkyContactLinkTests.swift index 5fd86ee99..d71bb57e5 100644 --- a/BitkitTests/PubkyContactLinkTests.swift +++ b/BitkitTests/PubkyContactLinkTests.swift @@ -22,6 +22,35 @@ final class PubkyContactLinkTests: XCTestCase { } } + @MainActor + func testDeletedContactDoesNotDismissANewerRoute() { + let navigation = NavigationViewModel() + for route: Route in [.contactDetail(publicKey: key), .editContact(publicKey: key), .contactSaved(publicKey: key)] { + navigation.path = [.contacts, route] + navigation.returnToContactsAfterRemoving(publicKey: key) + XCTAssertEqual(navigation.path, [.contacts]) + + navigation.navigate(.addContact(publicKey: key)) + let addPath = navigation.path + navigation.returnToContactsAfterRemoving(publicKey: key) + XCTAssertEqual(navigation.path, addPath) + + navigation.path = [.contacts, route, .addContact(publicKey: key)] + navigation.returnToContactsAfterRemoving(publicKey: key) + XCTAssertEqual(navigation.path, [.contacts, .addContact(publicKey: key)]) + + navigation.path = [route] + navigation.returnToContactsAfterRemoving(publicKey: key) + XCTAssertEqual(navigation.path, [.contacts]) + } + let otherKey = "pubky" + String(repeating: "y", count: 52) + for route: Route in [.contactDetail(publicKey: otherKey), .contactSaved(publicKey: otherKey), .settings] { + navigation.path = [.contacts, route] + navigation.returnToContactsAfterRemoving(publicKey: key) + XCTAssertEqual(navigation.path, [.contacts, route]) + } + } + func testRejectsMalformedLinksAndNonKeyPayloads() throws { for link in [ "https://contact?pubky=\(key)", 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 31adaa404..7baa8964e 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 @@ -264,6 +265,89 @@ final class PubkyProfileManagerTests: XCTestCase { } } + @MainActor + func testDeferredRecoveryUsesBoundedShortRetriesBeforeBackingOff() async { + for multiplier in [0.8, 1.0, 1.2] { + let manager = RecoveryProfileManager() + let attempts = SessionRecoveryAttempts() + var delays: [Duration] = [] + await manager.retrySessionRestoration( + jitter: { multiplier }, + sleep: { delays.append($0) }, + hasStoredIdentity: { true }, + initializeSession: { + guard await attempts.next() > 11 else { return .restorationDeferred } + return .restored(publicKey: "existing-identity") + } + ) + + let expected = Array(repeating: 5.0, count: 8) + [10, 20, 40] + XCTAssertEqual(delays, expected.map { .seconds($0 * multiplier) }) + XCTAssertEqual(manager.publicKey, "existing-identity") + XCTAssertFalse(manager.sessionRestorationFailed) + } + } + + @MainActor + func testDeferredRecoveryKeepsNormalBackoffForInvalidCredentials() async { + let manager = RecoveryProfileManager() + let attempts = SessionRecoveryAttempts() + var delays: [Duration] = [] + await manager.retrySessionRestoration( + jitter: { 1 }, + sleep: { delays.append($0) }, + hasStoredIdentity: { true }, + initializeSession: { + switch await attempts.next() { + case 1: throw PaykitError.SharedStateBusy(code: "shared_state_busy", context: "Locked") + case 2: throw PubkyServiceError.authFailed("Invalid credentials") + default: return .restored(publicKey: "existing-identity") + } + } + ) + XCTAssertEqual(delays, [.seconds(5), .seconds(10)]) + XCTAssertEqual(manager.publicKey, "existing-identity") + } + + @MainActor + func testDeferredRecoveryStopsAfterCancellationOrIdentityChange() async throws { + for changeIdentity in [false, true] { + let manager = RecoveryProfileManager() + let sleeping = expectation(description: "waiting before deferred retry") + let (stream, continuation) = AsyncStream.makeStream() + defer { continuation.finish() } + let attempts = SessionRecoveryAttempts() + let recovery = Task { + await manager.retrySessionRestoration( + sleep: { _ in + sleeping.fulfill() + for await _ in stream {} + }, + hasStoredIdentity: { true }, + initializeSession: { + let attempt = await attempts.next() + XCTAssertEqual(attempt, 1, "Invalidated recovery must not restore another session") + return attempt == 1 ? .restorationDeferred : .restored(publicKey: "unexpected-identity") + } + ) + } + await fulfillment(of: [sleeping], timeout: 2) + if changeIdentity { + try await PubkyProfileManager.restoreSessionBackupState( + nil, + deleteKeychainValue: { _ in }, + removeOwnSharedRecords: {}, + forgetSessionAccess: {} + ) + } else { + recovery.cancel() + } + continuation.finish() + await recovery.value + XCTAssertNil(manager.publicKey) + } + } + @MainActor func testCancelledRecoveryDoesNotRetryAfterPendingStartup() async { let manager = RecoveryProfileManager() @@ -333,7 +417,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 +596,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 = [ @@ -649,6 +733,44 @@ final class PubkyProfileManagerTests: XCTestCase { XCTAssertNil(manager.initializationErrorMessage) } + @MainActor + func testOverlappingRecoverySharesFailureAndAllowsLaterRetry() async { + let manager = RecoveryProfileManager() + let started = expectation(description: "recovery started") + let (stream, continuation) = AsyncStream.makeStream() + let recovery = Task { + await manager.restoreSessionIfNeeded(hasStoredIdentity: { true }) { + started.fulfill() + for await _ in stream {} + throw PubkyServiceError.authFailed("offline") + } + } + await fulfillment(of: [started], timeout: 2) + let waiting = expectation(description: "recovery callers waiting") + waiting.expectedFulfillmentCount = 2 + let retries = (0 ..< 2).map { _ in + Task { + waiting.fulfill() + await manager.restoreSessionIfNeeded(hasStoredIdentity: { true }) { + XCTFail("Waiting callers must share the completed recovery attempt") + return .noSession + } + } + } + await fulfillment(of: [waiting], timeout: 2) + continuation.finish() + await recovery.value + for retry in retries { + await retry.value + } + XCTAssertNil(manager.publicKey) + XCTAssertFalse(manager.isRestoringSession) + + await manager.restoreSessionIfNeeded(hasStoredIdentity: { true }) { .restored(publicKey: "existing-identity") } + XCTAssertEqual(manager.publicKey, "existing-identity") + XCTAssertEqual(manager.authState, .authenticated) + } + @MainActor func testAutomaticRecoveryKeepsUsableStateWhileRetryFails() async { let manager = RecoveryProfileManager() @@ -858,7 +980,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 +996,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 +1050,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 +1104,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 +1178,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") @@ -2094,7 +2224,7 @@ final class PubkyProfileManagerTests: XCTestCase { manager.clearAuthenticatedStateForTesting() }), ("automatic recovery while signed out", true, { manager in - for result in [PubkyProfileManager.SessionInitializationResult.noSession, .restorationFailed] { + for result in [PubkyProfileManager.SessionInitializationResult.noSession, .restorationFailed, .restorationDeferred] { await manager.restoreSessionIfNeeded(hasStoredIdentity: { true }) { result } } }), @@ -2729,6 +2859,52 @@ final class PubkyProfileManagerTests: XCTestCase { XCTAssertEqual(result, .restored(publicKey: "pubky_saved")) } + @MainActor + func testResolveSessionInitializationKeepsSessionOnTemporaryFailure() async { + let errors: [Error] = [ + PaykitError.ConcurrentUpdate(code: "concurrent_update", context: "Locked"), + PaykitError.SharedStateBusy(code: "shared_state_busy", context: "Pending write"), + PaykitError.Transport(code: "transport_error", context: "Offline"), + CancellationError(), + ] + 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, .restorationDeferred) + let manager = RecoveryProfileManager() + await manager.initialize { result } + XCTAssertTrue(manager.isInitialized) + XCTAssertFalse(manager.sessionRestorationFailed) + XCTAssertNil(manager.initializationErrorMessage) + XCTAssertEqual(manager.authState, .idle) + + await manager.initialize { throw error } + XCTAssertTrue(manager.isInitialized) + XCTAssertFalse(manager.sessionRestorationFailed) + XCTAssertNil(manager.initializationErrorMessage) + } + } + + func testResolveSessionInitializationDefersTemporarySignInFailure() async { + let result = await PubkyProfileManager.resolveSessionInitialization( + savedSessionSecret: nil, + storedSecretKeyHex: "local-secret", + importSession: { _ in + XCTFail("No saved session to import") + return "unused" + }, + signInWithSecretKey: { _ in throw PaykitError.Transport(code: "transport_error", context: "Offline") } + ) + XCTAssertEqual(result, .restorationDeferred) + } + 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/SendConfirmationSwipeTests.swift b/BitkitTests/SendConfirmationSwipeTests.swift index ece2ce5bf..bb08aa77e 100644 --- a/BitkitTests/SendConfirmationSwipeTests.swift +++ b/BitkitTests/SendConfirmationSwipeTests.swift @@ -2,6 +2,24 @@ import XCTest final class SendConfirmationSwipeTests: XCTestCase { + func testPreparingRequestKeepsSwipeDisabledAndLoadingWithKnownFees() { + for walletType in [WalletType.lightning, .onchain] { + XCTAssertTrue(SendConfirmationView.isSwipeDisabled( + walletType: walletType, + isHardwarePayment: false, + isHardwareConfirmationUnavailable: false, + feeRate: 5, + isPreparingRequest: true + )) + } + XCTAssertTrue(SendConfirmationView.isSwipeLoading( + hasStartedAutomaticPayment: false, + isFeeRateMissing: false, + feeRateLoadFailed: false, + isPreparingRequest: true + )) + } + func testOnchainPaymentWithoutFeeRateDisablesSwipe() { XCTAssertTrue(SendConfirmationView.isSwipeDisabled( walletType: .onchain, diff --git a/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..2eda7e7ca 100644 --- a/Docs/paykit-issuer-interoperability.md +++ b/Docs/paykit-issuer-interoperability.md @@ -11,14 +11,16 @@ An actionable request must satisfy all of these requirements: - The amount asset is exactly lowercase `btc`. - The amount is a positive decimal Bitcoin value with at most eight significant fractional digits and no more than `18,446,744,073,709,551` satoshis. -- The request is a one-time proposal: the local role is payer, lifecycle state is proposed, and recurrence is absent. -- The proposal expiration is absent or is a valid future ISO 8601 timestamp. -- `paymentDeadline` is absent. Bitkit does not yet enforce actual-payment deadlines. +- The request is one-time: the local role is payer, its actionable lifecycle state is proposed or accepted, and recurrence is absent. +- The proposal expiration is absent or is a valid ISO 8601 timestamp. It must be in the future while the request is proposed; it does not prevent payment after acceptance. +- `paymentDeadline` is absent or uses the absolute `At` form with a valid UTC ISO 8601 timestamp ending in `Z` that has not passed. The deadline is inclusive: payment is allowed at the exact deadline instant. - `acceptedPaymentEndpointIdentifiers` retains at least one identifier supported on the wallet's current network. Bitkit filters `acceptedPaymentEndpointIdentifiers` in issuer order, removes duplicates after their first occurrence, and drops unknown or wrong-network identifiers. The request remains actionable when at least one identifier survives. -Requests with a payment deadline remain visible in history but are unavailable for payment. This restriction is separate from proposal expiration, which controls acceptance. +Bitkit enforces absolute one-time payment deadlines during payment preparation and before submission, including retries. Requests whose valid absolute deadline has passed remain visible in history but are unavailable for payment. This is separate from proposal expiration, which controls acceptance. + +Malformed deadlines and relative deadline forms are not actionable. Recurring requests are outside this one-time contract. ### Endpoint identifiers @@ -74,7 +76,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..2972826f9 --- /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, restore contacts when re-importing an identity, prepare incoming payments in the payment sheet, support one-time payment deadlines, prioritize payment submission over background synchronization, distinguish temporary restoration failures from expired sessions, preserve subscription reminders through retries, and label received payments with their Paykit contact. 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..ec743685b 100644 --- a/journeys/contacts/README.md +++ b/journeys/contacts/README.md @@ -1,21 +1,39 @@ # Contacts +`delete-newly-saved-contact.xml` checks deletion directly from Contact Saved and adding that +contact again. It is mirrored on Android. Deleted contact screens must not remain in Back history. + `contacts-entry-points.xml` checks onboarding without an identity and the authenticated Contacts list. It has not yet been ported to Android. Saved-identity recovery from the drawer and Contacts intro, pending lookup, and leaving while lookup waits require storage or network fault injection, which the journey runner does not provide. Check those cases using the PR manual checks. -Import journeys require a disposable identity with a known following list. They save local Bitkit contacts; payment sharing remains a separate step. +Import journeys require a disposable identity with a known following list. They save Bitkit contacts; payment sharing remains a separate step. + +Continue waits for public payment setup, not private linking with every imported contact. +Private preparation runs in the background. Repeat Import All with a large following list containing +unavailable profiles, then delete a contact while preparation is running. It must not be republished +after deletion. Unavailable private-link lookups are retried after five minutes rather than on each refresh. + +Network and storage fault injection are outside journey-runner capabilities. After the preview has +loaded, verify that importing its prepared contacts does not repeat profile lookups; saving shared +contact state still requires Pubky storage access. Simulate a failed contact batch: stay on import, +preserve previously saved contacts, and retry the entire unsaved selection without claiming partial +success. Duplicate selections and contacts already saved are skipped. Payment sharing remains a +separate step. On Android, a failed Continue on the payment-sharing screen should offer recovery +guidance and leave saved contacts intact. -Network and storage fault injection are outside journey-runner capabilities. Manually disable -connectivity after the preview has loaded: importing the prepared contacts must still finish. -Simulate a failed local save: stay on import, preserve successful saves, and retry only missing -contacts without claiming complete success. On Android, a failed Continue on the payment-sharing -screen should offer recovery guidance and leave saved contacts intact. +Repeat both import journeys after deleting the disposable profile and adopting the same identity +again, including the 62-contact case. Record Import All to the payment-sharing screen separately +from Continue; there is no guaranteed network completion deadline. Explicit re-import saves the +selected contacts and removes their blocks atomically, without restoring old private links. +Deselected contacts remain blocked. Ordinary label updates must not remove a block. SDK failure +and cancellation coverage verifies that the app does not issue compensating block writes after +an atomic save, including when cancellation hides a completed commit. `ContactImportUITests.swift` checks pending imports using the DEBUG-only `-contact-import-ui-test` -fixture. It holds the first local save until the test taps Finish save, without network or SDK +fixture. It holds the contact batch until the test taps Finish save, without network or SDK storage. The tests drive the real overview and selection views, attempt repeated import taps, and verify that Select stays disabled, the preview stays intact, and completion saves the chosen contacts. This controlled fixture is separate from the identity-backed journeys above. @@ -23,3 +41,42 @@ 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. + +Hold deletion cleanup after a contact disappears from the list, then open that contact's Add +screen using its deep link. Release cleanup: the Add screen must remain open and Save must +still work. Repeat from Edit Contact and while viewing a different contact. Cleanup from the +deleted contact must not replace the newer navigation destination. + +Make a Contact Pay lookup fail with SDK contention, on both Contact Detail and the Send contact +picker. Verify the error uses localized retry guidance rather than SDK codes or context. A canceled +lookup must not display an error. Restore access and retry; payment validation must run again. 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/delete-newly-saved-contact.xml b/journeys/contacts/delete-newly-saved-contact.xml new file mode 100644 index 000000000..9a8aefdf6 --- /dev/null +++ b/journeys/contacts/delete-newly-saved-contact.xml @@ -0,0 +1,13 @@ + + + Requires an authenticated disposable identity and a valid contact not already saved. + The contact must have no active subscription. No payment is sent. + + + Open Contacts and add the contact by its Pubky key + On the Contact Saved screen, tap Delete and confirm + Verify Contacts appears and the deleted contact is absent, with no empty contact screen + Navigate Back and verify the deleted contact screen is not restored + Add the same contact again and verify its saved details appear + + diff --git a/journeys/contacts/import-all-contacts.xml b/journeys/contacts/import-all-contacts.xml index bdc4a31de..e30c6f9c1 100644 --- a/journeys/contacts/import-all-contacts.xml +++ b/journeys/contacts/import-all-contacts.xml @@ -3,6 +3,9 @@ Precondition: the import preview is open for a disposable Ring identity with known follows that are not yet saved in this Bitkit wallet. Include Pubky-only profiles without Paykit receivers. Repeat with a large list (for example 62 contacts). + Repeat after deleting this profile and selecting the same Ring identity again. + Measure Import All to Let Your Contacts Pay You and check logs for a terminal error + or an uncertain-write safety wait. Also repeat with an identity that follows itself. The preview count must exclude your own profile, and your own profile must be absent from the saved contacts. @@ -10,6 +13,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..94bca8c18 100644 --- a/journeys/paykit-clock-changes.md +++ b/journeys/paykit-clock-changes.md @@ -24,6 +24,9 @@ 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. +7. Repeat the cold-launch reminder while the startup request refresh fails before the stored request snapshot loads. Keep the reminder pending through the failure. If another request is available, open it manually: preparation and payment must remain usable, and a second Pay must not replace the active payment. Restore connectivity during confirmation and retry that manual payment; the reminder must wait until the manual flow ends. Dismiss it and allow a later refresh to open the reminder's exact due period without tapping the reminder again. Clear a stale reminder only after a successful snapshot confirms that period was handled or the subscription is inactive. Switching identities must discard the previous identity's pending reminder. +8. Open a due reminder whose payment cannot be prepared. The same Send sheet must remain open through resolution retries and close once on terminal feedback, without reopening from that reminder. Repeat and close the preparing sheet between retries; it must stay closed while the period remains pending and manually reopenable. An insufficient-balance or pending-private-link result must also end that reminder's presentation without an automatic reopen cycle. These steps describe the remaining manual verification. Unit tests cover injected restoration failures, state preservation, retry timing, UTC recurrence, and notification scheduling; they do not replace a live grant-session clock-change test. @@ -33,8 +36,8 @@ Network fault injection is not provided by the journey capability table. Use a d 1. Record the profile name, public key, contacts, receiving address, and wallet balance while online. 2. Disable both Wi-Fi and mobile data on the test device, force-stop Bitkit, and reopen it. Wait for session restoration to fail. The cached name must remain. Tap the profile header: verify the rendered screen contains the Retry button (`ProfileRetry`), not signup or the Ring chooser, even with a different identity available in Ring. A `.profile` navigation route alone does not prove this. Repeat with a saved identity that has no cached name and after the disconnected-profile toast has been dismissed. -3. Re-enable connectivity while leaving Bitkit open on the profile screen. The first retry waits roughly 8–12 seconds. Repeated failures back off with jitter to a maximum three-minute delay, even if no connection-restored toast appears; wait for the next retry. Reconnecting or foregrounding the app starts a fresh retry schedule. Verify that the same identity and contact list recover without selecting the Ring identity again, signing out, or restarting the app. If the saved grant is expired or revoked, recovery may sign in again with the shared Ring secret. Identity selection is required only when that secret is unavailable or invalid. +3. Re-enable connectivity while leaving Bitkit open on the profile screen. On iOS, the first eight temporary restoration deferrals retry after roughly 4–6 seconds each; persistent failure then backs off from ten seconds with jitter to a maximum three-minute delay. These delays start after each attempt returns, not while the SDK is still waiting for a lock or an uncertain write. Reconnecting or foregrounding the app starts a fresh retry schedule. Verify that the same identity and contact list recover without selecting the Ring identity again, signing out, or restarting the app. If the saved grant is expired or revoked, recovery may sign in again with the shared Ring secret. Identity selection is required only when that secret is unavailable or invalid. 4. Repeat using the profile's Retry button after restoring connectivity; it must restore authentication as well as load the profile. While session restoration or the profile fetch is pending, show the ProfileLoading indicator instead of the failure message and Retry button. If restoration fails again, stop loading and show Retry; preserve any already loaded profile while refreshing. Then repeat the failed startup and restore connectivity while Bitkit is backgrounded. Return to the foreground from a profile/contact screen and verify the same recovery. Resume must work from any screen, not only Home. 5. Separately start an explicit identity switch while recovery is pending and select a Ring-owned identity. A late restoration result must not replace that selection. Repeat after leaving the identity list with Back, then retry or foreground the app. Recovery must remain available, and explicit sign-out or wallet reset must not be undone by a pending restoration. Also let a pending Ring sign-in fail after sign-out or reset has completed; relaunching or foregrounding must not restore the old Ring identity. -Both platforms retry automatically on connectivity restoration and app resume. iOS also retries while foregrounded and unauthenticated, so recovery does not depend on a new network-path callback. Delays double from ten seconds, with jitter and a three-minute maximum; foreground and network changes reset the schedule. Recovery or absent saved credentials ends the loop, and backgrounding or starting wallet restoration cancels it. Keep a device offline long enough to reach the capped delay, then verify recovery on reconnect. Also verify that a wallet with no Pubky identity, or one after sign-out, does not keep retrying. A valid saved session must recover without another identity-selection or authorization step. An expired or revoked grant may recover with the shared Ring secret; the platform's Ring flow is needed only when that secret is unavailable or invalid. +Both platforms retry automatically on connectivity restoration and app resume. iOS also retries while foregrounded and unauthenticated, so recovery does not depend on a new network-path callback. Its bounded short retry window applies only to temporary deferrals; other failures retain exponential backoff. Recovery, absent saved credentials or a changed identity ends the loop, and backgrounding or starting wallet restoration cancels it. Keep a device offline long enough to reach the capped delay, then verify recovery on reconnect. Also verify that a wallet with no Pubky identity, or one after sign-out, does not keep retrying. A valid saved session must recover without another identity-selection or authorization step. An expired or revoked grant may recover with the shared Ring secret; the platform's Ring flow is needed only when that secret is unavailable or invalid. diff --git a/journeys/payment-requests/README.md b/journeys/payment-requests/README.md index 033c17459..4a45ba0a8 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,42 @@ 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. + +During preparation, `automatic-presentation.xml` checks the existing Send confirmation sheet with +the selected request's sender, amount and note. Its payment swipe control stays disabled and shows +progress until fresh validation and wallet preparation finish. The same sheet then becomes usable. +Closing during preparation leaves the request pending +and manually reopenable, but suppresses automatic reopening for the current identity's app session. +That suppression is not persisted. The request is marked presented only once ready. +`requested-resolution-failure.xml` also checks the return to the underlying request list or details. +An unfunded wallet can verify loading followed by wallet rejection, not an enabled payment control. + +## Accepting install + +Acceptance intent is saved before the remote operation and included in wallet backups. +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 +112,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 @@ -114,6 +150,8 @@ the SDK and can push the Pay step well past the budget. - Screen: `PaymentRequestsScreen`. - Detail screen: `PaymentRequestDetailScreen`. - Detail amount and status: `PaymentRequestDetailsAmount`, `PaymentRequestDetailsStatus`. +- Detail Pay action: `PaymentRequestDetailsPay`. +- Payment swipe control, including its loading spinner: `GRAB`. - Request row: `PaymentRequestRow--one-time`; use the complete identifier because `wait-for-ui` does not support prefix matching. - Pay action: `PaymentRequestPay-`. - Dismiss action: `PaymentRequestDismiss-`. @@ -135,12 +173,53 @@ the SDK and can push the Pay step well past the budget. `delete-contact-with-active-subscription.xml` requires an accepted open-ended payer subscription. It verifies that deletion explains why the contact must stay saved until the subscription ends, then that canceling, deleting, and readding does not revive it. No new payment is sent. Both contact-deletion journeys are mirrored on iOS and Android. +## Hardware broadcast recovery (manual) + +This requires a funded disposable regtest hardware wallet, a linked issuer with an absolute +payment deadline, and fault injection that can drop a successful broadcast response and hold +wallet reconciliation or its UI delivery. These controls are not journey capabilities; record +this test as blocked, not passed, when they are unavailable. Do not change the device clock. + +1. Submit before the deadline, forward the signed transaction to the regtest node, and drop + only its response. Record the node's transaction ID and keep wallet reconciliation paused. +2. Let the payment deadline pass and retry. Verify that no additional broadcast occurs, the + signed transaction and started proof remain retained, and an unknown outcome is not treated + as a definite failure that allows another payment. Once the retry finishes, Back and ordinary + dismissal remain available; an expired retry must not create a navigation lock. +3. Release reconciliation for that transaction in the same hardware wallet. Verify that the + existing send screen reaches normal success, pending progress clears, and navigation is + available again. Check the wallet's activity and issuer proof against the recorded transaction; + no additional broadcast or duplicate activity may be created. +4. Repeat with a new request, pausing UI delivery after the matching resolution is retained. + Detach and reattach the send view while retaining its AppViewModel and coordinator before releasing delivery. Verify the same + completion and navigation result; consuming the global proof event must not lose completion. +5. Deliver a resolution for a different identity, request or hardware wallet before the matching + one. Verify it cannot complete the pending send, then release the matching result and verify + normal completion. Capture redacted logs and UI evidence for each boundary. +6. Repeat with a first broadcast that never reaches the node but reports an uncertain outcome. + Retry after expiry, including expiry during authorization and while queued for hardware + submission. Verify that no new broadcast occurs and the user can leave without a resolution. + Dismissal must leave the started proof pending for later reconciliation, not permit a new payment. + +For definite hardware failures, inject Core's `InvalidHex` or `InvalidTransaction` before +the first network submission. Leave the signing screen and reopen the unpaid request; it must +remain payable. Repeat after an earlier uncertain submission: the proof must stay pending and +a fresh payment must remain blocked. Electrum and unclassified errors are not proof of rejection. + ## Payment deadline history -`payment-deadline-history.xml` covers rc56 requests with actual-payment deadlines. -Bitkit keeps their lifecycle and paid-period history, and subscription cancellation, -but does not accept them, offer payments, or schedule payment reminders. The journey -requires a controlled rc56 peer to prepare the accepted and paid records; repository + +`absolute-payment-deadline.xml` verifies valid absolute deadlines, acceptance before proposal expiry, +retry after proposal expiry, expiry during invoice retrieval, and proof reconciliation after the deadline. +It matches Android's journey, using the iOS `Retry` accessibility identifier. + +`payment-deadline-history.xml` covers expired one-time actual-payment deadlines and +unsupported recurring deadlines. Bitkit keeps their lifecycle and paid-period history, +and subscription cancellation, but does not offer expired or unsupported payments. +Future one-time absolute deadlines are supported, including fractional UTC timestamps; +the deadline is inclusive and is checked again at payment submission. Proposal expiry +does not expire an already-accepted payment, and proof delivery may finish after the payment deadline. +The journey requires a controlled shared-runtime peer to prepare the accepted and paid records; repository tests cover these states without sending funds. On iOS, payment-history rows show notes or dates rather than lifecycle labels. On Android, unpaid history rows show lifecycle labels, while paid rows show subscription names, notes, or dates. Active subscriptions diff --git a/journeys/payment-requests/absolute-payment-deadline.xml b/journeys/payment-requests/absolute-payment-deadline.xml new file mode 100644 index 000000000..a19458e85 --- /dev/null +++ b/journeys/payment-requests/absolute-payment-deadline.xml @@ -0,0 +1,27 @@ + + + Verifies one-time BTC requests with absolute At payment deadlines from a controlled Paykit issuer. + Use disposable linked regtest identities, a funded test wallet, and a controlled LNURL invoice callback. + Proposal expiry is the acceptance deadline, not the actual-payment deadline. Absolute payment deadlines + are inclusive UTC timestamps and may include fractional seconds. Recurring PeriodStart deadlines remain + unsupported. Record each request id and the issuer's UTC deadline without changing the device clock. + Lost broadcast responses require the separate hardware recovery manual test in this suite's README; + running this journey does not verify that fault-injection scenario. + + + Have the issuer send a one-time request with a future absolute payment deadline containing fractional seconds and a shorter proposal expiry + Open the request and verify its amount and payment review are available before either deadline + Configure the invoice callback to fail, then swipe to pay before proposal expiry so acceptance completes but no payment is dispatched + Wait until proposal expiry has passed while the payment deadline is still in the future + Restore the callback, tap Try Again (id "Retry"), and verify the accepted request returns to payment review + Swipe to pay before the payment deadline and verify payment succeeds + Have the issuer send another request with a short future absolute payment deadline and open its payment review + Delay the invoice callback, swipe to pay before the deadline, and release the callback only after the deadline + Verify no new Lightning payment is dispatched and the expired request cannot be paid again + Repeat with a new on-chain request, waiting past its payment deadline during fee confirmation or PIN entry, and verify no transaction is submitted + Repeat with a hardware wallet, waiting past its payment deadline during signing, and verify an expired-payment error is shown without broadcasting + Have the issuer send a request whose absolute payment deadline is already past and verify no Pay action is offered + Keep proof delivery unavailable for a payment sent before its deadline, restore delivery after the deadline, and verify reconciliation still delivers its proof without sending another payment + Restart Bitkit and verify completed and expired request history remains visible + + diff --git a/journeys/payment-requests/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..cc7ea79ed 100644 --- a/journeys/payment-requests/automatic-presentation.xml +++ b/journeys/payment-requests/automatic-presentation.xml @@ -1,20 +1,33 @@ - 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" + While the payer prepares the request, verify the existing Payment Request confirmation sheet (id "PaymentRequestConfirm") shows the requester's contact name, 21,000 sats and "First request", with the payment swipe control (id "GRAB") disabled and showing progress + Close the confirmation while preparation is still pending + Allow preparation and another private-message sync to finish; verify the request does not reopen automatically + Open Payment Requests, verify "First request" is still pending, and tap Pay on it + Verify the existing Payment Request confirmation sheet shows the same sender, amount and note with its payment control disabled and showing progress + Wait for fresh validation and wallet preparation to finish; verify that the same sheet remains open and its payment swipe control becomes available without dismissing and reopening the sheet Close the confirmation without paying On the payer, open Receive and leave its sheet open On the requester, send the payer contact another Payment Request for 5,000 sats with the note "Second request" Wait for the requester's send operation to finish and for the payer to synchronize its pending requests On the payer, verify Receive stays open without being replaced by confirmation + Verify no preparing payment sheet or confirmation covers Receive Close Receive Verify Payment Request confirmation (id "PaymentRequestConfirm") appears automatically with 5,000 sats and For shows "Second request" Close the confirmation without paying Open Receive and close it again Verify Home remains visible and neither reviewed request reopens automatically + On the payer, open the Home menu (id "HeaderMenu") before the next request arrives + On the requester, send a Payment Request above the payer's available balance with the note "Unaffordable request" + Wait for the payer to synchronize the request and verify no preparing payment sheet or confirmation covers the open drawer + Close the drawer and verify the existing Payment Request confirmation sheet shows the request metadata with its payment control disabled and showing progress + On the payer, verify an insufficient-balance toast appears once and the preparing confirmation closes without enabling payment + Allow another pending-request sync, then open Receive and close it; verify the unaffordable request does not automatically prepare again or repeat its toast + Open Payment Requests and tap Pay on "Unaffordable request"; verify this explicit retry shows the insufficient-balance toast again diff --git a/journeys/payment-requests/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..a873eb4d7 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") + Verify the existing Payment Request confirmation sheet shows 100,000 sats with its payment swipe control (id "GRAB") disabled and showing progress while the request is prepared Verify the Payment Request confirmation screen (id "PaymentRequestConfirm") shows 100,000 sats + Wait for fresh validation and wallet preparation to finish; verify the same sheet stays open and its payment swipe control stops showing progress Tap Show details (id "SendConfirmToggleDetails") Verify the recipient (id "ReviewContactRecipient") is the saved fixture issuer contact diff --git a/journeys/payment-requests/payment-deadline-history.xml b/journeys/payment-requests/payment-deadline-history.xml index ad3022a4e..ab40324e7 100644 --- a/journeys/payment-requests/payment-deadline-history.xml +++ b/journeys/payment-requests/payment-deadline-history.xml @@ -1,11 +1,11 @@ - Requests with actual-payment deadlines are visible but cannot be paid by this version of Bitkit. - Use a disposable Paykit test identity linked to a controlled rc56 peer. Prepare one-time BTC - requests with deadlines in proposed, accepted, rejected, canceled and proof-submitted states, + One-time requests past their actual-payment deadlines and unsupported recurring deadlines remain visible in history. + Use a disposable Paykit test identity linked to a controlled shared-runtime peer. Prepare one-time BTC + requests with expired absolute deadlines in proposed, accepted, rejected, canceled and proof-submitted states, plus an incoming, accepted monthly BTC subscription with no end date, a period-start deadline, one paid period and one unpaid period. Acceptance and proof submission must be prepared through the controlled - client because Bitkit intentionally cannot accept these terms. Use valid regtest payment proofs. + client to retain these expired and unsupported fixture states. Use valid regtest payment proofs. Do not reset or replace a funded wallet to prepare this fixture. On iOS, history rows show notes or dates instead of status labels. On Android, unpaid history rows show status labels, while paid rows show subscription names, notes or dates. Record each payment request id. diff --git a/journeys/payment-requests/request-summary.xml b/journeys/payment-requests/request-summary.xml index 31f2c5918..7ee861bd0 100644 --- a/journeys/payment-requests/request-summary.xml +++ b/journeys/payment-requests/request-summary.xml @@ -1,14 +1,15 @@ - 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" + On the requester, verify completion (id "PaymentRequestSent") says "Sent" with "You have sent a payment request", or "Queued" with "Your payment request is queued and will send automatically"; the title and description must agree On the payer, verify the Payment Request confirmation screen (id "PaymentRequestConfirm") appears with 21,000 sats without opening the pending Payment Requests bell Verify From (id "PaymentRequestFrom") shows the requester's contact name Verify For (id "PaymentRequestFor") shows "Lunch last week" 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..f8cb258f2 100644 --- a/journeys/payment-requests/requested-resolution-failure.xml +++ b/journeys/payment-requests/requested-resolution-failure.xml @@ -2,7 +2,9 @@ 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. + An explicit Pay opens the existing Send confirmation sheet with known request metadata while + resolution runs; the underlying request list or details remain available after it closes. Precondition: onboarded dev wallet with Paykit UI enabled, a profile, and one linked saved contact. Seed exactly one proposed incoming Payment Request from that contact with a known @@ -13,9 +15,17 @@ Verify the incoming request row (id "PaymentRequestRow-<payment-request-id>-one-time") is visible Tap Pay (id "PaymentRequestPay-<payment-request-id>") + Verify the existing Payment Request confirmation sheet (id "PaymentRequestConfirm") shows the sender, amount and note, with the payment swipe control (id "GRAB") disabled and showing progress while the request is prepared + Verify the same preparing confirmation stays open across resolution retries without dismissing and reopening Wait up to 35 seconds for the terminal error toast (id "PaymentRequestUnavailableToast") + Verify the preparing confirmation sheet is no longer visible Verify the toast title is "Payment Request" and its description is "The payment request is no longer available." 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 + Open the incoming request row and tap Pay in its details (id "PaymentRequestDetailsPay") + Verify the existing Payment Request confirmation sheet shows the same sender, amount and note, with the payment swipe control disabled and showing progress + Close the preparing confirmation between resolution retries and verify the underlying details remain available and the request does not immediately reopen or retry automatically + While preparation is pending, navigate to Dev settings and turn off Enable Paykit UI (id "PaykitUiToggle") + Return to the wallet home screen and verify no payment sheet opens diff --git a/journeys/profile/README.md b/journeys/profile/README.md index 95526ceeb..35a21e8b7 100644 --- a/journeys/profile/README.md +++ b/journeys/profile/README.md @@ -6,4 +6,6 @@ Delayed or failed session restoration requires network fault injection, which is not a journey-runner capability; see the PR manual checks. +For a saved identity, inject a temporary import failure (`concurrent_update`, `shared_state_busy`, or a transport failure) during cold start. Verify that no "Session expired" toast appears, the saved credentials remain, and recovery succeeds after the temporary failure ends. A genuinely invalid session must still produce the existing expiry feedback. + For the reported background-resume case, verify returning directly to Profile with the process still alive, then repeat after the OS recreates the process. A delay in session recovery must keep the saved identity on the profile loading/retry screen rather than show profile onboarding. diff --git a/journeys/profile/delete-profile.xml b/journeys/profile/delete-profile.xml index b5e948e6c..9244c247a 100644 --- a/journeys/profile/delete-profile.xml +++ b/journeys/profile/delete-profile.xml @@ -3,6 +3,8 @@ Precondition: an onboarded disposable test wallet with Paykit enabled and a profile created through Bitkit. The profile and its contacts will be deleted; do not use a personal identity. Record the sequence so progress can be checked even when deletion completes quickly. + Repeat with 62 imported contacts, once after preparation settles and once immediately after import. + Measure confirmation to onboarding and check logs for any uncertain-write safety wait. Open the existing profile from the Home header 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.