diff --git a/Bitkit.xcodeproj/project.pbxproj b/Bitkit.xcodeproj/project.pbxproj index f6fa87289..7182c97e1 100644 --- a/Bitkit.xcodeproj/project.pbxproj +++ b/Bitkit.xcodeproj/project.pbxproj @@ -1182,7 +1182,7 @@ repositoryURL = "https://github.com/pubky/paykit-rs"; requirement = { kind = exactVersion; - version = "0.1.0-rc43"; + version = "0.1.0-rc46"; }; }; 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 491c9b1fc..db4383107 100644 --- a/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -42,8 +42,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pubky/paykit-rs", "state" : { - "revision" : "6b241878a9bba5cecea919c0298c3f90624be6ff", - "version" : "0.1.0-rc43" + "revision" : "09e388d82f70d02be9860b8b6ac108ae722a3fb9", + "version" : "0.1.0-rc46" } }, { diff --git a/Bitkit/AppScene.swift b/Bitkit/AppScene.swift index 92dd2a859..ff455107e 100644 --- a/Bitkit/AppScene.swift +++ b/Bitkit/AppScene.swift @@ -202,6 +202,7 @@ struct AppScene: View { .environment(paykitPaymentRequestManager) .onChange(of: pubkyProfile.authState, initial: true) { _, authState in if authState == .authenticated, let pk = pubkyProfile.publicKey { + paykitPaymentRequestManager.activate(identity: pk) Task { try? await contactsManager.loadContacts(for: pk) await refreshPrivateOnlyPaykitReceiverMarker() @@ -235,20 +236,32 @@ struct AppScene: View { .onReceive(PrivatePaykitService.initialLinkBurstStartedPublisher) { initialPaykitSyncGeneration += 1 } - .onReceive(sheets.$activeSheetConfiguration) { configuration in - guard configuration == nil else { return } + .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 presentNextIncomingPaykitPaymentRequest() + } + } + .onChange(of: paykitPaymentRequestManager.requestedPresentationId) { _, requestId in + guard requestId != nil else { return } + Task { await presentNextIncomingPaykitPaymentRequest() } + } + .onChange(of: paykitPaymentRequestManager.presentationRetryTrigger) { Task { await presentNextIncomingPaykitPaymentRequest() } } .onChange(of: paykitPaymentRequestManager.pendingRequests) { _, requests in guard let request = app.contactPaymentContext?.incomingPaymentRequest, - request.isExpired(at: Date()), !requests.contains(where: { $0.id == request.id }), + !paykitPaymentRequestManager.isApprovedForPayment(request), sheets.activeSheetConfiguration?.id == .send else { return } - app.resetSendState() - wallet.resetSendState(speed: settings.defaultTransactionSpeed) - sheets.hideSheetIfActive(.send, reason: "Incoming payment request expired") + sheets.hideSheetIfActive(.send, reason: "Incoming payment request is no longer available") } .onChange(of: navigation.currentRoute) { oldRoute, newRoute in guard shouldDiscardPendingImport(currentRoute: oldRoute, destination: newRoute) else { @@ -752,9 +765,13 @@ struct AppScene: View { guard PaykitFeatureFlags.isUIEnabled, wallet.walletExists == true, pubkyProfile.authState == .authenticated - else { return false } + else { + paykitPaymentRequestManager.clearEligibleTargets() + return false + } let previousRequests = paykitPaymentRequestManager.pendingRequests + await paykitPaymentRequestManager.refreshEligibleTargets(savedPublicKeys: contactsManager.contacts.map(\.publicKey)) await paykitPaymentRequestManager.refresh() await presentNextIncomingPaykitPaymentRequest() return paykitPaymentRequestManager.pendingRequests != previousRequests @@ -800,16 +817,23 @@ struct AppScene: View { private func presentNextIncomingPaykitPaymentRequest() async { guard sheets.activeSheetConfiguration == nil, + !sheets.isReplacingSheet, app.contactPaymentContext == nil else { return } - await paykitPaymentRequestManager.presentRequests { requests in - guard sheets.activeSheetConfiguration == nil, app.contactPaymentContext == nil else { return } + let attemptedPresentation = await paykitPaymentRequestManager.presentRequests { requests in + guard sheets.activeSheetConfiguration == nil, !sheets.isReplacingSheet, app.contactPaymentContext == nil else { return } for request in requests { + guard paykitPaymentRequestManager.isCurrentPresentation(request) else { return } do { let result = try await PrivatePaykitService.shared.beginPaymentRequest(request) - guard sheets.activeSheetConfiguration == nil, app.contactPaymentContext == nil else { return } + guard paykitPaymentRequestManager.isCurrentPresentation(request), + sheets.activeSheetConfiguration == nil, + !sheets.isReplacingSheet, + app.contactPaymentContext == nil + else { return } guard case let .opened(paymentTarget, privatePaymentContext) = result else { + Logger.debug("Incoming Paykit payment request is waiting for private payment details: \(result)", context: "AppScene") paykitPaymentRequestManager.deferPresentation(request) continue } @@ -826,9 +850,17 @@ struct AppScene: View { paymentTarget, claimedContactPaymentContext: contactPaymentContext ) - guard app.ownsContactPaymentContext(contactPaymentContext), - sheets.activeSheetConfiguration == nil - else { return } + guard paykitPaymentRequestManager.isCurrentPresentation(request), + app.ownsContactPaymentContext(contactPaymentContext), + sheets.activeSheetConfiguration == nil, + !sheets.isReplacingSheet + else { + if app.ownsContactPaymentContext(contactPaymentContext) { + app.resetSendState() + wallet.resetSendState(speed: settings.defaultTransactionSpeed) + } + return + } guard PaymentNavigationHelper.appropriateSendRoute(app: app, currency: currency, settings: settings) != nil else { app.resetSendState() wallet.resetSendState(speed: settings.defaultTransactionSpeed) @@ -836,10 +868,10 @@ struct AppScene: View { continue } - guard paykitPaymentRequestManager.markPresentedIfPending(request) else { + guard paykitPaymentRequestManager.isCurrentPresentation(request) else { app.resetSendState() wallet.resetSendState(speed: settings.defaultTransactionSpeed) - continue + return } } catch is CancellationError { if app.ownsContactPaymentContext(contactPaymentContext) { @@ -849,6 +881,11 @@ struct AppScene: View { return } catch { guard app.ownsContactPaymentContext(contactPaymentContext) else { return } + guard paykitPaymentRequestManager.isCurrentPresentation(request) else { + app.resetSendState() + wallet.resetSendState(speed: settings.defaultTransactionSpeed) + return + } Logger.warn("Failed to present incoming Paykit payment request: \(error)", context: "AppScene") app.resetSendState() wallet.resetSendState(speed: settings.defaultTransactionSpeed) @@ -857,16 +894,31 @@ struct AppScene: View { } let route: SendRoute = app.lnurlPayData == nil ? .confirm : .lnurlPayConfirm + guard paykitPaymentRequestManager.isCurrentPresentation(request) else { + app.resetSendState() + wallet.resetSendState(speed: settings.defaultTransactionSpeed) + return + } sheets.showSheet(.send, data: SendConfig(view: route)) return } catch is CancellationError { return } catch { + guard paykitPaymentRequestManager.isCurrentPresentation(request) else { return } Logger.warn("Failed to present incoming Paykit payment request: \(error)", context: "AppScene") paykitPaymentRequestManager.deferPresentation(request) } } } + + guard attemptedPresentation, + paykitPaymentRequestManager.requestedPresentationId != nil || + !paykitPaymentRequestManager.requestsForPresentation().isEmpty, + sheets.activeSheetConfiguration == nil, + !sheets.isReplacingSheet, + app.contactPaymentContext == nil + else { return } + await presentNextIncomingPaykitPaymentRequest() } private func retryPendingPaykitEndpointRemoval() async { diff --git a/Bitkit/Components/DrawerView.swift b/Bitkit/Components/DrawerView.swift index 7af367f39..10a8335de 100644 --- a/Bitkit/Components/DrawerView.swift +++ b/Bitkit/Components/DrawerView.swift @@ -3,6 +3,7 @@ import SwiftUI enum DrawerMenuItem: Int, CaseIterable, Identifiable, Hashable { case wallet case activity + case paymentRequests case contacts case profile case widgets @@ -19,6 +20,7 @@ enum DrawerMenuItem: Int, CaseIterable, Identifiable, Hashable { switch self { case .wallet: return "coins" case .activity: return "activity" + case .paymentRequests: return "file-text" case .contacts: return "users" case .profile: return "user-square" case .widgets: return "stack" @@ -33,6 +35,7 @@ enum DrawerMenuItem: Int, CaseIterable, Identifiable, Hashable { switch self { case .wallet: return t("wallet__drawer__wallet") case .activity: return t("wallet__drawer__activity") + case .paymentRequests: return t("wallet__drawer__payment_requests") case .contacts: return t("wallet__drawer__contacts") case .profile: return t("wallet__drawer__profile") case .widgets: return t("wallet__drawer__widgets") @@ -56,6 +59,7 @@ enum DrawerMenuItem: Int, CaseIterable, Identifiable, Hashable { switch self { case .wallet: return "DrawerWallet" case .activity: return "DrawerActivity" + case .paymentRequests: return "DrawerPaymentRequests" case .contacts: return "DrawerContacts" case .profile: return "DrawerProfile" case .widgets: return "DrawerWidgets" @@ -68,6 +72,8 @@ enum DrawerMenuItem: Int, CaseIterable, Identifiable, Hashable { } struct DrawerView: View { + @AppStorage(PaykitFeatureFlags.uiEnabledKey) private var isPaykitUIEnabled = false + @EnvironmentObject private var app: AppViewModel @EnvironmentObject private var navigation: NavigationViewModel @EnvironmentObject private var settings: SettingsViewModel @@ -78,6 +84,12 @@ struct DrawerView: View { @State private var showBackdrop = false @State private var showMenu = false + private var mainMenuItems: [DrawerMenuItem] { + DrawerMenuItem.allCases.filter { item in + item.isMainMenuItem && (item != .paymentRequests || PaykitFeatureFlags.isUIAvailable && isPaykitUIEnabled) + } + } + private func closeMenu() { withAnimation(.easeOut(duration: 0.25)) { showBackdrop = false @@ -104,6 +116,7 @@ struct DrawerView: View { switch item { case .wallet: return nil case .activity: return .activityList + case .paymentRequests: return .paymentRequests case .contacts: return .contacts case .profile: return .profile case .widgets: return nil @@ -163,7 +176,7 @@ struct DrawerView: View { if showMenu { GeometryReader { geometry in VStack(alignment: .leading, spacing: 0) { - ForEach(DrawerMenuItem.allCases.filter(\.isMainMenuItem)) { item in + ForEach(mainMenuItems) { item in Button(action: { selectDrawerItem(item) }) { diff --git a/Bitkit/Components/Header.swift b/Bitkit/Components/Header.swift index 3c3dec200..f4b61fe27 100644 --- a/Bitkit/Components/Header.swift +++ b/Bitkit/Components/Header.swift @@ -8,6 +8,8 @@ struct Header: View { @EnvironmentObject var app: AppViewModel @EnvironmentObject var navigation: NavigationViewModel @EnvironmentObject var pubkyProfile: PubkyProfileManager + @EnvironmentObject private var sheets: SheetViewModel + @Environment(PaykitPaymentRequestManager.self) private var paymentRequests /// When true, shows the widget edit button (only on the widgets tab). var showWidgetEditButton: Bool = false @@ -40,6 +42,30 @@ struct Header: View { } ) + if isPaykitUIActive, !paymentRequests.pendingRequests.isEmpty { + Button { + if dismissCalculatorIfNeeded() { return } + sheets.showSheet(.paymentRequests) + } label: { + Image("bell") + .resizable() + .scaledToFit() + .foregroundColor(.brandAccent) + .frame(width: 24, height: 24) + .frame(width: 32, height: 32) + .contentShape(Rectangle()) + .shadow(color: .brandAccent.opacity(0.5), radius: 8) + } + .accessibilityLabel(t("wallet__payment_requests")) + .accessibilityValue( + t( + "wallet__payment_requests_pending_count", + variables: ["count": "\(paymentRequests.pendingRequests.count)"] + ) + ) + .accessibilityIdentifier("PaymentRequestsBell") + } + if showWidgetEditButton { Button(action: { if dismissCalculatorIfNeeded() { return } diff --git a/Bitkit/Components/NoteTextEditor.swift b/Bitkit/Components/NoteTextEditor.swift new file mode 100644 index 000000000..33bc657b6 --- /dev/null +++ b/Bitkit/Components/NoteTextEditor.swift @@ -0,0 +1,32 @@ +import SwiftUI + +struct NoteTextEditor: View { + @Binding var text: String + let placeholder: String + let testIdentifier: String + let isFocused: FocusState.Binding + + var body: some View { + ZStack(alignment: .topLeading) { + if text.isEmpty { + BodySSBText(placeholder, textColor: .textSecondary) + } + + TextEditor(text: $text) + .focused(isFocused) + .font(.custom(Fonts.semiBold, size: 15)) + .foregroundColor(.textPrimary) + .accentColor(.brandAccent) + .submitLabel(.done) + .scrollContentBackground(.hidden) + .padding(EdgeInsets(top: -8, leading: -5, bottom: -5, trailing: -5)) + .frame(minHeight: 30, maxHeight: 50) + .dismissKeyboardOnReturn(text: $text, isFocused: isFocused) + .accessibilityValue(text) + .accessibilityIdentifier(testIdentifier) + } + .padding() + .background(Color.white06) + .cornerRadius(8) + } +} diff --git a/Bitkit/Components/PubkyContactRow.swift b/Bitkit/Components/PubkyContactRow.swift index f1c95b754..919060b9c 100644 --- a/Bitkit/Components/PubkyContactRow.swift +++ b/Bitkit/Components/PubkyContactRow.swift @@ -5,6 +5,8 @@ struct PubkyContactRow: View { var verticalPadding: CGFloat = 12 var showsDivider = true var isLoading = false + var isSelected = false + var selectionColor: Color = .brandAccent let action: () -> Void var body: some View { @@ -25,6 +27,13 @@ struct PubkyContactRow: View { if isLoading { ProgressView() + } else if isSelected { + Image("check-mark") + .resizable() + .scaledToFit() + .foregroundColor(selectionColor) + .frame(width: 24, height: 24) + .accessibilityHidden(true) } } .padding(.vertical, verticalPadding) @@ -33,6 +42,7 @@ struct PubkyContactRow: View { .buttonStyle(.plain) .disabled(isLoading) .accessibilityLabel(contact.displayName) + .accessibilityAddTraits(isSelected ? .isSelected : []) if showsDivider { CustomDivider() diff --git a/Bitkit/Constants/Env.swift b/Bitkit/Constants/Env.swift index 9c90c109f..ff1facd54 100644 --- a/Bitkit/Constants/Env.swift +++ b/Bitkit/Constants/Env.swift @@ -83,6 +83,10 @@ enum Env { infoPlistValue("E2E_HOMEGATE_URL") ?? "http://\(e2eLocalHost):6288" } + static var e2eHomeserverPubky: String? { + isLocalE2EBackend ? infoPlistValue("E2E_HOMESERVER_PUBKY") : nil + } + static var pubkyLocalTestnetHost: String? { isLocalE2EBackend ? e2eLocalHost : nil } diff --git a/Bitkit/Info.plist b/Bitkit/Info.plist index 28416207a..020553009 100644 --- a/Bitkit/Info.plist +++ b/Bitkit/Info.plist @@ -23,6 +23,8 @@ $(E2E_BACKEND) E2E_HOMEGATE_URL $(E2E_HOMEGATE_URL) + E2E_HOMESERVER_PUBKY + $(E2E_HOMESERVER_PUBKY) E2E_LOCAL_HOST $(E2E_LOCAL_HOST) E2E_NETWORK diff --git a/Bitkit/MainNavView.swift b/Bitkit/MainNavView.swift index 119f82f6c..cf45f67b6 100644 --- a/Bitkit/MainNavView.swift +++ b/Bitkit/MainNavView.swift @@ -122,6 +122,14 @@ struct MainNavView: View { ) { config in NotificationsSheet(config: config) } + .sheet( + item: $sheets.paymentRequestsSheetItem, + onDismiss: { + sheets.hideSheetIfActive(.paymentRequests, reason: "Payment requests sheet dismissed") + } + ) { + config in PaymentRequestsSheet(config: config) + } .sheet( item: $sheets.receiveSheetItem, onDismiss: { @@ -174,6 +182,8 @@ struct MainNavView: View { .sheet( item: $sheets.sendSheetItem, onDismiss: { + app.resetSendState() + wallet.resetSendState(speed: settings.defaultTransactionSpeed) sheets.hideSheetIfActive(.send, reason: "Send sheet dismissed") } ) { @@ -530,6 +540,8 @@ struct MainNavView: View { if isPaykitUIActive { EditProfileView() } else { paykitDisabledRedirectView } case .payContacts: if isPaykitUIActive { PayContactsView() } else { paykitDisabledRedirectView } + case .paymentRequests: + if isPaykitUIActive { PaymentRequestsView() } else { paykitDisabledRedirectView } // Shop case .shopIntro: ShopIntro() diff --git a/Bitkit/Managers/PubkyProfileManager.swift b/Bitkit/Managers/PubkyProfileManager.swift index 0a053cadf..b3da17c3d 100644 --- a/Bitkit/Managers/PubkyProfileManager.swift +++ b/Bitkit/Managers/PubkyProfileManager.swift @@ -255,14 +255,20 @@ class PubkyProfileManager: ObservableObject { let (publicKeyZ32, secretKeyHex) = try await deriveKeys() _ = try await Task.detached { - let homegate = try await Self.fetchHomegateSignupCode() + let signupDetails: (homeserverPubky: String, signupCode: String?) + if let homeserverPubky = Env.e2eHomeserverPubky { + signupDetails = (homeserverPubky, nil) + } else { + let homegate = try await Self.fetchHomegateSignupCode() + signupDetails = (homegate.homeserverPubky, homegate.signupCode) + } var session: String do { session = try await PubkyService.signUp( secretKeyHex: secretKeyHex, - homeserverZ32: homegate.homeserverPubky, - signupCode: homegate.signupCode + homeserverZ32: signupDetails.homeserverPubky, + signupCode: signupDetails.signupCode ) } catch { Logger.info("signUp failed (likely already registered), trying signIn: \(error)", context: "PubkyProfileManager") diff --git a/Bitkit/Resources/Localization/en.lproj/Localizable.strings b/Bitkit/Resources/Localization/en.lproj/Localizable.strings index b31175674..fec09aab0 100644 --- a/Bitkit/Resources/Localization/en.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/en.lproj/Localizable.strings @@ -1214,6 +1214,7 @@ "profile__suggestions_title" = "Suggestions To Add"; "wallet__drawer__wallet" = "Wallet"; "wallet__drawer__activity" = "Activity"; +"wallet__drawer__payment_requests" = "Requests"; "wallet__drawer__contacts" = "Contacts"; "wallet__drawer__profile" = "Profile"; "wallet__drawer__widgets" = "Widgets"; @@ -1465,6 +1466,40 @@ "wallet__payment_request_expired" = "The payment request has expired."; "wallet__payment_request_in_progress" = "The payment request is already being processed."; "wallet__payment_request_mismatch" = "The payment details did not match the request. Payment cancelled."; +"wallet__payment_requests" = "Payment Requests"; +"wallet__payment_requests_pending_count" = "{count} pending"; +"wallet__payment_requests_review" = "Review each request, then pay or dismiss."; +"wallet__payment_request_dismiss" = "Dismiss"; +"wallet__payment_requests_not_now" = "Not Now"; +"wallet__payment_requests_see_all" = "See All"; +"wallet__payment_requests_empty_description" = "You have not made any payments to providers and don’t have any payment requests yet."; +"wallet__payment_requests_empty_headline" = "No Payment\nHistory"; +"wallet__payment_request_status_accepted" = "Accepted"; +"wallet__payment_request_status_rejected" = "Rejected"; +"wallet__payment_request_status_canceled" = "Canceled"; +"wallet__payment_request_status_expired" = "Expired"; +"wallet__payment_request_status_proof_submitted" = "Proof submitted"; +"wallet__payment_request_status_action_required" = "Action required"; +"wallet__payment_request_status_unavailable" = "Unavailable"; +"wallet__payment_request_send" = "Send Payment Request"; +"wallet__payment_request_amount" = "Amount"; +"wallet__payment_request_expires" = "Expires In"; +"wallet__payment_request_expiry_hour" = "1 hour"; +"wallet__payment_request_expiry_day" = "1 day"; +"wallet__payment_request_expiry_week" = "1 week"; +"wallet__payment_request_expiry_month" = "1 month"; +"wallet__payment_request_choose_recipient" = "Choose Recipient"; +"wallet__payment_request_recipient" = "Recipient"; +"wallet__payment_request_enter_pubky" = "Enter pubky"; +"wallet__payment_request_request_payment" = "Request Payment"; +"wallet__payment_request_send_request" = "Send Request"; +"wallet__payment_request_sent_title" = "Sent"; +"wallet__payment_request_sent_headline" = "Payment Requested"; +"wallet__payment_request_sent_description" = "You have sent a payment request"; +"wallet__payment_request_queued_description" = "Your payment request is queued and will send automatically"; +"wallet__payment_request_sending" = "Sending request"; +"wallet__payment_request_waiting" = "Waiting for payment"; +"wallet__payment_request_waiting_for_recipient" = "Waiting for {name} to pay"; "wallet__instant_payment_received" = "Received Instant Bitcoin"; "wallet__error_create_tx" = "Transaction Creation Failed"; "wallet__error_create_tx_msg" = "An error occurred. Please try again {raw}"; diff --git a/Bitkit/Services/PaykitPaymentRequestService.swift b/Bitkit/Services/PaykitPaymentRequestService.swift index 86619dc9c..58693a982 100644 --- a/Bitkit/Services/PaykitPaymentRequestService.swift +++ b/Bitkit/Services/PaykitPaymentRequestService.swift @@ -1,8 +1,18 @@ import Foundation import Paykit -struct PaykitPaymentRequest: Identifiable, Equatable { - struct ID: Hashable { +struct PaykitPaymentRequest: Identifiable, Hashable { + enum Direction: Hashable { + case incoming + case outgoing + } + + enum DeliveryStatus: Hashable { + case queued + case sent + } + + struct ID: Codable, Hashable { let paymentRequestId: String let counterparty: String let counterpartyReceiverPath: String @@ -13,8 +23,13 @@ struct PaykitPaymentRequest: Identifiable, Equatable { let counterpartyReceiverPath: String let amountValue: String let amountSats: UInt64 + let note: String? + let createdAt: Date? let expiresAt: Date? let acceptedPaymentEndpointIdentifiers: [String] + let deliveryStatus: DeliveryStatus? + let direction: Direction + let lifecycleState: Paykit.PaymentRequestLifecycleState var id: ID { ID( @@ -25,8 +40,27 @@ struct PaykitPaymentRequest: Identifiable, Equatable { } init?(record: Paykit.PaymentRequestRecord, now: Date) { - guard record.localRole == .payer, - record.state == .proposed, + self.init(record: record, expectedRole: .payer, now: now, requiresActionableRequest: true) + } + + init?(historyRecord: Paykit.PaymentRequestRecord, now: Date) { + guard let localRole = historyRecord.localRole else { return nil } + switch localRole { + case .payer, .payee: + self.init(record: historyRecord, expectedRole: localRole, now: now, requiresActionableRequest: false) + case .unknown: + return nil + } + } + + private init?( + record: Paykit.PaymentRequestRecord, + expectedRole: Paykit.PaymentRequestLocalRole, + now: Date, + requiresActionableRequest: Bool + ) { + guard record.localRole == expectedRole, + record.state != .activeRecurring, let terms = record.terms, terms.recurrence == nil, terms.amount.asset == "btc", @@ -34,14 +68,22 @@ struct PaykitPaymentRequest: Identifiable, Equatable { amountSats <= UInt64.max / 1000 else { return nil } + if requiresActionableRequest, record.state != .proposed { + return nil + } + let acceptedPaymentEndpointIdentifiers = Self.supportedEndpointIdentifiers( terms.acceptedPaymentEndpointIdentifiers ) - guard !acceptedPaymentEndpointIdentifiers.isEmpty else { return nil } + if requiresActionableRequest, acceptedPaymentEndpointIdentifiers.isEmpty { + return nil + } let expiresAt: Date? if let proposalExpiresAt = terms.proposalExpiresAt { - guard let parsedExpiration = Self.parseDate(proposalExpiresAt), parsedExpiration > now else { + guard let parsedExpiration = Self.parseDate(proposalExpiresAt), + !requiresActionableRequest || parsedExpiration > now + else { return nil } expiresAt = parsedExpiration @@ -54,8 +96,81 @@ struct PaykitPaymentRequest: Identifiable, Equatable { counterpartyReceiverPath = record.counterpartyReceiverPath amountValue = terms.amount.value self.amountSats = amountSats + note = Self.note(from: terms.metadata) + createdAt = record.lastEventAt.flatMap(Self.parseDate) self.expiresAt = expiresAt self.acceptedPaymentEndpointIdentifiers = acceptedPaymentEndpointIdentifiers + deliveryStatus = expectedRole == .payee ? Self.deliveryStatus(from: record.proposalOutboundStatus) : nil + direction = expectedRole == .payer ? .incoming : .outgoing + lifecycleState = record.state + } + + init( + createdRecord: Paykit.PaymentRequestRecord, + draft: PaykitPaymentRequestDraft, + target: PaykitPaymentRequestTarget, + acceptedPaymentEndpointIdentifiers: [String], + deliveryStatus: DeliveryStatus, + createdAt fallbackCreatedAt: Date + ) { + 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 + self.acceptedPaymentEndpointIdentifiers = acceptedPaymentEndpointIdentifiers + self.deliveryStatus = deliveryStatus + direction = .outgoing + lifecycleState = .proposed + } + + func updatingLifecycleState(_ state: Paykit.PaymentRequestLifecycleState) -> PaykitPaymentRequest { + PaykitPaymentRequest( + paymentRequestId: paymentRequestId, + counterparty: counterparty, + counterpartyReceiverPath: counterpartyReceiverPath, + amountValue: amountValue, + amountSats: amountSats, + note: note, + createdAt: createdAt, + expiresAt: expiresAt, + acceptedPaymentEndpointIdentifiers: acceptedPaymentEndpointIdentifiers, + deliveryStatus: deliveryStatus, + direction: direction, + lifecycleState: state + ) + } + + private init( + paymentRequestId: String, + counterparty: String, + counterpartyReceiverPath: String, + amountValue: String, + amountSats: UInt64, + note: String?, + createdAt: Date?, + expiresAt: Date?, + acceptedPaymentEndpointIdentifiers: [String], + deliveryStatus: DeliveryStatus?, + direction: Direction, + lifecycleState: Paykit.PaymentRequestLifecycleState + ) { + 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.acceptedPaymentEndpointIdentifiers = acceptedPaymentEndpointIdentifiers + self.deliveryStatus = deliveryStatus + self.direction = direction + self.lifecycleState = lifecycleState } func isExpired(at date: Date) -> Bool { @@ -119,6 +234,43 @@ struct PaykitPaymentRequest: Identifiable, Equatable { formatter.formatOptions = [.withInternetDateTime] return formatter.date(from: timestamp) } + + private static func note(from metadata: Paykit.PrivateJsonObject) -> String? { + guard let data = metadata.exportText().data(using: .utf8), + let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let note = object["note"] as? String + else { return nil } + + let trimmedNote = note.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmedNote.isEmpty ? nil : trimmedNote + } + + private static func deliveryStatus(from status: Paykit.OutboundPrivateMessageStatus?) -> DeliveryStatus { + if case .sent? = status { + return .sent + } + return .queued + } +} + +struct PaykitPaymentRequestTarget: Identifiable, Equatable, Hashable { + let publicKey: String + let receiverPath: String + + var id: String { + "\(publicKey)|\(receiverPath)" + } +} + +struct PaykitPaymentRequestDraft: Hashable { + let amountSats: UInt64 + let note: String + let expiresAt: Date +} + +struct PaykitPaymentRequestSnapshot: Equatable { + let incoming: [PaykitPaymentRequest] + let history: [PaykitPaymentRequest] } enum PaykitPaymentRequestError: LocalizedError, Equatable { @@ -144,12 +296,27 @@ enum PaykitPaymentRequestError: LocalizedError, Equatable { protocol PaykitPaymentRequestSdkHandling: Sendable { func processPendingPrivateMessages() async throws -> [Paykit.OutboundPrivateCounterpartySendReport] func receivePrivateMessagesFromLinkedPeers() async throws -> [Paykit.PrivateStreamCounterpartyIntakeReport] - func actionableReceivedPaymentRequests() async throws -> [Paykit.PaymentRequestRecord] + func paymentRequests() async throws -> [Paykit.PaymentRequestRecord] + func identityStatus() async throws -> Paykit.IdentityStatus? + func linkedPeers() async throws -> [Paykit.LinkedPeerRecord] + func paymentRequestReceiverPaths(publicKey: String) async throws -> [String] + func proposePaymentRequest( + counterparty: String, + counterpartyReceiverPath: String, + terms: Paykit.PaymentRequestTerms, + expectedIdentity: String + ) async throws -> Paykit.PaymentRequestRecord func acceptPaymentRequest( counterparty: String, counterpartyReceiverPath: String, paymentRequestId: String ) async throws -> Paykit.PaymentRequestRecord + func rejectPaymentRequest( + counterparty: String, + counterpartyReceiverPath: String, + paymentRequestId: String, + reason: String? + ) async throws -> Paykit.PaymentRequestRecord } extension PaykitSdkService: PaykitPaymentRequestSdkHandling {} @@ -157,28 +324,124 @@ extension PaykitSdkService: PaykitPaymentRequestSdkHandling {} struct PaykitPaymentRequestService { private let sdk: any PaykitPaymentRequestSdkHandling private let now: @Sendable () -> Date + private let isPrivatePaymentPublishingEnabled: @Sendable () -> Bool private let logWarning: @Sendable (String) -> Void init( sdk: any PaykitPaymentRequestSdkHandling = PaykitSdkService.shared, now: @escaping @Sendable () -> Date = { Date() }, + isPrivatePaymentPublishingEnabled: @escaping @Sendable () -> Bool = { + UserDefaults.standard.bool(forKey: PrivatePaykitService.publishingEnabledKey) + }, logWarning: @escaping @Sendable (String) -> Void = { Logger.warn($0, context: "PaykitPaymentRequest") } ) { self.sdk = sdk self.now = now + self.isPrivatePaymentPublishingEnabled = isPrivatePaymentPublishingEnabled self.logWarning = logWarning } - func synchronize() async throws -> [PaykitPaymentRequest] { + func synchronize() async throws -> PaykitPaymentRequestSnapshot { try await processPendingMessages() let intakeReports = try await sdk.receivePrivateMessagesFromLinkedPeers() logIntakeFailures(intakeReports) let synchronizationDate = now() - return try await sdk.actionableReceivedPaymentRequests().compactMap { + let records = try await sdk.paymentRequests() + let incoming = records.compactMap { PaykitPaymentRequest(record: $0, now: synchronizationDate) } + let history = records.compactMap { + PaykitPaymentRequest(historyRecord: $0, now: synchronizationDate) + } + return PaykitPaymentRequestSnapshot(incoming: incoming, history: history) + } + + func eligibleTargets(savedPublicKeys: [String], expectedIdentity: String) async throws -> [PaykitPaymentRequestTarget] { + guard isPrivatePaymentPublishingEnabled(), !Self.acceptedPaymentEndpointIdentifiers().isEmpty else { return [] } + guard let identityStatus = try await sdk.identityStatus(), + identityStatus.liveSessionAvailable, + PubkyPublicKeyFormat.matches(identityStatus.publicKey, expectedIdentity) + else { return [] } + 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] = [] + for publicKey in savedKeys { + guard let linkedPaths = linkedPathsByPublicKey[publicKey] else { continue } + let capablePaths: [String] + do { + capablePaths = try await sdk.paymentRequestReceiverPaths(publicKey: publicKey) + } catch is CancellationError { + throw CancellationError() + } catch { + logWarning("Failed to inspect Paykit payment request support for \(PubkyPublicKeyFormat.redacted(publicKey)): \(error)") + continue + } + guard let receiverPath = PaykitReceiverPath.supported.first(where: { + linkedPaths.contains($0) && capablePaths.contains($0) + }) else { continue } + + targets.append(PaykitPaymentRequestTarget(publicKey: publicKey, receiverPath: receiverPath)) + } + return targets + } + + func propose( + _ draft: PaykitPaymentRequestDraft, + to target: PaykitPaymentRequestTarget, + savedPublicKeys: [String], + expectedIdentity: String + ) async throws -> PaykitPaymentRequest { + let acceptedPaymentEndpointIdentifiers = Self.acceptedPaymentEndpointIdentifiers() + let proposalDate = now() + guard draft.amountSats > 0, !acceptedPaymentEndpointIdentifiers.isEmpty else { + throw PaykitPaymentRequestError.requestUnavailable + } + guard draft.expiresAt > proposalDate else { + throw PaykitPaymentRequestError.requestExpired + } + guard try await eligibleTargets(savedPublicKeys: savedPublicKeys, expectedIdentity: expectedIdentity).contains(target) else { + throw PaykitPaymentRequestError.requestUnavailable + } + let metadataData = try JSONSerialization.data(withJSONObject: ["note": draft.note]) + let metadataText = String(decoding: metadataData, as: UTF8.self) + let terms = try Paykit.PaymentRequestTerms( + amount: Paykit.PaymentRequestAmount(value: WalletViewModel.formatBitcoinAmount(sats: draft.amountSats), asset: "btc"), + paymentReference: Paykit.PaymentReference(text: "bitkit-\(UUID().uuidString)"), + proposalExpiresAt: Self.timestamp(draft.expiresAt), + recurrence: nil, + acceptedPaymentEndpointIdentifiers: acceptedPaymentEndpointIdentifiers, + 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 + return PaykitPaymentRequest( + createdRecord: record, + draft: draft, + target: target, + acceptedPaymentEndpointIdentifiers: acceptedPaymentEndpointIdentifiers, + deliveryStatus: deliveryStatus, + createdAt: proposalDate + ) } func accept(_ request: PaykitPaymentRequest) async throws { @@ -191,22 +454,76 @@ struct PaykitPaymentRequestService { counterpartyReceiverPath: request.counterpartyReceiverPath, paymentRequestId: request.paymentRequestId ) - try? await processPendingMessages() + _ = try? await processPendingMessages() + } + + func reject(_ request: PaykitPaymentRequest) async throws { + guard !request.isExpired(at: now()) else { + throw PaykitPaymentRequestError.requestExpired + } + + _ = try await sdk.rejectPaymentRequest( + counterparty: request.counterparty, + counterpartyReceiverPath: request.counterpartyReceiverPath, + paymentRequestId: request.paymentRequestId, + reason: nil + ) + _ = try? await processPendingMessages() } - private func processPendingMessages() async throws { + private static func acceptedPaymentEndpointIdentifiers() -> [String] { + PublicPaykitService.MethodId.publishableMethodIds.compactMap { methodId in + if methodId == .bitcoinLightningBolt11 { + return PublicPaykitService.isLightningPaymentOptionEnabled() ? methodId.rawValue : nil + } + guard methodId.onchainNetwork == Env.network, + PublicPaykitService.isOnchainPaymentOptionEnabled() + else { return nil } + return methodId.rawValue + } + } + + private static func timestamp(_ date: Date) -> String { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return formatter.string(from: date) + } + + @discardableResult + private func processPendingMessages() async throws -> [Paykit.OutboundPrivateCounterpartySendReport] { do { let reports = try await sdk.processPendingPrivateMessages() for report in reports { - guard let error = report.error else { continue } - logWarning( - "Failed to deliver Paykit private messages to \(PubkyPublicKeyFormat.redacted(report.counterparty)): \(error.redactedContext())" - ) + if let error = report.error { + logWarning( + "Failed to deliver Paykit private messages to \(PubkyPublicKeyFormat.redacted(report.counterparty)): \(error.redactedContext())" + ) + } + for failure in report.report?.failed ?? [] { + logWarning( + "Failed to deliver Paykit private message \(failure.outboundMessageId) to " + + "\(PubkyPublicKeyFormat.redacted(report.counterparty)): \(failure.error.redactedContext())" + ) + } } + return reports } catch is CancellationError { throw CancellationError() } catch { logWarning("Failed to deliver pending Paykit private messages: \(error)") + return [] + } + } + + private func proposalWasSent( + _ record: Paykit.PaymentRequestRecord, + reports: [Paykit.OutboundPrivateCounterpartySendReport] + ) -> Bool { + guard let messageId = record.proposalOutboundMessageId else { return false } + return reports.contains { report in + PubkyPublicKeyFormat.matches(report.counterparty, record.counterparty) && + report.counterpartyReceiverPath == record.counterpartyReceiverPath && + report.report?.sent.contains(messageId) == true } } @@ -220,39 +537,187 @@ struct PaykitPaymentRequestService { } } +protocol PaykitPaymentRequestPresentationStoring { + func load(identity: String) throws -> Set + func save(_ ids: Set, identity: String) throws +} + +struct PaykitPaymentRequestPresentationStore: PaykitPaymentRequestPresentationStoring { + 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 [] } + let state = try JSONDecoder().decode(State.self, from: data) + guard let normalizedIdentity = PubkyPublicKeyFormat.normalized(identity) else { return [] } + return Set(state.idsByIdentity[normalizedIdentity] ?? []) + } + + 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) { + try JSONDecoder().decode(State.self, from: data) + } else { + State(idsByIdentity: [:]) + } + state.idsByIdentity[normalizedIdentity] = Array(ids) + try Keychain.upsert(key: .paykitPresentedPaymentRequests, data: JSONEncoder().encode(state)) + } +} + @Observable @MainActor final class PaykitPaymentRequestManager { - private static let presentationRetryDelays: [TimeInterval] = [30, 60, 120, 300] + private static let presentationRetryDelays = Array(repeating: TimeInterval(2), count: 14) private(set) var pendingRequests: [PaykitPaymentRequest] = [] + private(set) var historyRequests: [PaykitPaymentRequest] = [] + private(set) var eligibleTargets: [PaykitPaymentRequestTarget] = [] + private(set) var requestedPresentationId: PaykitPaymentRequest.ID? + private(set) var isCreatingRequest = false + private(set) var presentationRetryTrigger = 0 private let service: PaykitPaymentRequestService + private let presentationStore: any PaykitPaymentRequestPresentationStoring private let now: @Sendable () -> Date private let logWarning: @Sendable (String) -> Void + private let isAvailable: @MainActor () -> Bool private var processingRequestIds: Set = [] + private var approvedPaymentRequestIds: Set = [] private var presentedRequestIds: Set = [] private var presentationRetryAttempts: [PaykitPaymentRequest.ID: Int] = [:] private var presentationRetryDates: [PaykitPaymentRequest.ID: Date] = [:] private var isPresentingRequests = false private var refreshTask: Task? private var expirationTask: Task? + private var presentationRetryTask: Task? private var refreshGeneration = 0 + private var eligibilityGeneration = 0 private var stateGeneration = 0 + private var presentationGeneration = 0 + private var activePresentationGeneration: Int? + private var activeIdentity: String? + private var savedPublicKeys: [String] = [] + private var persistedPresentedRequestIds: Set = [] + + var outgoingRequests: [PaykitPaymentRequest] { + historyRequests.filter { $0.direction == .outgoing } + } init( service: PaykitPaymentRequestService? = nil, + presentationStore: any PaykitPaymentRequestPresentationStoring = PaykitPaymentRequestPresentationStore(), now: @escaping @Sendable () -> Date = { Date() }, + isAvailable: @escaping @MainActor () -> Bool = { PaykitFeatureFlags.isUIEnabled }, logWarning: @escaping @Sendable (String) -> Void = { Logger.warn($0, context: "PaykitPaymentRequest") } ) { self.service = service ?? PaykitPaymentRequestService(now: now, logWarning: logWarning) + self.presentationStore = presentationStore self.now = now + self.isAvailable = isAvailable self.logWarning = logWarning } + func activate(identity: String) { + guard let normalizedIdentity = PubkyPublicKeyFormat.normalized(identity) else { return } + guard !PubkyPublicKeyFormat.matches(activeIdentity, normalizedIdentity) else { return } + if activeIdentity != nil { + clear() + } + activeIdentity = normalizedIdentity + do { + presentedRequestIds = try presentationStore.load(identity: normalizedIdentity) + persistedPresentedRequestIds = presentedRequestIds + } catch { + presentedRequestIds = [] + persistedPresentedRequestIds = [] + logWarning("Failed to restore surfaced Paykit payment requests: \(error)") + } + } + + func refreshEligibleTargets(savedPublicKeys: [String]) async { + eligibilityGeneration += 1 + let generation = eligibilityGeneration + let currentStateGeneration = stateGeneration + self.savedPublicKeys = savedPublicKeys + guard isAvailable(), let activeIdentity else { + eligibleTargets = [] + return + } + + do { + let targets = try await service.eligibleTargets(savedPublicKeys: savedPublicKeys, expectedIdentity: activeIdentity) + guard generation == eligibilityGeneration, + currentStateGeneration == stateGeneration, + savedPublicKeys == self.savedPublicKeys, + isAvailable(), + PubkyPublicKeyFormat.matches(self.activeIdentity, activeIdentity) + else { return } + eligibleTargets = targets + } catch is CancellationError { + return + } catch { + guard generation == eligibilityGeneration, + currentStateGeneration == stateGeneration + else { return } + eligibleTargets = [] + logWarning("Failed to refresh Paykit payment request recipients: \(error)") + } + } + + func clearEligibleTargets() { + eligibilityGeneration += 1 + savedPublicKeys = [] + eligibleTargets = [] + } + + func propose(_ draft: PaykitPaymentRequestDraft, to target: PaykitPaymentRequestTarget) async throws -> PaykitPaymentRequest { + guard draft.amountSats > 0, + isAvailable(), + let activeIdentity, + eligibleTargets.contains(target) + else { + throw PaykitPaymentRequestError.requestUnavailable + } + guard !isCreatingRequest else { + throw PaykitPaymentRequestError.operationInProgress + } + + let actionGeneration = stateGeneration + let savedPublicKeysSnapshot = savedPublicKeys + isCreatingRequest = true + defer { + if actionGeneration == stateGeneration { + isCreatingRequest = false + } + } + let request = try await service.propose( + draft, + to: target, + savedPublicKeys: savedPublicKeysSnapshot, + expectedIdentity: activeIdentity + ) + if actionGeneration == stateGeneration, + isAvailable(), + PubkyPublicKeyFormat.matches(self.activeIdentity, activeIdentity), + savedPublicKeysSnapshot == savedPublicKeys + { + invalidateRefresh() + historyRequests.removeAll { $0.id == request.id } + historyRequests.insert(request, at: 0) + discardExpiredRequests() + } + return request + } + func refresh() async { + await refresh(excludingProtectedRequestId: nil) + } + + private func refresh(excludingProtectedRequestId: PaykitPaymentRequest.ID?) async { if let refreshTask { await refreshTask.value return @@ -262,7 +727,7 @@ final class PaykitPaymentRequestManager { let generation = refreshGeneration let task = Task { [weak self] in guard let self else { return } - await performRefresh(generation: generation) + await performRefresh(generation: generation, excludingProtectedRequestId: excludingProtectedRequestId) } refreshTask = task await task.value @@ -276,7 +741,7 @@ final class PaykitPaymentRequestManager { consumePrivatePaymentList: () async throws -> Void = {} ) async throws { do { - try await perform(request) { + try await perform(request, resultingState: .accepted, markApprovedForPayment: true) { try await consumePrivatePaymentList() try await service.accept($0) } @@ -288,73 +753,175 @@ final class PaykitPaymentRequestManager { } } + func reject(_ request: PaykitPaymentRequest) async throws { + guard requestedPresentationId != request.id else { + throw PaykitPaymentRequestError.operationInProgress + } + try await perform(request, resultingState: .rejected) { + try await service.reject($0) + } + } + + @discardableResult + func requestPresentation(_ request: PaykitPaymentRequest) -> Bool { + discardExpiredRequests() + guard pendingRequests.contains(where: { $0.id == request.id }), + !processingRequestIds.contains(request.id), + requestedPresentationId == nil + else { return false } + presentationGeneration += 1 + presentationRetryAttempts.removeValue(forKey: request.id) + presentationRetryDates.removeValue(forKey: request.id) + requestedPresentationId = request.id + schedulePresentationRetry() + return true + } + func clear() { stateGeneration += 1 + presentationGeneration += 1 invalidateRefresh() + eligibilityGeneration += 1 expirationTask?.cancel() expirationTask = nil + presentationRetryTask?.cancel() + presentationRetryTask = nil pendingRequests = [] + historyRequests = [] + eligibleTargets = [] processingRequestIds = [] + approvedPaymentRequestIds = [] + activeIdentity = nil + savedPublicKeys = [] presentedRequestIds = [] + persistedPresentedRequestIds = [] presentationRetryAttempts = [:] presentationRetryDates = [:] - isPresentingRequests = false + requestedPresentationId = nil + isCreatingRequest = false } func requestsForPresentation() -> [PaykitPaymentRequest] { let date = now() + if let requestedPresentationId { + guard !processingRequestIds.contains(requestedPresentationId), + let requestedRequest = pendingRequests.first(where: { $0.id == requestedPresentationId }), + presentationRetryAttempts[requestedPresentationId, default: 0] <= Self.presentationRetryDelays.count, + presentationRetryDates[requestedPresentationId].map({ $0 <= date }) ?? true + else { return [] } + return [requestedRequest] + } + return pendingRequests.filter { !presentedRequestIds.contains($0.id) && + !processingRequestIds.contains($0.id) && presentationRetryAttempts[$0.id, default: 0] <= Self.presentationRetryDelays.count && (presentationRetryDates[$0.id].map { $0 <= date } ?? true) } } - func presentRequests(_ operation: ([PaykitPaymentRequest]) async -> Void) async { - guard !isPresentingRequests else { return } + @discardableResult + func presentRequests(_ operation: ([PaykitPaymentRequest]) async -> Void) async -> Bool { + guard !isPresentingRequests else { return false } let requests = requestsForPresentation() - guard !requests.isEmpty else { return } + guard !requests.isEmpty else { return false } isPresentingRequests = true - defer { isPresentingRequests = false } + activePresentationGeneration = presentationGeneration + defer { + isPresentingRequests = false + activePresentationGeneration = nil + } await operation(requests) + return true + } + + func isCurrentPresentation(_ request: PaykitPaymentRequest) -> Bool { + guard isPresentingRequests, + activePresentationGeneration == presentationGeneration, + pendingRequests.contains(where: { $0.id == request.id }), + !processingRequestIds.contains(request.id) + else { return false } + + return requestedPresentationId.map { $0 == request.id } ?? true + } + + func isApprovedForPayment(_ request: PaykitPaymentRequest) -> Bool { + approvedPaymentRequestIds.contains(request.id) + } + + func finishPayment(_ request: PaykitPaymentRequest) { + approvedPaymentRequestIds.remove(request.id) } func deferPresentation(_ request: PaykitPaymentRequest) { discardExpiredRequests() guard pendingRequests.contains(where: { $0.id == request.id }) else { return } - presentedRequestIds.remove(request.id) + let isRequestedPresentation = requestedPresentationId == request.id + presentationGeneration += 1 + if !isRequestedPresentation { + presentedRequestIds.remove(request.id) + } let attempt = presentationRetryAttempts[request.id, default: 0] presentationRetryAttempts[request.id] = attempt + 1 guard attempt < Self.presentationRetryDelays.count else { presentationRetryDates.removeValue(forKey: request.id) + if isRequestedPresentation { + requestedPresentationId = nil + } logWarning("Stopped retrying incoming Paykit payment request after \(attempt + 1) presentation attempts") + schedulePresentationRetry() return } let delay = Self.presentationRetryDelays[attempt] presentationRetryDates[request.id] = now().addingTimeInterval(delay) + schedulePresentationRetry() } func markPresentedIfPending(_ request: PaykitPaymentRequest) -> Bool { discardExpiredRequests() guard pendingRequests.contains(where: { $0.id == request.id }) else { return false } presentedRequestIds.insert(request.id) + if requestedPresentationId == request.id { + presentationGeneration += 1 + requestedPresentationId = nil + } presentationRetryAttempts.removeValue(forKey: request.id) presentationRetryDates.removeValue(forKey: request.id) + schedulePresentationRetry() + persistPresentedRequestIds() return true } - private func performRefresh(generation: Int) async { + private func performRefresh( + generation: Int, + excludingProtectedRequestId: PaykitPaymentRequest.ID? + ) async { do { - let requests = try await service.synchronize() + let snapshot = try await service.synchronize() guard generation == refreshGeneration else { return } - pendingRequests = requests - let requestIds = Set(requests.map(\.id)) + let protectedRequests = pendingRequests.filter { + processingRequestIds.contains($0.id) && $0.id != excludingProtectedRequestId + } + pendingRequests = snapshot.incoming + for request in protectedRequests where !pendingRequests.contains(where: { $0.id == request.id }) { + pendingRequests.append(request) + } + historyRequests = snapshot.history.sorted { + ($0.createdAt ?? .distantPast) > ($1.createdAt ?? .distantPast) + } + let requestIds = Set(pendingRequests.map(\.id)) presentedRequestIds.formIntersection(requestIds) presentationRetryAttempts = presentationRetryAttempts.filter { requestIds.contains($0.key) } presentationRetryDates = presentationRetryDates.filter { requestIds.contains($0.key) } + if requestedPresentationId.map({ !requestIds.contains($0) }) == true { + presentationGeneration += 1 + requestedPresentationId = nil + } + persistPresentedRequestIds() discardExpiredRequests() + schedulePresentationRetry() } catch is CancellationError { return } catch { @@ -366,6 +933,8 @@ final class PaykitPaymentRequestManager { private func perform( _ request: PaykitPaymentRequest, + resultingState: Paykit.PaymentRequestLifecycleState, + markApprovedForPayment: Bool = false, operation: (PaykitPaymentRequest) async throws -> Void ) async throws { guard !request.isExpired(at: now()) else { @@ -389,16 +958,29 @@ final class PaykitPaymentRequestManager { try await operation(request) guard actionGeneration == stateGeneration else { return } invalidateRefresh() + if markApprovedForPayment { + approvedPaymentRequestIds.insert(request.id) + } + let updatedRequest = request.updatingLifecycleState(resultingState) + historyRequests.removeAll { $0.id == request.id } + historyRequests.insert(updatedRequest, at: 0) pendingRequests.removeAll { $0.id == request.id } + presentedRequestIds.remove(request.id) presentationRetryAttempts.removeValue(forKey: request.id) presentationRetryDates.removeValue(forKey: request.id) + schedulePresentationRetry() + if requestedPresentationId == request.id { + presentationGeneration += 1 + requestedPresentationId = nil + } + persistPresentedRequestIds() discardExpiredRequests() } catch is CancellationError { throw CancellationError() } catch { guard actionGeneration == stateGeneration else { throw error } invalidateRefresh() - await refresh() + await refresh(excludingProtectedRequestId: request.id) throw error } } @@ -415,7 +997,31 @@ final class PaykitPaymentRequestManager { presentedRequestIds.formIntersection(requestIds) presentationRetryAttempts = presentationRetryAttempts.filter { requestIds.contains($0.key) } presentationRetryDates = presentationRetryDates.filter { requestIds.contains($0.key) } + if requestedPresentationId.map({ !requestIds.contains($0) }) == true { + presentationGeneration += 1 + requestedPresentationId = nil + } + persistPresentedRequestIds() scheduleExpiration() + schedulePresentationRetry() + } + + private func schedulePresentationRetry() { + presentationRetryTask?.cancel() + presentationRetryTask = nil + + guard let nextRetry = presentationRetryDates.values.min() else { return } + let delay = max(0, nextRetry.timeIntervalSince(now())) + presentationRetryTask = Task { [weak self] in + do { + try await Task.sleep(for: .seconds(delay)) + } catch { + return + } + guard !Task.isCancelled else { return } + self?.presentationRetryTask = nil + self?.presentationRetryTrigger += 1 + } } private func scheduleExpiration() { @@ -434,4 +1040,14 @@ final class PaykitPaymentRequestManager { self?.discardExpiredRequests() } } + + private func persistPresentedRequestIds() { + guard let activeIdentity, presentedRequestIds != persistedPresentedRequestIds else { return } + do { + try presentationStore.save(presentedRequestIds, identity: activeIdentity) + persistedPresentedRequestIds = presentedRequestIds + } catch { + logWarning("Failed to persist surfaced Paykit payment requests: \(error)") + } + } } diff --git a/Bitkit/Services/PrivatePaykitService+Payments.swift b/Bitkit/Services/PrivatePaykitService+Payments.swift index e757a0b85..fb76259fd 100644 --- a/Bitkit/Services/PrivatePaykitService+Payments.swift +++ b/Bitkit/Services/PrivatePaykitService+Payments.swift @@ -57,6 +57,7 @@ extension PrivatePaykitService { paymentRequest: PaykitPaymentRequest? = nil ) async throws -> PublicPaykitPaymentLaunchResult { let consumedVersion = state.contacts[publicKey]?.consumedPrivatePaymentListVersionsByReceiverPath[receiverPath] + let previousPaymentListVersion = consumedVersion.map(String.init) ?? "none" let amount = paymentRequest.map { PaymentAmountContext(value: $0.amountValue, asset: "btc") } @@ -89,6 +90,10 @@ extension PrivatePaykitService { 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( @@ -103,6 +108,10 @@ extension PrivatePaykitService { } if resolution.status == .waitingForUpdatedPaymentList { + Logger.info( + "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) return .waitingForUpdatedPaymentList } @@ -142,6 +151,10 @@ extension PrivatePaykitService { contactState.cachedResolvedEndpoints.removeAll() state.contacts[publicKey] = contactState try persistStateOrThrow(markWalletBackup: true) + Logger.info( + "Consumed private Paykit payment list version \(context.paymentListVersion) for \(PubkyPublicKeyFormat.redacted(publicKey))", + context: "PrivatePaykit" + ) } private func currentLinkState( diff --git a/Bitkit/Services/PubkyService.swift b/Bitkit/Services/PubkyService.swift index 97155d80f..0e79b62e1 100644 --- a/Bitkit/Services/PubkyService.swift +++ b/Bitkit/Services/PubkyService.swift @@ -304,8 +304,24 @@ actor PaykitSdkService { func initialize() async throws { try await operationLock.withLock { - let sdk = try handle() - _ = try await sdk.initialize() + var sdk = try handle() + do { + _ = try await sdk.initialize() + } catch { + guard try sessionProvider.canDeferStaleSession(error: error) else { throw error } + + Logger.warn("Deferring stale Paykit session restoration until SDK setup completes", context: "PaykitSdkService") + sessionProvider.suspendStoredSessionAccess() + resetRuntime() + do { + sdk = try handle() + _ = try await sdk.initialize() + } catch { + sessionProvider.resumeStoredSessionAccess() + throw error + } + sessionProvider.resumeStoredSessionAccess() + } await publishReceiverMarkerIfLiveSessionAvailable(using: sdk) } } @@ -548,6 +564,24 @@ actor PaykitSdkService { } } + func paymentRequestReceiverPaths(publicKey: String) async throws -> [String] { + try await operationLock.withLock { + let sdk = try handle() + let paths = try await sdk.paykitReceiverPaths(publicKey: publicKey) + var capablePaths = Set() + + for path in paths where PaykitReceiverPath.supported.contains(path) { + guard let marker = try await sdk.paykitReceiverMarker(publicKey: publicKey, receiverPath: path), + marker.capabilities.paymentRequests == true + else { continue } + + capablePaths.insert(path) + } + + return PaykitReceiverPath.supported.filter { capablePaths.contains($0) } + } + } + func privateReceiverPathSelection(publicKey: String, savedReceiverPaths: [String]) async throws -> PrivateReceiverPathSelection { try await operationLock.withLock { let paths = Self.mergedReceiverPaths(savedReceiverPaths) @@ -652,9 +686,30 @@ actor PaykitSdkService { } } - func actionableReceivedPaymentRequests() async throws -> [Paykit.PaymentRequestRecord] { + func paymentRequests() async throws -> [Paykit.PaymentRequestRecord] { try await operationLock.withLock { - try await handle().actionableReceivedPaymentRequests() + try await handle().paymentRequests() + } + } + + func proposePaymentRequest( + counterparty: String, + counterpartyReceiverPath: String, + terms: Paykit.PaymentRequestTerms, + expectedIdentity: String + ) async throws -> Paykit.PaymentRequestRecord { + try await withStateRevisionTracking { sdk in + guard let identityStatus = try await sdk.identityStatus(), + identityStatus.liveSessionAvailable, + PubkyPublicKeyFormat.matches(identityStatus.publicKey, expectedIdentity) + else { + throw PaykitPaymentRequestError.requestUnavailable + } + return try await sdk.proposePaymentRequest( + counterparty: counterparty, + counterpartyReceiverPath: counterpartyReceiverPath, + terms: terms + ) } } @@ -672,6 +727,22 @@ actor PaykitSdkService { } } + 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 + ) + } + } + func linkedPeers() async throws -> [LinkedPeerRecord] { try await operationLock.withLock { try await handle().linkedPeers() @@ -922,6 +993,16 @@ actor PaykitSdkService { 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 + else { + return false + } + + return context == "import Pubky session from platform provider" + } + private nonisolated static func canReceivePrivatePaymentDetails(marker: Paykit.PaykitReceiverMarker?) -> Bool { marker?.capabilities.privatePayments == true && marker?.capabilities.outgoingPayments == true } @@ -1035,6 +1116,7 @@ private final class PaykitSdkSessionProvider: SdkPubkySessionProvider, @unchecke private let lock = NSLock() private let receiverNoiseKeyStore = PaykitReceiverNoiseKeyStore() private var liveSessionAccess: PubkySessionAccess? + private var isStoredSessionAccessSuspended = false func setLiveSessionAccess(_ access: PubkySessionAccess) { lock.lock() @@ -1049,13 +1131,18 @@ private final class PaykitSdkSessionProvider: SdkPubkySessionProvider, @unchecke } func loadSessionAccess() throws -> PubkySessionAccess? { + lock.lock() + defer { lock.unlock() } + + guard !isStoredSessionAccessSuspended else { + return nil + } + guard let sessionSecret = try Keychain.loadString(key: .paykitSession), !sessionSecret.isEmpty else { return nil } - lock.lock() let liveAccess = liveSessionAccess - lock.unlock() if liveAccess?.exportSessionSecret() == sessionSecret { return liveAccess @@ -1072,6 +1159,24 @@ private final class PaykitSdkSessionProvider: SdkPubkySessionProvider, @unchecke true } + func canDeferStaleSession(error: Error) throws -> Bool { + let hasStoredSession = try Keychain.loadString(key: .paykitSession)?.isEmpty == false + return PaykitSdkService.shouldDeferStaleSession(error: error, hasStoredSession: hasStoredSession) + } + + func suspendStoredSessionAccess() { + lock.lock() + liveSessionAccess = nil + isStoredSessionAccessSuspended = true + lock.unlock() + } + + func resumeStoredSessionAccess() { + lock.lock() + isStoredSessionAccessSuspended = false + lock.unlock() + } + func clearSessionAccess() throws { clearLiveSessionAccess() try? Keychain.delete(key: .paykitSession) diff --git a/Bitkit/Utilities/Keychain.swift b/Bitkit/Utilities/Keychain.swift index 49e45f723..19ee2a9d0 100644 --- a/Bitkit/Utilities/Keychain.swift +++ b/Bitkit/Utilities/Keychain.swift @@ -7,6 +7,7 @@ enum KeychainEntryType { case pushNotificationPrivateKey // For secp256k1 shared secret when decrypting push payload case securityPin case paykitSession + case paykitPresentedPaymentRequests case paykitReceiverNoiseSecretKey case paykitSdkState case pubkySecretKey @@ -18,6 +19,7 @@ enum KeychainEntryType { case .pushNotificationPrivateKey: "push_notification_private_key" case .securityPin: "security_pin" case .paykitSession: "paykit_session" + case .paykitPresentedPaymentRequests: "paykit_presented_payment_requests" case .paykitReceiverNoiseSecretKey: "paykit_receiver_noise_secret_key" case .paykitSdkState: "paykit_sdk_state" case .pubkySecretKey: "pubky_secret_key" diff --git a/Bitkit/ViewModels/NavigationViewModel.swift b/Bitkit/ViewModels/NavigationViewModel.swift index d63b349f9..ba841f693 100644 --- a/Bitkit/ViewModels/NavigationViewModel.swift +++ b/Bitkit/ViewModels/NavigationViewModel.swift @@ -32,6 +32,7 @@ enum Route: Hashable { case createProfile case editProfile case payContacts + case paymentRequests case transferIntro case fundingOptions case spendingIntro diff --git a/Bitkit/ViewModels/SheetViewModel.swift b/Bitkit/ViewModels/SheetViewModel.swift index 7ddfbe0be..daf8581ec 100644 --- a/Bitkit/ViewModels/SheetViewModel.swift +++ b/Bitkit/ViewModels/SheetViewModel.swift @@ -14,6 +14,7 @@ enum SheetID: String, CaseIterable { case lnurlWithdraw case pubkyAuthApproval case notifications + case paymentRequests case quickpay case receive case receivedTx @@ -37,15 +38,18 @@ struct SheetConfiguration { class SheetViewModel: ObservableObject { @Published var activeSheetConfiguration: SheetConfiguration? = nil @Published var hardwareConnectHandlesPairing = false + @Published private(set) var isReplacingSheet = false func showSheet(_ id: SheetID, data: Any? = nil) { if isAnySheetOpen { // If any other sheet is open, close it and delay before showing the new sheet // to prevent the new sheet from closing immediately (bug) + isReplacingSheet = true hideSheet() DispatchQueue.main.asyncAfter(deadline: .now() + 0.7) { [weak self] in guard let self else { return } + isReplacingSheet = false Logger.debug("Showing sheet \(id.rawValue) after delay", context: "SheetViewModel") activeSheetConfiguration = SheetConfiguration(id: id, data: data) playHaptics(for: id) @@ -56,6 +60,7 @@ class SheetViewModel: ObservableObject { } } } else { + isReplacingSheet = false // If no sheet is open, show the new sheet immediately Logger.debug("Showing sheet \(id.rawValue)", context: "SheetViewModel") activeSheetConfiguration = SheetConfiguration(id: id, data: data) @@ -86,6 +91,22 @@ class SheetViewModel: ObservableObject { } } + func hideSheetBeforePerforming(reason: String, action: @escaping () -> Void) { + guard isAnySheetOpen else { + action() + return + } + + isReplacingSheet = true + hideSheet(reason: reason) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.7) { [weak self] in + guard let self else { return } + isReplacingSheet = false + guard activeSheetConfiguration == nil else { return } + action() + } + } + func hideSheetIfActive(_ id: SheetID, reason: String? = nil, file: String = #file, function: String = #function, line: Int = #line) { guard activeSheetConfiguration?.id == id else { let fallback = "\(URL(fileURLWithPath: file).lastPathComponent):\(line) \(function)" @@ -264,6 +285,18 @@ class SheetViewModel: ObservableObject { } } + var paymentRequestsSheetItem: PaymentRequestsSheetItem? { + get { + guard let config = activeSheetConfiguration, config.id == .paymentRequests else { return nil } + return PaymentRequestsSheetItem() + } + set { + if newValue == nil { + activeSheetConfiguration = nil + } + } + } + var quickpaySheetItem: QuickpaySheetItem? { get { guard let config = activeSheetConfiguration, config.id == .quickpay else { return nil } diff --git a/Bitkit/ViewModels/WalletViewModel.swift b/Bitkit/ViewModels/WalletViewModel.swift index 60ddecdab..81be0e66b 100644 --- a/Bitkit/ViewModels/WalletViewModel.swift +++ b/Bitkit/ViewModels/WalletViewModel.swift @@ -1400,7 +1400,7 @@ class WalletViewModel: ObservableObject { /// Formats satoshi amount to Bitcoin decimal format for BIP21 URIs /// - Parameter sats: Amount in satoshis /// - Returns: Formatted Bitcoin amount as string (e.g., "0.00123000") - static func formatBitcoinAmount(sats: UInt64) -> String { + nonisolated static func formatBitcoinAmount(sats: UInt64) -> String { let btcAmount = Double(sats) / 100_000_000.0 let formatter = NumberFormatter() formatter.numberStyle = .decimal diff --git a/Bitkit/Views/PaymentRequests/CreatePaymentRequestView.swift b/Bitkit/Views/PaymentRequests/CreatePaymentRequestView.swift new file mode 100644 index 000000000..8c00d6771 --- /dev/null +++ b/Bitkit/Views/PaymentRequests/CreatePaymentRequestView.swift @@ -0,0 +1,368 @@ +import SwiftUI + +enum PaymentRequestExpiration: String, CaseIterable, CustomStringConvertible, Identifiable { + case hour + case day + case week + case month + + var id: String { + rawValue + } + + var description: String { + switch self { + case .hour: t("wallet__payment_request_expiry_hour") + case .day: t("wallet__payment_request_expiry_day") + case .week: t("wallet__payment_request_expiry_week") + case .month: t("wallet__payment_request_expiry_month") + } + } + + func date(from date: Date) -> Date { + switch self { + case .hour: date.addingTimeInterval(60 * 60) + case .day: date.addingTimeInterval(24 * 60 * 60) + case .week: date.addingTimeInterval(7 * 24 * 60 * 60) + case .month: Calendar.current.date(byAdding: .month, value: 1, to: date) ?? date.addingTimeInterval(30 * 24 * 60 * 60) + } + } + + static func closest(to expiration: Date, from date: Date) -> PaymentRequestExpiration { + allCases.min { + abs($0.date(from: date).timeIntervalSince(expiration)) < abs($1.date(from: date).timeIntervalSince(expiration)) + } ?? .week + } +} + +struct PaymentRequestDetailsView: View { + @EnvironmentObject private var currency: CurrencyViewModel + + let initialDraft: PaykitPaymentRequestDraft + let onContinue: (PaykitPaymentRequestDraft) -> Void + + @State private var amountViewModel = AmountInputViewModel() + @State private var note = "" + @State private var expiration = PaymentRequestExpiration.week + @State private var isAmountInputFocused = false + @FocusState private var isNoteFocused: Bool + + var body: some View { + VStack(spacing: 0) { + SheetHeader(title: t("wallet__payment_request"), showBackButton: true) + + VStack(alignment: .leading, spacing: 0) { + CaptionMText(t("wallet__payment_request_amount"), textColor: .white64) + .padding(.bottom, 8) + + NumberPadTextField( + viewModel: amountViewModel, + showEditButton: !isAmountInputFocused, + isFocused: isAmountInputFocused, + testIdentifier: "PaymentRequestAmountField" + ) + .onTapGesture { + if isAmountInputFocused { + amountViewModel.togglePrimaryDisplay(currency: currency) + } else { + isAmountInputFocused = true + } + } + + if !isAmountInputFocused { + CaptionMText(t("wallet__note"), textColor: .white64) + .padding(.top, 24) + .padding(.bottom, 8) + + NoteTextEditor( + text: $note, + placeholder: t("wallet__receive_note_placeholder"), + testIdentifier: "PaymentRequestNote", + isFocused: $isNoteFocused + ) + + CaptionMText(t("wallet__payment_request_expires"), textColor: .white64) + .padding(.top, 24) + .padding(.bottom, 12) + + expirationPicker + + Spacer() + + CustomButton( + title: t("wallet__payment_request_choose_recipient"), + isDisabled: amountViewModel.amountSats == 0 + ) { + let draft = PaykitPaymentRequestDraft( + amountSats: amountViewModel.amountSats, + note: note.trimmingCharacters(in: .whitespacesAndNewlines), + expiresAt: expiration.date(from: Date()) + ) + onContinue(draft) + } + .buttonBottomPadding(isFocused: isNoteFocused) + .accessibilityIdentifier("PaymentRequestAmountContinue") + } + } + + if isAmountInputFocused { + Spacer() + + NumberPad( + type: amountViewModel.getNumberPadType(currency: currency), + errorKey: amountViewModel.errorKey + ) { key in + amountViewModel.handleNumberPadInput(key, currency: currency) + } + + CustomButton(title: t("common__continue"), isDisabled: amountViewModel.amountSats == 0) { + isAmountInputFocused = false + } + } + } + .padding(.horizontal, 16) + .sheetBackground() + .navigationBarHidden(true) + .task { + amountViewModel.updateFromSats(initialDraft.amountSats, currency: currency) + note = initialDraft.note + if initialDraft.expiresAt > Date() { + expiration = .closest(to: initialDraft.expiresAt, from: Date()) + } + } + .onChange(of: note) { _, value in + if value.count > 256 { + note = String(value.prefix(256)) + } + } + } + + private var expirationPicker: some View { + SegmentedControl( + selectedTab: $expiration, + tabs: PaymentRequestExpiration.allCases, + activeColor: .textPrimary + ) + } +} + +struct PaymentRequestRecipientView: View { + @EnvironmentObject private var app: AppViewModel + @EnvironmentObject private var contactsManager: ContactsManager + @Environment(PaykitPaymentRequestManager.self) private var paymentRequests + + let draft: PaykitPaymentRequestDraft + let onEditExpiration: () -> Void + let onSent: (PaykitPaymentRequest) -> Void + + @State private var selectedTarget: PaykitPaymentRequestTarget? + @State private var recipientQuery = "" + + private var recipientTargets: [PaykitPaymentRequestTarget] { + paymentRequests.eligibleTargets + .filter { target in + let query = recipientQuery.trimmingCharacters(in: .whitespacesAndNewlines) + return query.isEmpty + || target.publicKey.localizedCaseInsensitiveContains(query) + || displayName(for: target).localizedCaseInsensitiveContains(query) + } + .sorted { + displayName(for: $0).localizedCaseInsensitiveCompare(displayName(for: $1)) == .orderedAscending + } + } + + private var allRecipientTargets: [PaykitPaymentRequestTarget] { + paymentRequests.eligibleTargets + } + + private var isSelectionAvailable: Bool { + guard let selectedTarget else { return false } + return allRecipientTargets.contains(selectedTarget) + } + + private var canSend: Bool { + isSelectionAvailable && !paymentRequests.isCreatingRequest + } + + private var recipientSection: some View { + VStack(alignment: .leading, spacing: 0) { + CaptionMText(t("wallet__payment_request_recipient").localizedUppercase, textColor: .white64) + .padding(.bottom, 8) + + HStack(spacing: 8) { + TextField( + t("wallet__payment_request_enter_pubky"), + text: $recipientQuery, + backgroundColor: .clear, + font: .custom(Fonts.regular, size: 17), + testIdentifier: "PaymentRequestRecipientFilter" + ) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .keyboardType(.asciiCapable) + + Button { + if let clipboard = UIPasteboard.general.string { + recipientQuery = PubkyPublicKeyFormat.bounded(clipboard) + } + } label: { + HStack(spacing: 8) { + Image("clipboard") + .resizable() + .scaledToFit() + .frame(width: 24, height: 24) + .accessibilityHidden(true) + + BodyMSBText(t("common__paste")) + } + .foregroundColor(.textPrimary) + } + .buttonStyle(.plain) + .padding(.trailing, 16) + .accessibilityIdentifier("PaymentRequestRecipientPaste") + } + .background(Color.white08) + .cornerRadius(8) + + CaptionMText(t("contacts__nav_title").localizedUppercase, textColor: .white64) + .padding(.top, 32) + .padding(.bottom, 16) + + CustomDivider() + } + } + + var body: some View { + VStack(spacing: 0) { + SheetHeader( + title: t("wallet__payment_request_choose_recipient"), + action: AnyView( + Button(action: onEditExpiration) { + Image("timer") + .resizable() + .scaledToFit() + .foregroundColor(.textPrimary) + .frame(width: 24, height: 24) + } + .buttonStyle(.plain) + .disabled(paymentRequests.isCreatingRequest) + .accessibilityLabel(t("wallet__payment_request_expires")) + .accessibilityIdentifier("PaymentRequestEditExpiration") + ) + ) + + ScrollView(showsIndicators: false) { + LazyVStack(alignment: .leading, spacing: 0) { + recipientSection + + ForEach(recipientTargets) { target in + if let contact = contact(for: target) { + PubkyContactRow( + contact: contact, + verticalPadding: 20, + isLoading: paymentRequests.isCreatingRequest && selectedTarget == target, + isSelected: selectedTarget == target, + selectionColor: .brandAccent + ) { + selectedTarget = target + } + .accessibilityIdentifier("PaymentRequestContact-\(contact.publicKey)") + .disabled(paymentRequests.isCreatingRequest) + } + } + } + } + + CustomButton( + title: t("wallet__payment_request_send_request"), + isDisabled: !canSend, + isLoading: paymentRequests.isCreatingRequest + ) { + await sendRequest() + } + .accessibilityIdentifier("PaymentRequestSend") + } + .padding(.horizontal, 16) + .sheetBackground() + .navigationBarHidden(true) + .interactiveDismissDisabled(paymentRequests.isCreatingRequest) + .onChange(of: allRecipientTargets) { _, targets in + if let selectedTarget, !targets.contains(selectedTarget) { + self.selectedTarget = nil + } + } + } + + private func sendRequest() async { + guard let selectedTarget, allRecipientTargets.contains(selectedTarget) else { return } + do { + let request = try await paymentRequests.propose(draft, to: selectedTarget) + guard paymentRequests.outgoingRequests.contains(where: { $0.id == request.id }) else { return } + onSent(request) + } catch { + app.toast(error) + } + } + + private func contact(for target: PaykitPaymentRequestTarget) -> PubkyContact? { + contactsManager.contacts.first { PubkyPublicKeyFormat.matches($0.publicKey, target.publicKey) } + } + + private func displayName(for target: PaykitPaymentRequestTarget) -> String { + contact(for: target)?.displayName ?? target.publicKey + } +} + +struct PaymentRequestSentView: View { + @EnvironmentObject private var sheets: SheetViewModel + + let request: PaykitPaymentRequest + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + SheetHeader(title: t("wallet__payment_request_sent_title")) + + Spacer(minLength: 8) + + Image("check") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 256, height: 256) + .frame(maxWidth: .infinity) + + Spacer(minLength: 8) + + DisplayText(t("wallet__payment_request_sent_headline"), accentColor: .purpleAccent) + .padding(.bottom, 8) + + BodyMText(description, textColor: .white64) + .padding(.bottom, 16) + + PaymentRequestCard( + request: request, + subtitleOverride: request.deliveryStatus == .sent + ? t("wallet__payment_request_waiting") + : t("wallet__payment_request_sending"), + isHighlighted: false + ) + + Spacer(minLength: 32) + + CustomButton(title: t("common__ok")) { + sheets.hideSheet(reason: "Payment request created") + } + } + .padding(.horizontal, 16) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .sheetBackground() + .navigationBarHidden(true) + .allowSwipeBack(false) + .accessibilityIdentifier("PaymentRequestSent") + } + + private var description: String { + request.deliveryStatus == .sent + ? t("wallet__payment_request_sent_description") + : t("wallet__payment_request_queued_description") + } +} diff --git a/Bitkit/Views/PaymentRequests/PaymentRequestsView.swift b/Bitkit/Views/PaymentRequests/PaymentRequestsView.swift new file mode 100644 index 000000000..e37cb7592 --- /dev/null +++ b/Bitkit/Views/PaymentRequests/PaymentRequestsView.swift @@ -0,0 +1,408 @@ +import SwiftUI + +struct PaymentRequestsSheetItem: SheetItem { + let id: SheetID = .paymentRequests + let size: SheetSize = .large +} + +struct PaymentRequestCard: View { + @EnvironmentObject private var contactsManager: ContactsManager + + let request: PaykitPaymentRequest + var subtitleOverride: String? + var status: String? + var isHighlighted = true + var isActionDisabled = false + var onPay: (() -> Void)? + var onReject: (() async -> Void)? + + @State private var isRejecting = false + + private var contact: PubkyContact? { + contactsManager.contacts.first { PubkyPublicKeyFormat.matches($0.publicKey, request.counterparty) } + } + + private var senderName: String { + contact?.displayName ?? PubkyPublicKeyFormat.displayTruncated(request.counterparty) + } + + private var subtitle: String { + if let subtitleOverride { + return subtitleOverride + } + guard let createdAt = request.createdAt else { return senderName } + return "\(senderName) - \(Self.dateFormatter.string(from: createdAt))" + } + + var body: some View { + VStack(spacing: 0) { + HStack(spacing: 12) { + avatar + + VStack(alignment: .leading, spacing: 4) { + BodyMSBText(request.note ?? t("wallet__payment_request")) + .lineLimit(1) + CaptionText(subtitle, textColor: .white64) + .lineLimit(1) + } + + Spacer(minLength: 8) + + MoneyCell(sats: Int(clamping: request.amountSats), prefix: "") + } + .padding(16) + + if let status { + CustomDivider() + HStack { + CaptionMText(status, textColor: .brandAccent) + Spacer() + if let expiresAt = request.expiresAt { + CaptionText(Self.expirationFormatter.localizedString(for: expiresAt, relativeTo: Date()), textColor: .white64) + } + } + .padding(16) + } else if onPay != nil || onReject != nil { + HStack(spacing: 12) { + CustomButton( + title: t("wallet__payment_request_dismiss"), + variant: .secondary, + size: .small, + icon: Image("x-mark").resizable().frame(width: 16, height: 16), + isDisabled: isActionDisabled, + isLoading: isRejecting + ) { + guard !isRejecting else { return } + isRejecting = true + await onReject?() + isRejecting = false + } + .frame(maxWidth: .infinity) + + CustomButton( + title: t("common__pay"), + size: .small, + icon: Image("coins").resizable().frame(width: 16, height: 16), + isDisabled: isActionDisabled || isRejecting + ) { + onPay?() + } + .frame(maxWidth: .infinity) + } + .padding(16) + .background(Color.gray5) + } + } + .background(Color.gray6) + .clipShape(RoundedRectangle(cornerRadius: 16)) + .overlay { + RoundedRectangle(cornerRadius: 16) + .stroke(isHighlighted ? Color.brand50 : Color.clear, lineWidth: 1) + } + .shadow(color: isHighlighted ? .brandAccent.opacity(0.16) : .clear, radius: 64) + .accessibilityElement(children: .contain) + .accessibilityIdentifier("PaymentRequestRow-\(request.paymentRequestId)") + } + + @ViewBuilder + private var avatar: some View { + if let contact { + PubkyContactAvatar(contact: contact, size: 40) + } else { + ContactAvatarLetter(source: request.counterparty, size: 40) + } + } + + private static let dateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.locale = .autoupdatingCurrent + formatter.setLocalizedDateFormatFromTemplate("MMMMdHm") + return formatter + }() + + private static let expirationFormatter: RelativeDateTimeFormatter = { + let formatter = RelativeDateTimeFormatter() + formatter.unitsStyle = .full + return formatter + }() +} + +struct PaymentRequestsSheet: View { + @EnvironmentObject private var app: AppViewModel + @EnvironmentObject private var navigation: NavigationViewModel + @EnvironmentObject private var sheets: SheetViewModel + @Environment(PaykitPaymentRequestManager.self) private var paymentRequests + + let config: PaymentRequestsSheetItem + + var body: some View { + Sheet(id: .paymentRequests, data: config) { + VStack(spacing: 0) { + SheetHeader(title: t("wallet__payment_requests")) + + BodyMText(t("wallet__payment_requests_review"), textColor: .white64) + .multilineTextAlignment(.center) + .padding(.bottom, 24) + + ScrollView(showsIndicators: false) { + LazyVStack(spacing: 16) { + ForEach(paymentRequests.pendingRequests.prefix(3)) { request in + requestCard(request) + } + } + } + + Spacer(minLength: 16) + + HStack(spacing: 16) { + CustomButton(title: t("wallet__payment_requests_not_now"), variant: .secondary) { + sheets.hideSheet(reason: "Payment requests deferred") + } + + CustomButton(title: t("wallet__payment_requests_see_all")) { + sheets.hideSheet(reason: "Opening all payment requests") + navigation.navigate(.paymentRequests) + } + .accessibilityIdentifier("PaymentRequestsSeeAll") + } + } + .padding(.horizontal, 16) + } + .accessibilityIdentifier("PaymentRequestsSheet") + .onChange(of: paymentRequests.pendingRequests) { _, requests in + if requests.isEmpty { + sheets.hideSheetIfActive(.paymentRequests, reason: "No pending payment requests") + } + } + } + + private func requestCard(_ request: PaykitPaymentRequest) -> some View { + PaymentRequestCard( + request: request, + onPay: { + sheets.hideSheetBeforePerforming(reason: "Paying payment request") { + _ = paymentRequests.requestPresentation(request) + } + }, + onReject: { + await reject(request) + } + ) + } + + private func reject(_ request: PaykitPaymentRequest) async { + do { + try await paymentRequests.reject(request) + } catch { + app.toast(error) + } + } +} + +struct PaymentRequestsView: View { + private struct HistorySection: Identifiable { + let title: String + var requests: [PaykitPaymentRequest] + + var id: String { + title + } + } + + @EnvironmentObject private var app: AppViewModel + @EnvironmentObject private var contactsManager: ContactsManager + @EnvironmentObject private var sheets: SheetViewModel + @Environment(PaykitPaymentRequestManager.self) private var paymentRequests + + var body: some View { + VStack(spacing: 0) { + NavigationBar(title: t("wallet__payment_requests")) + + if paymentRequests.historyRequests.isEmpty { + emptyState + } else { + ScrollView(showsIndicators: false) { + LazyVStack(alignment: .leading, spacing: 16) { + if !activeRequests.isEmpty { + CaptionMText(t("wallet__payment_requests").localizedUppercase, textColor: .white64) + ForEach(activeRequests) { request in + activeRequestCard(request) + } + } + + ForEach(historySections) { section in + CaptionMText(section.title.localizedUppercase, textColor: .white64) + .padding(.top, 8) + ForEach(section.requests) { request in + PaymentRequestCard(request: request, subtitleOverride: historyDate(for: request), isHighlighted: false) + } + } + } + .padding(.top, 24) + .padding(.bottom, 120) + } + } + + if !paymentRequests.eligibleTargets.isEmpty { + CustomButton(title: t("wallet__payment_request_request_payment")) { + let draft = PaykitPaymentRequestDraft(amountSats: 0, note: "", expiresAt: .now) + sheets.showSheet(.receive, data: ReceiveConfig(view: .paymentRequestDetails(draft))) + } + .padding(.bottom, 16) + .accessibilityIdentifier("PaymentRequestRequestPayment") + } + } + .padding(.horizontal, 16) + .background(Color.black) + .navigationBarHidden(true) + .accessibilityIdentifier("PaymentRequestsScreen") + .task { + await paymentRequests.refresh() + } + } + + private var emptyState: some View { + VStack(alignment: .leading, spacing: 0) { + Spacer() + + Image("restore") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 256, height: 256) + .frame(maxWidth: .infinity) + .accessibilityHidden(true) + + Spacer() + + DisplayText(t("wallet__payment_requests_empty_headline"), accentColor: .purpleAccent) + Spacer().frame(height: 12) + BodyMText(t("wallet__payment_requests_empty_description"), textColor: .white64) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding(.bottom, 24) + } + + private var activeRequests: [PaykitPaymentRequest] { + paymentRequests.historyRequests.filter { + $0.lifecycleState == .proposed && !$0.isExpired(at: Date()) + } + } + + private var historicalRequests: [PaykitPaymentRequest] { + paymentRequests.historyRequests.filter { request in + !activeRequests.contains { $0.id == request.id } + } + } + + private var historySections: [HistorySection] { + historicalRequests + .sorted { ($0.createdAt ?? .distantPast) > ($1.createdAt ?? .distantPast) } + .reduce(into: [HistorySection]()) { sections, request in + let title = request.createdAt.map { DateFormatterHelpers.getActivityGroupHeader(for: $0) } ?? t("other__earlier") + if sections.last?.title == title { + sections[sections.count - 1].requests.append(request) + } else { + sections.append(HistorySection(title: title, requests: [request])) + } + } + } + + private func isActionable(_ request: PaykitPaymentRequest) -> Bool { + paymentRequests.pendingRequests.contains { $0.id == request.id } + } + + private func status(for request: PaykitPaymentRequest) -> String { + if request.isExpired(at: Date()), request.lifecycleState == .proposed { + return t("wallet__payment_request_status_expired") + } + + switch request.lifecycleState { + case .proposed: + if request.direction == .incoming { + return t("wallet__payment_request_status_unavailable") + } + return request.deliveryStatus == .sent + ? t("wallet__payment_request_waiting") + : t("wallet__payment_request_sending") + case .proposalExpired: + return t("wallet__payment_request_status_expired") + case .accepted: + return t("wallet__payment_request_status_accepted") + case .rejected: + return t("wallet__payment_request_status_rejected") + case .canceled: + return t("wallet__payment_request_status_canceled") + case .proofSubmitted: + return t("wallet__payment_request_status_proof_submitted") + case .recoveryRequired: + return t("wallet__payment_request_status_action_required") + case .invalidConflict, .activeRecurring, .unknown: + return t("wallet__payment_request_status_unavailable") + } + } + + @ViewBuilder + private func activeRequestCard(_ request: PaykitPaymentRequest) -> some View { + if isActionable(request) { + PaymentRequestCard( + request: request, + subtitleOverride: activeDate(for: request), + isActionDisabled: paymentRequests.requestedPresentationId == request.id, + onPay: { _ = paymentRequests.requestPresentation(request) }, + onReject: { await reject(request) } + ) + } else if request.direction == .outgoing { + PaymentRequestCard( + request: request, + subtitleOverride: t( + "wallet__payment_request_waiting_for_recipient", + variables: ["name": displayName(for: request)] + ), + isHighlighted: false + ) + } else { + PaymentRequestCard(request: request, status: status(for: request)) + } + } + + private func activeDate(for request: PaykitPaymentRequest) -> String { + guard let createdAt = request.createdAt else { return status(for: request) } + return Self.dateTimeFormatter.string(from: createdAt) + } + + private func historyDate(for request: PaykitPaymentRequest) -> String { + guard let createdAt = request.createdAt else { return status(for: request) } + return Self.dateFormatter.string(from: createdAt) + } + + private func displayName(for request: PaykitPaymentRequest) -> String { + guard let contact = contactsManager.contacts.first(where: { + PubkyPublicKeyFormat.matches($0.publicKey, request.counterparty) + }) else { + return PubkyPublicKeyFormat.displayTruncated(request.counterparty) + } + return contact.displayName + } + + private static let dateTimeFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.locale = .autoupdatingCurrent + formatter.setLocalizedDateFormatFromTemplate("MMMMdHm") + return formatter + }() + + private static let dateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.locale = .autoupdatingCurrent + formatter.setLocalizedDateFormatFromTemplate("MMMMd") + return formatter + }() + + private func reject(_ request: PaykitPaymentRequest) async { + do { + try await paymentRequests.reject(request) + } catch { + app.toast(error) + } + } +} diff --git a/Bitkit/Views/Wallets/Receive/ReceiveEdit.swift b/Bitkit/Views/Wallets/Receive/ReceiveEdit.swift index f61389aba..f8bf147b4 100644 --- a/Bitkit/Views/Wallets/Receive/ReceiveEdit.swift +++ b/Bitkit/Views/Wallets/Receive/ReceiveEdit.swift @@ -8,26 +8,26 @@ struct ReceiveEdit: View { @EnvironmentObject private var transfer: TransferViewModel @EnvironmentObject private var wallet: WalletViewModel @EnvironmentObject private var tagManager: TagManager + @Environment(PaykitPaymentRequestManager.self) private var paymentRequests @Environment(\.dismiss) private var dismiss + @AppStorage(PaykitFeatureFlags.uiEnabledKey) private var isPaykitUIEnabled = false + @Binding var navigationPath: [ReceiveRoute] + let onSendPaymentRequest: (PaykitPaymentRequestDraft) -> Void @State private var amountViewModel = AmountInputViewModel() @State private var note = "" @State private var isAmountInputFocused: Bool = false @FocusState private var isNoteEditorFocused: Bool - var navTitle: String { - isAmountInputFocused ? t("wallet__receive_specify") : t("wallet__receive_amount") - } - var amountSats: UInt64 { amountViewModel.amountSats } var body: some View { VStack(spacing: 0) { - SheetHeader(title: navTitle, showBackButton: true) + SheetHeader(title: t("wallet__receive_specify"), showBackButton: true) VStack(alignment: .leading, spacing: 0) { NumberPadTextField( @@ -49,27 +49,12 @@ struct ReceiveEdit: View { CaptionMText(t("wallet__note")) .padding(.bottom, 8) - ZStack(alignment: .topLeading) { - if note.isEmpty { - BodySSBText(t("wallet__receive_note_placeholder"), textColor: .textSecondary) - } - - TextEditor(text: $note) - .focused($isNoteEditorFocused) - .font(.custom(Fonts.semiBold, size: 15)) - .foregroundColor(.textPrimary) - .accentColor(.brandAccent) - .submitLabel(.done) - .scrollContentBackground(.hidden) - .padding(EdgeInsets(top: -8, leading: -5, bottom: -5, trailing: -5)) - .frame(minHeight: 30, maxHeight: 50) - .dismissKeyboardOnReturn(text: $note, isFocused: $isNoteEditorFocused) - .accessibilityValue(note) - .accessibilityIdentifier("ReceiveNote") - } - .padding() - .background(Color.white06) - .cornerRadius(8) + NoteTextEditor( + text: $note, + placeholder: t("wallet__receive_note_placeholder"), + testIdentifier: "ReceiveNote", + isFocused: $isNoteEditorFocused + ) if !isNoteEditorFocused { VStack(alignment: .leading, spacing: 0) { @@ -95,6 +80,27 @@ struct ReceiveEdit: View { Spacer() + if PaykitFeatureFlags.isUIAvailable, + isPaykitUIEnabled, + !paymentRequests.eligibleTargets.isEmpty + { + CustomButton( + title: t("wallet__payment_request_send"), + variant: .secondary, + isDisabled: amountSats == 0 + ) { + onSendPaymentRequest( + PaykitPaymentRequestDraft( + amountSats: amountSats, + note: note.trimmingCharacters(in: .whitespacesAndNewlines), + expiresAt: PaymentRequestExpiration.week.date(from: .now) + ) + ) + } + .padding(.bottom, 12) + .accessibilityIdentifier("PaymentRequestSendButton") + } + CustomButton(title: t("wallet__receive_show_qr")) { Task { await onShowQR() diff --git a/Bitkit/Views/Wallets/Receive/ReceiveQr.swift b/Bitkit/Views/Wallets/Receive/ReceiveQr.swift index abb48ab77..236b1f712 100644 --- a/Bitkit/Views/Wallets/Receive/ReceiveQr.swift +++ b/Bitkit/Views/Wallets/Receive/ReceiveQr.swift @@ -4,7 +4,6 @@ struct ReceiveQr: View { @EnvironmentObject private var app: AppViewModel @EnvironmentObject private var blocktank: BlocktankViewModel @EnvironmentObject private var wallet: WalletViewModel - @Binding var navigationPath: [ReceiveRoute] let cjitInvoice: String? let tab: ReceiveTab? @@ -13,7 +12,11 @@ struct ReceiveQr: View { @State private var showDetails = false @State private var hasAppliedDefaultTab = false - init(navigationPath: Binding<[ReceiveRoute]>, cjitInvoice: String? = nil, tab: ReceiveTab? = nil) { + init( + navigationPath: Binding<[ReceiveRoute]>, + cjitInvoice: String? = nil, + tab: ReceiveTab? = nil + ) { _navigationPath = navigationPath self.cjitInvoice = cjitInvoice self.tab = tab diff --git a/Bitkit/Views/Wallets/Receive/ReceiveSheet.swift b/Bitkit/Views/Wallets/Receive/ReceiveSheet.swift index 11ddfaf68..c917fb3e7 100644 --- a/Bitkit/Views/Wallets/Receive/ReceiveSheet.swift +++ b/Bitkit/Views/Wallets/Receive/ReceiveSheet.swift @@ -9,6 +9,9 @@ enum ReceiveRoute: Hashable { case cjitConfirm(entry: IcJitEntry, receiveAmountSats: UInt64, isAdditional: Bool) case cjitLearnMore(entry: IcJitEntry, receiveAmountSats: UInt64, isAdditional: Bool) case cjitGeoBlocked + case paymentRequestDetails(PaykitPaymentRequestDraft) + case paymentRequestRecipient(PaykitPaymentRequestDraft) + case paymentRequestSent(PaykitPaymentRequest) } struct ReceiveConfig { @@ -65,9 +68,15 @@ struct ReceiveSheet: View { private func viewForRoute(_ route: ReceiveRoute) -> some View { switch route { case let .qr(cjitInvoice, tab): - ReceiveQr(navigationPath: $navigationPath, cjitInvoice: cjitInvoice, tab: tab) + ReceiveQr( + navigationPath: $navigationPath, + cjitInvoice: cjitInvoice, + tab: tab + ) case .edit: - ReceiveEdit(navigationPath: $navigationPath) + ReceiveEdit(navigationPath: $navigationPath) { draft in + navigationPath.append(.paymentRequestRecipient(draft)) + } case .tag: ReceiveTag(navigationPath: $navigationPath) case .cjitAmount: @@ -78,6 +87,28 @@ struct ReceiveSheet: View { ReceiveCjitLearnMore(entry: entry, receiveAmountSats: receiveAmountSats, isAdditional: isAdditional) case .cjitGeoBlocked: ReceiveCjitGeoBlocked() + case let .paymentRequestDetails(draft): + PaymentRequestDetailsView(initialDraft: draft) { updatedDraft in + if navigationPath.count >= 2, + case .paymentRequestDetails = navigationPath[navigationPath.count - 1], + case .paymentRequestRecipient = navigationPath[navigationPath.count - 2] + { + navigationPath.removeLast(2) + } + navigationPath.append(.paymentRequestRecipient(updatedDraft)) + } + case let .paymentRequestRecipient(draft): + PaymentRequestRecipientView( + draft: draft, + onEditExpiration: { + navigationPath.append(.paymentRequestDetails(draft)) + }, + onSent: { request in + navigationPath.append(.paymentRequestSent(request)) + } + ) + case let .paymentRequestSent(request): + PaymentRequestSentView(request: request) } } } diff --git a/Bitkit/Views/Wallets/Send/SendSheet.swift b/Bitkit/Views/Wallets/Send/SendSheet.swift index 696c02dde..88124988c 100644 --- a/Bitkit/Views/Wallets/Send/SendSheet.swift +++ b/Bitkit/Views/Wallets/Send/SendSheet.swift @@ -88,6 +88,7 @@ struct SendSheet: View { @State private var navigationPath: [SendRoute] = [] @State private var hasValidatedAfterSync = false + @State private var incomingPaymentRequest: PaykitPaymentRequest? @State private var routingCacheResetAttempted = false @State private var syncTimedOut = false @State private var pinCheckContinuations: [CheckedContinuation] = [] @@ -162,6 +163,12 @@ struct SendSheet: View { tagManager.clearSelectedTags() wallet.resetSendState(speed: settings.defaultTransactionSpeed) if let request = app.contactPaymentContext?.incomingPaymentRequest { + incomingPaymentRequest = request + guard paykitPaymentRequestManager.markPresentedIfPending(request) else { + app.resetSendState() + sheets.hideSheetIfActive(.send, reason: "Incoming payment request is no longer available") + return + } wallet.sendAmountSats = request.amountSats } hasValidatedAfterSync = false @@ -186,6 +193,9 @@ struct SendSheet: View { } } .onDisappear { + if let incomingPaymentRequest { + paykitPaymentRequestManager.finishPayment(incomingPaymentRequest) + } app.contactPaymentContext = nil } .onChange(of: wallet.nodeLifecycleState) { _, state in diff --git a/BitkitTests/PaykitPaymentRequestServiceTests.swift b/BitkitTests/PaykitPaymentRequestServiceTests.swift index 67c75579e..53fc20aac 100644 --- a/BitkitTests/PaykitPaymentRequestServiceTests.swift +++ b/BitkitTests/PaykitPaymentRequestServiceTests.swift @@ -98,6 +98,39 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { XCTAssertEqual(manager.pendingRequests.map(\.paymentRequestId), ["valid"]) } + func testRefreshKeepsOneTimeBitcoinLifecycleHistory() async throws { + let now = Date(timeIntervalSince1970: 1_800_000_000) + let records = try [ + paymentRequestRecord(id: "incoming", state: .proposed), + paymentRequestRecord(id: "accepted", state: .accepted), + paymentRequestRecord(id: "rejected", state: .rejected), + paymentRequestRecord(id: "expired", state: .proposalExpired, expiresAt: timestamp(now)), + paymentRequestRecord(id: "outgoing", state: .proposed, role: .payee), + paymentRequestRecord(id: "recurring", state: .activeRecurring), + paymentRequestRecord(id: "unsupported", state: .canceled, endpoints: ["btc-unsupported-method"]), + ] + let manager = paymentRequestManager( + sdk: PaymentRequestSdkMock(records: records), + clock: PaymentRequestTestClock(now) + ) + + await manager.refresh() + + XCTAssertEqual(manager.pendingRequests.map(\.paymentRequestId), ["incoming"]) + XCTAssertEqual( + Set(manager.historyRequests.map(\.paymentRequestId)), + Set(["incoming", "accepted", "rejected", "expired", "outgoing", "unsupported"]) + ) + XCTAssertEqual( + manager.historyRequests.first { $0.paymentRequestId == "accepted" }?.lifecycleState, + .accepted + ) + XCTAssertEqual( + manager.historyRequests.first { $0.paymentRequestId == "outgoing" }?.direction, + .outgoing + ) + } + func testRefreshRejectsAmountsOutsideTheAppPaymentRange() async throws { let records = try [ paymentRequestRecord(id: "one-sat", amount: "0.00000001"), @@ -169,6 +202,7 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { XCTAssertEqual(manager.pendingRequests.count, 1) try await waitUntil(timeout: .seconds(5)) { manager.pendingRequests.isEmpty } + XCTAssertEqual(manager.historyRequests.count, 1) } func testPresentedRequestRemainsPendingWithoutBeingPresentedAgain() async throws { @@ -195,11 +229,11 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { manager.deferPresentation(request) XCTAssertTrue(manager.requestsForPresentation().isEmpty) - clock.advance(by: 30) + clock.advance(by: 2) XCTAssertEqual(manager.requestsForPresentation(), [request]) manager.deferPresentation(request) - clock.advance(by: 59) + clock.advance(by: 1) XCTAssertTrue(manager.requestsForPresentation().isEmpty) clock.advance(by: 1) @@ -214,15 +248,15 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { await manager.refresh() let request = try XCTUnwrap(manager.requestsForPresentation().first) - for delay in [30.0, 60.0, 120.0, 300.0] { + for _ in 0 ..< 14 { manager.deferPresentation(request) XCTAssertTrue(manager.requestsForPresentation().isEmpty) - clock.advance(by: delay) + clock.advance(by: 2) XCTAssertEqual(manager.requestsForPresentation(), [request]) } manager.deferPresentation(request) - clock.advance(by: 300) + clock.advance(by: 2) XCTAssertTrue(manager.requestsForPresentation().isEmpty) XCTAssertEqual(manager.pendingRequests, [request]) @@ -270,10 +304,46 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { let snapshot = await sdk.snapshot() XCTAssertTrue(snapshot.acceptedRequests.isEmpty) - clock.advance(by: 30) + clock.advance(by: 2) XCTAssertEqual(manager.requestsForPresentation(), [request]) } + func testFailedAcceptanceDropsRequestRemovedFromAuthoritativeQueue() 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.failNextAcceptAfterRemoval() + + do { + try await manager.prepareForPayment(request) + XCTFail("Expected payment request acceptance to fail") + } catch { + XCTAssertEqual(error as? PaymentRequestSdkMockError, .process) + } + + XCTAssertTrue(manager.pendingRequests.isEmpty) + XCTAssertTrue(manager.requestsForPresentation().isEmpty) + } + + func testFailedRejectionDropsRequestRemovedFromAuthoritativeQueue() 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.failNextRejectAfterRemoval() + + do { + try await manager.reject(request) + XCTFail("Expected payment request rejection to fail") + } catch { + XCTAssertEqual(error as? PaymentRequestSdkMockError, .process) + } + + XCTAssertTrue(manager.pendingRequests.isEmpty) + XCTAssertTrue(manager.requestsForPresentation().isEmpty) + } + func testPresentationOperationIsNotReentered() async throws { let sdk = try PaymentRequestSdkMock(records: [paymentRequestRecord()]) let manager = paymentRequestManager(sdk: sdk) @@ -293,11 +363,130 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { XCTAssertEqual(presentationCount, 1) continuation?.resume() - await presentationTask.value + _ = await presentationTask.value await manager.presentRequests { _ in presentationCount += 1 } XCTAssertEqual(presentationCount, 2) } + func testClearInvalidatesInFlightPresentation() async throws { + let sdk = try PaymentRequestSdkMock(records: [paymentRequestRecord()]) + let manager = paymentRequestManager(sdk: sdk) + await manager.refresh() + var wasCurrentBeforeClear = false + var wasCurrentAfterClear = true + + await manager.presentRequests { requests in + guard let request = requests.first else { + XCTFail("Expected an incoming payment request") + return + } + wasCurrentBeforeClear = manager.isCurrentPresentation(request) + manager.clear() + wasCurrentAfterClear = manager.isCurrentPresentation(request) + } + + XCTAssertTrue(wasCurrentBeforeClear) + XCTAssertFalse(wasCurrentAfterClear) + } + + func testManualPresentationSupersedesInFlightAutomaticPresentation() async throws { + let first = try paymentRequestRecord(id: "first") + let second = try paymentRequestRecord(id: "second") + let manager = paymentRequestManager(sdk: PaymentRequestSdkMock(records: [first, second])) + await manager.refresh() + let secondRequest = try XCTUnwrap(manager.pendingRequests.first { $0.paymentRequestId == "second" }) + var continuation: CheckedContinuation? + var automaticPresentationRemainedCurrent = true + + let task = Task { + await manager.presentRequests { requests in + guard let automaticRequest = requests.first else { + XCTFail("Expected an incoming payment request") + return + } + await withCheckedContinuation { continuation = $0 } + automaticPresentationRemainedCurrent = manager.isCurrentPresentation(automaticRequest) + } + } + try await waitUntil { continuation != nil } + + XCTAssertTrue(manager.requestPresentation(secondRequest)) + continuation?.resume() + _ = await task.value + + XCTAssertFalse(automaticPresentationRemainedCurrent) + XCTAssertEqual(manager.requestsForPresentation(), [secondRequest]) + } + + func testRequestBeingAcceptedIsExcludedFromAutomaticPresentation() async throws { + let first = try paymentRequestRecord(id: "first") + let second = try paymentRequestRecord(id: "second") + let sdk = PaymentRequestSdkMock(records: [first, second]) + let manager = paymentRequestManager(sdk: sdk) + await manager.refresh() + let firstRequest = try XCTUnwrap(manager.pendingRequests.first { $0.paymentRequestId == "first" }) + await sdk.pauseNextAccept() + + let task = Task { try await manager.prepareForPayment(firstRequest) } + try await waitUntil { await sdk.acceptIsPaused() } + + XCTAssertEqual(manager.requestsForPresentation().map(\.paymentRequestId), ["second"]) + + await sdk.resumeAccept() + try await task.value + } + + func testAcceptedRequestStaysApprovedUntilSendFlowFinishes() async throws { + 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)) + manager.finishPayment(request) + XCTAssertFalse(manager.isApprovedForPayment(request)) + } + + func testRefreshKeepsRequestVisibleWhileAcceptanceIsFinishing() 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.pauseNextProcess() + + let acceptance = Task { try await manager.prepareForPayment(request) } + try await waitUntil { await sdk.processIsPaused() } + await manager.refresh() + + XCTAssertEqual(manager.pendingRequests, [request]) + XCTAssertFalse(manager.isApprovedForPayment(request)) + + await sdk.resumeProcess() + try await acceptance.value + XCTAssertTrue(manager.pendingRequests.isEmpty) + XCTAssertTrue(manager.isApprovedForPayment(request)) + } + + func testManualPresentationRetriesWhenPrivateDetailsBecomeAvailable() async throws { + let sdk = try PaymentRequestSdkMock(records: [paymentRequestRecord()]) + let clock = PaymentRequestTestClock(Date()) + let manager = paymentRequestManager(sdk: sdk, clock: clock) + await manager.refresh() + let request = try XCTUnwrap(manager.pendingRequests.first) + XCTAssertTrue(manager.markPresentedIfPending(request)) + XCTAssertTrue(manager.requestPresentation(request)) + + manager.deferPresentation(request) + + XCTAssertEqual(manager.requestedPresentationId, request.id) + XCTAssertTrue(manager.requestsForPresentation().isEmpty) + try await waitUntil(timeout: .seconds(4)) { manager.presentationRetryTrigger > 0 } + clock.advance(by: 2) + XCTAssertEqual(manager.requestsForPresentation(), [request]) + } + func testExpiredRequestCannotBeMarkedPresented() async throws { let now = Date(timeIntervalSince1970: 1_800_000_000) let clock = PaymentRequestTestClock(now) @@ -339,6 +528,10 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { try await manager.prepareForPayment(request) XCTAssertEqual(manager.pendingRequests.map(\.id), remainingIds) + XCTAssertEqual( + manager.historyRequests.first { $0.id == request.id }?.lifecycleState, + .accepted + ) let snapshot = await sdk.snapshot() XCTAssertEqual( snapshot.acceptedRequests, @@ -435,16 +628,462 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { XCTAssertEqual(snapshot.receiveCallCount, 1) } + func testPresentedRequestStaysQueuedWithoutAutoPresentingAfterManagerRecreation() async throws { + let identity = "pubky\(String(repeating: "y", count: 52))" + let sdk = try PaymentRequestSdkMock(records: [paymentRequestRecord()]) + let store = PaymentRequestPresentationMemoryStore() + let firstManager = PaykitPaymentRequestManager( + service: PaykitPaymentRequestService(sdk: sdk, logWarning: { _ in }), + presentationStore: store, + logWarning: { _ in } + ) + firstManager.activate(identity: identity) + await firstManager.refresh() + let request = try XCTUnwrap(firstManager.pendingRequests.first) + XCTAssertTrue(firstManager.markPresentedIfPending(request)) + + let restoredManager = PaykitPaymentRequestManager( + service: PaykitPaymentRequestService(sdk: sdk, logWarning: { _ in }), + presentationStore: store, + logWarning: { _ in } + ) + restoredManager.activate(identity: identity) + await restoredManager.refresh() + + XCTAssertEqual(restoredManager.pendingRequests, [request]) + XCTAssertTrue(restoredManager.requestsForPresentation().isEmpty) + XCTAssertTrue(restoredManager.requestPresentation(request)) + XCTAssertEqual(restoredManager.requestsForPresentation(), [request]) + } + + func testRejectRemovesOnlyMatchingRequestAndQueuesResponse() async throws { + let firstRecord = try paymentRequestRecord(id: "first") + let secondRecord = try paymentRequestRecord(id: "second") + let sdk = PaymentRequestSdkMock(records: [firstRecord, secondRecord]) + let manager = paymentRequestManager(sdk: sdk) + await manager.refresh() + + try await manager.reject(XCTUnwrap(manager.pendingRequests.first(where: { $0.paymentRequestId == "first" }))) + + XCTAssertEqual(manager.pendingRequests.map(\.paymentRequestId), ["second"]) + XCTAssertEqual( + manager.historyRequests.first { $0.paymentRequestId == "first" }?.lifecycleState, + .rejected + ) + let snapshot = await sdk.snapshot() + XCTAssertEqual(snapshot.rejectedRequests.map(\.paymentRequestId), ["first"]) + XCTAssertEqual(snapshot.processCallCount, 2) + } + + func testEligibleTargetsRequireSavedLinkedPaymentRequestCapablePath() 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), + ], + receiverPathsByPublicKey: [ + savedKey: [PaykitReceiverPath.wallet, PaykitReceiverPath.server], + unsavedKey: [PaykitReceiverPath.wallet], + ] + ) + let manager = paymentRequestManager(sdk: sdk) + + await manager.refreshEligibleTargets(savedPublicKeys: [savedKey]) + + XCTAssertEqual( + manager.eligibleTargets, + [PaykitPaymentRequestTarget(publicKey: savedKey, receiverPath: PaykitReceiverPath.server)] + ) + } + + 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]] + ) + await sdk.setLiveSessionAvailable(false) + let manager = paymentRequestManager(sdk: sdk) + + await manager.refreshEligibleTargets(savedPublicKeys: [savedKey]) + + XCTAssertTrue(manager.eligibleTargets.isEmpty) + } + + func testEligibleTargetsRequireTheActiveIdentity() 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]] + ) + await sdk.setActiveIdentity("pubky\(String(repeating: "a", count: 52))") + let manager = paymentRequestManager(sdk: sdk) + + await manager.refreshEligibleTargets(savedPublicKeys: [savedKey]) + + XCTAssertTrue(manager.eligibleTargets.isEmpty) + } + + func testEligibleTargetsRequirePrivatePaymentPublication() 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]] + ) + let manager = paymentRequestManager(sdk: sdk, isPrivatePaymentPublishingEnabled: false) + + await manager.refreshEligibleTargets(savedPublicKeys: [savedKey]) + + XCTAssertTrue(manager.eligibleTargets.isEmpty) + } + + func testOlderEligibilityRefreshCannotOverwriteNewerContacts() async throws { + let firstKey = "pubky\(String(repeating: "a", count: 52))" + 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]] + ) + await sdk.pauseNextLinkedPeers() + let manager = paymentRequestManager(sdk: sdk) + + let olderRefresh = Task { + await manager.refreshEligibleTargets(savedPublicKeys: [firstKey]) + } + try await waitUntil { await sdk.linkedPeersIsPaused() } + await sdk.configureRecipients( + peers: [linkedPeer(counterparty: secondKey, path: PaykitReceiverPath.wallet, state: .linked)], + receiverPathsByPublicKey: [secondKey: [PaykitReceiverPath.wallet]] + ) + await manager.refreshEligibleTargets(savedPublicKeys: [secondKey]) + await sdk.resumeLinkedPeers() + await olderRefresh.value + + XCTAssertEqual( + manager.eligibleTargets, + [PaykitPaymentRequestTarget(publicKey: secondKey, receiverPath: PaykitReceiverPath.wallet)] + ) + } + + func testProposeBuildsOneTimeBitcoinTermsAndDrainsOutbox() async throws { + let publicKey = "pubky\(String(repeating: "y", count: 52))" + 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]] + ) + try await sdk.setProposalResult(paymentRequestRecord( + id: "outgoing", + counterparty: publicKey, + counterpartyReceiverPath: PaykitReceiverPath.wallet, + role: .payee, + expiresAt: timestamp(expiresAt) + )) + let manager = paymentRequestManager(sdk: sdk) + await manager.refreshEligibleTargets(savedPublicKeys: [publicKey]) + let target = try XCTUnwrap(manager.eligibleTargets.first) + + let request = try await manager.propose( + PaykitPaymentRequestDraft(amountSats: 1, note: "Coffee", expiresAt: expiresAt), + to: target + ) + + XCTAssertEqual(request.amountSats, 1) + XCTAssertEqual(request.note, "Coffee") + 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) + XCTAssertEqual(snapshot.proposedRequests.first?.metadata, #"{"note":"Coffee"}"#) + XCTAssertTrue(snapshot.proposedRequests.first?.endpointIdentifiers.contains( + PublicPaykitService.MethodId.bitcoinLightningBolt11.rawValue + ) == true) + XCTAssertTrue(snapshot.proposedRequests.first?.endpointIdentifiers.allSatisfy { + PublicPaykitService.MethodId(rawValue: $0)?.onchainNetwork.map { $0 == Env.network } ?? true + } == true) + XCTAssertEqual(snapshot.processCallCount, 1) + } + + func testProposeRemainsCreatedWhenImmediateDeliveryFails() async throws { + let publicKey = "pubky\(String(repeating: "y", count: 52))" + let expiresAt = Date(timeIntervalSince1970: 1_900_000_000) + let sdk = PaymentRequestSdkMock(records: []) + await sdk.configureRecipients( + peers: [linkedPeer(counterparty: publicKey, path: PaykitReceiverPath.wallet, state: .linked)], + receiverPathsByPublicKey: [publicKey: [PaykitReceiverPath.wallet]] + ) + try await sdk.setProposalResult(paymentRequestRecord( + id: "outgoing", + counterparty: publicKey, + counterpartyReceiverPath: PaykitReceiverPath.wallet, + role: .payee, + expiresAt: timestamp(expiresAt) + )) + await sdk.failNextProcess() + let manager = paymentRequestManager(sdk: sdk) + await manager.refreshEligibleTargets(savedPublicKeys: [publicKey]) + + _ = try await manager.propose( + PaykitPaymentRequestDraft(amountSats: 1, note: "", expiresAt: expiresAt), + to: XCTUnwrap(manager.eligibleTargets.first) + ) + + XCTAssertEqual(manager.outgoingRequests.map(\.paymentRequestId), ["outgoing"]) + let snapshot = await sdk.snapshot() + XCTAssertEqual(snapshot.proposedRequests.count, 1) + XCTAssertEqual(snapshot.processCallCount, 1) + } + + func testProposeRevalidatesTargetBeforeEnqueueing() 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]] + ) + try await sdk.setProposalResult(paymentRequestRecord( + id: "outgoing", + counterparty: publicKey, + counterpartyReceiverPath: PaykitReceiverPath.wallet, + role: .payee, + expiresAt: timestamp(expiresAt) + )) + let manager = paymentRequestManager(sdk: sdk) + await manager.refreshEligibleTargets(savedPublicKeys: [publicKey]) + let target = try XCTUnwrap(manager.eligibleTargets.first) + + await sdk.configureRecipients(peers: [], receiverPathsByPublicKey: [publicKey: [PaykitReceiverPath.wallet]]) + + do { + _ = try await manager.propose( + PaykitPaymentRequestDraft(amountSats: 1, note: "", expiresAt: expiresAt), + to: target + ) + XCTFail("Expected the stale target to be rejected") + } catch let error as PaykitPaymentRequestError { + XCTAssertEqual(error, .requestUnavailable) + } + let snapshot = await sdk.snapshot() + XCTAssertTrue(snapshot.proposedRequests.isEmpty) + } + + func testExpiredDraftIsRejectedBeforeEnqueueing() async throws { + let now = Date(timeIntervalSince1970: 1_800_000_000) + 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]] + ) + let manager = paymentRequestManager(sdk: sdk, clock: PaymentRequestTestClock(now)) + await manager.refreshEligibleTargets(savedPublicKeys: [publicKey]) + let target = try XCTUnwrap(manager.eligibleTargets.first) + + do { + _ = try await manager.propose( + PaykitPaymentRequestDraft(amountSats: 1, note: "", expiresAt: now), + to: target + ) + XCTFail("Expected the expired draft to be rejected") + } catch let error as PaykitPaymentRequestError { + XCTAssertEqual(error, .requestExpired) + } + let snapshot = await sdk.snapshot() + XCTAssertTrue(snapshot.proposedRequests.isEmpty) + } + + func testProposalCompletionAfterClearDoesNotRepopulateManager() 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]] + ) + try await sdk.setProposalResult(paymentRequestRecord( + id: "outgoing", + counterparty: publicKey, + counterpartyReceiverPath: PaykitReceiverPath.wallet, + role: .payee, + expiresAt: timestamp(expiresAt) + )) + await sdk.pauseNextProposal() + let manager = paymentRequestManager(sdk: sdk) + await manager.refreshEligibleTargets(savedPublicKeys: [publicKey]) + let target = try XCTUnwrap(manager.eligibleTargets.first) + + let task = Task { + try await manager.propose( + PaykitPaymentRequestDraft(amountSats: 1, note: "", expiresAt: expiresAt), + to: target + ) + } + try await waitUntil { await sdk.proposalIsPaused() } + manager.clear() + await sdk.resumeProposal() + + let committedRequest = try await task.value + XCTAssertEqual(committedRequest.paymentRequestId, "outgoing") + XCTAssertTrue(manager.outgoingRequests.isEmpty) + XCTAssertTrue(manager.eligibleTargets.isEmpty) + } + + func testProposalDoesNotCommitAfterSdkIdentityChanges() 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]] + ) + try await sdk.setProposalResult(paymentRequestRecord( + id: "outgoing", + counterparty: publicKey, + counterpartyReceiverPath: PaykitReceiverPath.wallet, + role: .payee, + expiresAt: timestamp(expiresAt) + )) + await sdk.pauseNextProposal() + 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.proposalIsPaused() } + await sdk.setActiveIdentity("pubky\(String(repeating: "a", count: 52))") + await sdk.resumeProposal() + + do { + _ = try await proposal.value + XCTFail("Expected the identity change to cancel the proposal") + } catch let error as PaykitPaymentRequestError { + XCTAssertEqual(error, .requestUnavailable) + } + let snapshot = await sdk.snapshot() + XCTAssertTrue(snapshot.proposedRequests.isEmpty) + XCTAssertTrue(manager.outgoingRequests.isEmpty) + } + + func testProposalReportsConfirmedDelivery() async throws { + let publicKey = "pubky\(String(repeating: "y", count: 52))" + let expiresAt = Date(timeIntervalSince1970: 1_900_000_000) + let messageId: UInt64 = 7 + let sdk = PaymentRequestSdkMock(records: []) + await sdk.configureRecipients( + peers: [linkedPeer(counterparty: publicKey, path: PaykitReceiverPath.wallet, state: .linked)], + receiverPathsByPublicKey: [publicKey: [PaykitReceiverPath.wallet]] + ) + try await sdk.setProposalResult(paymentRequestRecord( + id: "outgoing", + counterparty: publicKey, + counterpartyReceiverPath: PaykitReceiverPath.wallet, + role: .payee, + expiresAt: timestamp(expiresAt), + proposalOutboundMessageId: messageId + )) + await sdk.setProcessReports([ + OutboundPrivateCounterpartySendReport( + counterparty: publicKey, + counterpartyReceiverPath: PaykitReceiverPath.wallet, + report: OutboundPrivateSendReport( + attempted: [messageId], + sent: [messageId], + failed: [], + reservationCleanupFailures: [], + recoveryMarkerFailures: [] + ), + error: nil + ), + ]) + let manager = paymentRequestManager(sdk: sdk) + await manager.refreshEligibleTargets(savedPublicKeys: [publicKey]) + let target = try XCTUnwrap(manager.eligibleTargets.first) + + let request = try await manager.propose( + PaykitPaymentRequestDraft(amountSats: 1, note: "", expiresAt: expiresAt), + to: target + ) + + XCTAssertEqual(request.deliveryStatus, .sent) + } + + func testManualPresentationDoesNotFallThroughToAutomaticRequest() async throws { + let first = try paymentRequestRecord(id: "first") + let second = try paymentRequestRecord(id: "second") + let manager = paymentRequestManager(sdk: PaymentRequestSdkMock(records: [first, second])) + await manager.refresh() + let secondRequest = try XCTUnwrap(manager.pendingRequests.first { $0.paymentRequestId == "second" }) + + XCTAssertTrue(manager.requestPresentation(secondRequest)) + XCTAssertEqual(manager.requestsForPresentation(), [secondRequest]) + } + + func testSurfacedRequestsRemainScopedAcrossIdentitySwitches() async throws { + let identityA = "pubky\(String(repeating: "a", count: 52))" + let identityB = "pubky\(String(repeating: "b", count: 52))" + let sdk = try PaymentRequestSdkMock(records: [paymentRequestRecord()]) + let store = PaymentRequestPresentationMemoryStore() + let manager = PaykitPaymentRequestManager( + service: PaykitPaymentRequestService(sdk: sdk, logWarning: { _ in }), + presentationStore: store, + isAvailable: { true }, + logWarning: { _ in } + ) + + manager.activate(identity: identityA) + await manager.refresh() + let requestForIdentityA = try XCTUnwrap(manager.pendingRequests.first) + XCTAssertTrue(manager.markPresentedIfPending(requestForIdentityA)) + + manager.activate(identity: identityB) + await manager.refresh() + XCTAssertEqual(manager.requestsForPresentation().count, 1) + let requestForIdentityB = try XCTUnwrap(manager.pendingRequests.first) + XCTAssertTrue(manager.markPresentedIfPending(requestForIdentityB)) + + manager.activate(identity: identityA) + await manager.refresh() + XCTAssertTrue(manager.requestsForPresentation().isEmpty) + } + private func paymentRequestManager( sdk: PaymentRequestSdkMock, - clock: PaymentRequestTestClock = PaymentRequestTestClock(Date()) + clock: PaymentRequestTestClock = PaymentRequestTestClock(Date()), + isPrivatePaymentPublishingEnabled: Bool = true ) -> PaykitPaymentRequestManager { let now: @Sendable () -> Date = { clock.now() } - return PaykitPaymentRequestManager( - service: PaykitPaymentRequestService(sdk: sdk, now: now, logWarning: { _ in }), + let manager = PaykitPaymentRequestManager( + service: PaykitPaymentRequestService( + sdk: sdk, + now: now, + isPrivatePaymentPublishingEnabled: { isPrivatePaymentPublishingEnabled }, + logWarning: { _ in } + ), + presentationStore: PaymentRequestPresentationMemoryStore(), now: now, + isAvailable: { true }, logWarning: { _ in } ) + manager.activate(identity: "pubky\(String(repeating: "z", count: 52))") + return manager } private func paymentRequestRecord( @@ -458,7 +1097,9 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { expiresAt: String? = nil, recurrence: PaymentRequestRecurrence? = nil, endpoints: [String] = [PublicPaykitService.MethodId.bitcoinLightningBolt11.rawValue], - metadata: String = "{}" + metadata: String = "{}", + proposalOutboundMessageId: UInt64? = nil, + proposalOutboundStatus: OutboundPrivateMessageStatus? = nil ) throws -> PaymentRequestRecord { try PaymentRequestRecord( counterparty: counterparty, @@ -467,8 +1108,8 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { localRole: role, state: state, proposalStreamItemId: 1, - proposalOutboundMessageId: nil, - proposalOutboundStatus: nil, + proposalOutboundMessageId: proposalOutboundMessageId, + proposalOutboundStatus: proposalOutboundStatus, proposalEventId: "650e8400-e29b-41d4-a716-446655440000", terms: PaymentRequestTerms( amount: PaymentRequestAmount(value: amount, asset: asset), @@ -498,34 +1139,87 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] return formatter.string(from: date) } + + private func linkedPeer(counterparty: String, path: String, state: LinkedPeerState) -> LinkedPeerRecord { + LinkedPeerRecord( + counterparty: counterparty, + counterpartyReceiverPath: path, + state: state, + lastSyncAt: nil, + lastPrivateReceiveAt: nil, + failureCount: 0, + localRecoveryAttemptId: nil, + localRecoveryMarkerCreatedAt: nil, + localRecoveryMarkerLastError: nil, + remoteRecoveryAttemptId: nil, + remoteRecoveryMarkerObservedAt: nil + ) + } +} + +private final class PaymentRequestPresentationMemoryStore: PaykitPaymentRequestPresentationStoring { + private var states: [String: Set] = [:] + + func load(identity: String) -> Set { + states[identity] ?? [] + } + + func save(_ ids: Set, identity: String) { + states[identity] = ids + } } private actor PaymentRequestSdkMock: PaykitPaymentRequestSdkHandling { + private var activeIdentity = "pubky\(String(repeating: "z", count: 52))" private var records: [PaymentRequestRecord] + private var peerRecords: [LinkedPeerRecord] = [] + private var receiverPathsByPublicKey: [String: [String]] = [:] + private var liveSessionAvailable = true + private var proposalResult: PaymentRequestRecord? private var processCallCount = 0 private var receiveCallCount = 0 private var processFailuresRemaining = 0 private var processCancellationsRemaining = 0 + private var processReports: [OutboundPrivateCounterpartySendReport] = [] + private var shouldPauseNextProcess = false + private var isProcessPaused = false + private var processContinuation: CheckedContinuation? private var receiveError: PaymentRequestSdkMockError? private var acceptedRequests: [PaymentRequestInvocation] = [] + private var rejectedRequests: [PaymentRequestInvocation] = [] + private var acceptFailuresAfterRemoval = 0 + private var rejectFailuresAfterRemoval = 0 + private var proposedRequests: [ProposedPaymentRequestInvocation] = [] private var shouldPauseNextPaymentRequestList = false private var isPaymentRequestListPaused = false private var paymentRequestListContinuation: CheckedContinuation? private var shouldPauseNextAccept = false private var isAcceptPaused = false private var acceptContinuation: CheckedContinuation? + private var shouldPauseNextProposal = false + private var isProposalPaused = false + private var proposalContinuation: CheckedContinuation? + private var shouldPauseNextLinkedPeers = false + private var isLinkedPeersPaused = false + private var linkedPeersContinuation: CheckedContinuation? init(records: [PaymentRequestRecord]) { self.records = records } - func processPendingPrivateMessages() throws -> [OutboundPrivateCounterpartySendReport] { + func processPendingPrivateMessages() async throws -> [OutboundPrivateCounterpartySendReport] { processCallCount += 1 + if shouldPauseNextProcess { + shouldPauseNextProcess = false + isProcessPaused = true + await withCheckedContinuation { processContinuation = $0 } + isProcessPaused = false + } if processCancellationsRemaining > 0 { processCancellationsRemaining -= 1 throw CancellationError() } - guard processFailuresRemaining > 0 else { return [] } + guard processFailuresRemaining > 0 else { return processReports } processFailuresRemaining -= 1 throw PaymentRequestSdkMockError.process } @@ -538,7 +1232,7 @@ private actor PaymentRequestSdkMock: PaykitPaymentRequestSdkHandling { return [] } - func actionableReceivedPaymentRequests() async -> [PaymentRequestRecord] { + func paymentRequests() async -> [PaymentRequestRecord] { let snapshot = records guard shouldPauseNextPaymentRequestList else { return snapshot } @@ -549,6 +1243,59 @@ private actor PaymentRequestSdkMock: PaykitPaymentRequestSdkHandling { return snapshot } + func identityStatus() -> IdentityStatus? { + IdentityStatus(publicKey: activeIdentity, liveSessionAvailable: liveSessionAvailable) + } + + func linkedPeers() async -> [LinkedPeerRecord] { + if shouldPauseNextLinkedPeers { + shouldPauseNextLinkedPeers = false + isLinkedPeersPaused = true + await withCheckedContinuation { linkedPeersContinuation = $0 } + isLinkedPeersPaused = false + } + return peerRecords + } + + func paymentRequestReceiverPaths(publicKey: String) -> [String] { + receiverPathsByPublicKey[publicKey] ?? [] + } + + func proposePaymentRequest( + counterparty: String, + counterpartyReceiverPath: String, + terms: PaymentRequestTerms, + expectedIdentity: String + ) async throws -> PaymentRequestRecord { + if shouldPauseNextProposal { + shouldPauseNextProposal = false + isProposalPaused = true + await withCheckedContinuation { proposalContinuation = $0 } + isProposalPaused = false + } + guard PubkyPublicKeyFormat.matches(activeIdentity, expectedIdentity) else { + throw PaykitPaymentRequestError.requestUnavailable + } + guard var result = proposalResult else { + 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, + recurrence: terms.recurrence, + endpointIdentifiers: terms.acceptedPaymentEndpointIdentifiers, + metadata: terms.metadata.exportText() + )) + return result + } + func acceptPaymentRequest( counterparty: String, counterpartyReceiverPath: String, @@ -566,6 +1313,10 @@ private actor PaymentRequestSdkMock: PaykitPaymentRequestSdkHandling { counterpartyReceiverPath: counterpartyReceiverPath, id: paymentRequestId ) + if acceptFailuresAfterRemoval > 0 { + acceptFailuresAfterRemoval -= 1 + throw PaymentRequestSdkMockError.process + } acceptedRequests.append(PaymentRequestInvocation( counterparty: counterparty, counterpartyReceiverPath: counterpartyReceiverPath, @@ -574,10 +1325,46 @@ private actor PaymentRequestSdkMock: PaykitPaymentRequestSdkHandling { return record } + 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 { + rejectFailuresAfterRemoval -= 1 + throw PaymentRequestSdkMockError.process + } + rejectedRequests.append(PaymentRequestInvocation( + counterparty: counterparty, + counterpartyReceiverPath: counterpartyReceiverPath, + paymentRequestId: paymentRequestId + )) + return record + } + func failNextProcess() { processFailuresRemaining += 1 } + func pauseNextProcess() { + shouldPauseNextProcess = true + } + + func processIsPaused() -> Bool { + isProcessPaused + } + + func resumeProcess() { + processContinuation?.resume() + processContinuation = nil + } + func cancelNextProcess() { processCancellationsRemaining += 1 } @@ -599,6 +1386,14 @@ private actor PaymentRequestSdkMock: PaykitPaymentRequestSdkHandling { shouldPauseNextAccept = true } + func failNextAcceptAfterRemoval() { + acceptFailuresAfterRemoval += 1 + } + + func failNextRejectAfterRemoval() { + rejectFailuresAfterRemoval += 1 + } + func acceptIsPaused() -> Bool { isAcceptPaused } @@ -612,6 +1407,56 @@ private actor PaymentRequestSdkMock: PaykitPaymentRequestSdkHandling { self.records = records } + func configureRecipients( + peers: [LinkedPeerRecord], + receiverPathsByPublicKey: [String: [String]] + ) { + peerRecords = peers + self.receiverPathsByPublicKey = receiverPathsByPublicKey + } + + func setLiveSessionAvailable(_ value: Bool) { + liveSessionAvailable = value + } + + func setActiveIdentity(_ identity: String) { + activeIdentity = identity + } + + func setProposalResult(_ record: PaymentRequestRecord) { + proposalResult = record + } + + func setProcessReports(_ reports: [OutboundPrivateCounterpartySendReport]) { + processReports = reports + } + + func pauseNextProposal() { + shouldPauseNextProposal = true + } + + func proposalIsPaused() -> Bool { + isProposalPaused + } + + func resumeProposal() { + proposalContinuation?.resume() + proposalContinuation = nil + } + + func pauseNextLinkedPeers() { + shouldPauseNextLinkedPeers = true + } + + func linkedPeersIsPaused() -> Bool { + isLinkedPeersPaused + } + + func resumeLinkedPeers() { + linkedPeersContinuation?.resume() + linkedPeersContinuation = nil + } + func setReceiveError(_ error: PaymentRequestSdkMockError?) { receiveError = error } @@ -620,7 +1465,9 @@ private actor PaymentRequestSdkMock: PaykitPaymentRequestSdkHandling { PaymentRequestSdkSnapshot( processCallCount: processCallCount, receiveCallCount: receiveCallCount, - acceptedRequests: acceptedRequests + acceptedRequests: acceptedRequests, + rejectedRequests: rejectedRequests, + proposedRequests: proposedRequests ) } @@ -644,6 +1491,19 @@ private struct PaymentRequestSdkSnapshot { let processCallCount: Int let receiveCallCount: Int let acceptedRequests: [PaymentRequestInvocation] + let rejectedRequests: [PaymentRequestInvocation] + let proposedRequests: [ProposedPaymentRequestInvocation] +} + +private struct ProposedPaymentRequestInvocation { + let counterparty: String + let counterpartyReceiverPath: String + let amount: String + let asset: String + let expiresAt: String? + let recurrence: PaymentRequestRecurrence? + let endpointIdentifiers: [String] + let metadata: String } private struct PaymentRequestInvocation: Equatable { diff --git a/BitkitTests/PaykitSdkClientConfigTests.swift b/BitkitTests/PaykitSdkClientConfigTests.swift index 67dfb1a10..c1cca72c7 100644 --- a/BitkitTests/PaykitSdkClientConfigTests.swift +++ b/BitkitTests/PaykitSdkClientConfigTests.swift @@ -14,4 +14,18 @@ final class PaykitSdkClientConfigTests: XCTestCase { XCTAssertEqual(config.localTestnetHost, "192.0.2.1") } + + func testStoredSessionCanBeDeferredDuringSdkInitialization() { + let error = PaykitError.Identity(code: "identity_error", context: "import Pubky session from platform provider") + + XCTAssertTrue(PaykitSdkService.shouldDeferStaleSession(error: error, hasStoredSession: true)) + } + + func testMissingSessionOrUnrelatedIdentityFailureIsNotDeferred() { + let staleSession = PaykitError.Identity(code: "identity_error", context: "import Pubky session from platform provider") + let unrelatedError = PaykitError.Identity(code: "identity_error", context: "local Pubky secret key does not match session public key") + + XCTAssertFalse(PaykitSdkService.shouldDeferStaleSession(error: staleSession, hasStoredSession: false)) + XCTAssertFalse(PaykitSdkService.shouldDeferStaleSession(error: unrelatedError, hasStoredSession: true)) + } } diff --git a/changelog.d/next/676.added.md b/changelog.d/next/676.added.md new file mode 100644 index 000000000..6ed8f71b4 --- /dev/null +++ b/changelog.d/next/676.added.md @@ -0,0 +1 @@ +Incoming Paykit payment requests now stay discoverable until handled, and connected contacts can send new private requests from the invoice flow.