diff --git a/Bitkit.xcodeproj/project.pbxproj b/Bitkit.xcodeproj/project.pbxproj index a2cdb4832..f5f46c539 100644 --- a/Bitkit.xcodeproj/project.pbxproj +++ b/Bitkit.xcodeproj/project.pbxproj @@ -1175,7 +1175,7 @@ repositoryURL = "https://github.com/pubky/paykit-rs"; requirement = { kind = exactVersion; - version = "0.1.0-rc37"; + version = "0.1.0-rc39"; }; }; 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 35ff21b4d..ab1ee9a73 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" : "c43ccbd468adb6c3f841682dec18b2804e5aaa16", - "version" : "0.1.0-rc37" + "revision" : "2fa056570bd5f93c166e43cf16c5356f6407cbbd", + "version" : "0.1.0-rc39" } }, { diff --git a/Bitkit/AppScene.swift b/Bitkit/AppScene.swift index 77d498af5..3b4dfb372 100644 --- a/Bitkit/AppScene.swift +++ b/Bitkit/AppScene.swift @@ -4,6 +4,8 @@ import SwiftUI import UserNotifications struct AppScene: View { + private static let paykitPaymentRequestRefreshInterval: Duration = .seconds(30) + @Environment(\.scenePhase) var scenePhase @EnvironmentObject private var session: SessionManager @@ -35,6 +37,8 @@ struct AppScene: View { @State private var trezorViewModel: TrezorViewModel @State private var hwWalletManager: HwWalletManager @State private var calculatorInputManager = CalculatorInputManager() + @State private var paykitPaymentRequestManager = PaykitPaymentRequestManager() + @State private var isPresentingPaykitPaymentRequest = false @State private var hideSplash = false @State private var removeSplash = false @@ -133,6 +137,7 @@ struct AppScene: View { config in AppUpdateSheet(config: config) } .task(priority: .userInitiated, setupTask) + .task(id: scenePhase) { await pollIncomingPaykitPaymentRequests() } .onChange(of: currency.hasStaleData) { _, newValue in handleCurrencyStaleData(newValue) } .onChange(of: wallet.walletExists) { _, newValue in handleWalletExistsChange(newValue) } .onChange(of: wallet.nodeLifecycleState) { _, newValue in handleNodeLifecycleChange(newValue) } @@ -191,17 +196,20 @@ struct AppScene: View { .environment(trezorViewModel) .environment(hwWalletManager) .environment(calculatorInputManager) + .environment(paykitPaymentRequestManager) .onChange(of: pubkyProfile.authState, initial: true) { _, authState in if authState == .authenticated, let pk = pubkyProfile.publicKey { Task { try? await contactsManager.loadContacts(for: pk) await refreshPrivateOnlyPaykitReceiverMarker() + await refreshIncomingPaykitPaymentRequests() if !PaykitFeatureFlags.isUIEnabled, wallet.walletExists == true { await retryPendingPaykitEndpointRemoval() } } } else if authState == .idle { contactsManager.reset() + paykitPaymentRequestManager.clear() } } .onReceive(contactsManager.$contacts) { contacts in @@ -212,8 +220,24 @@ struct AppScene: View { let publicKeys = contacts.map(\.publicKey) Task { await PrivatePaykitService.shared.prepareSavedContacts(publicKeys, wallet: wallet) + await refreshIncomingPaykitPaymentRequests() } } + .onReceive(sheets.$activeSheetConfiguration) { configuration in + guard configuration == nil else { return } + 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 }), + sheets.activeSheetConfiguration?.id == .send + else { return } + + app.resetSendState() + wallet.resetSendState(speed: settings.defaultTransactionSpeed) + sheets.hideSheetIfActive(.send, reason: "Incoming payment request expired") + } .onChange(of: navigation.currentRoute) { oldRoute, newRoute in guard shouldDiscardPendingImport(currentRoute: oldRoute, destination: newRoute) else { return @@ -639,6 +663,7 @@ struct AppScene: View { contactsManager.contacts.map(\.publicKey), wallet: wallet ) + await refreshIncomingPaykitPaymentRequests() } } else { if case .errorStarting = state { @@ -680,6 +705,7 @@ struct AppScene: View { savedPublicKeys: contactPublicKeys, wallet: wallet ) + await refreshIncomingPaykitPaymentRequests() } } } @@ -699,6 +725,100 @@ struct AppScene: View { } } + private func refreshIncomingPaykitPaymentRequests() async { + guard PaykitFeatureFlags.isUIEnabled, + wallet.walletExists == true, + pubkyProfile.authState == .authenticated + else { return } + + await paykitPaymentRequestManager.refresh() + await presentNextIncomingPaykitPaymentRequest() + } + + private func pollIncomingPaykitPaymentRequests() async { + guard scenePhase == .active else { return } + + while !Task.isCancelled { + do { + try await Task.sleep(for: Self.paykitPaymentRequestRefreshInterval) + } catch { + return + } + await refreshIncomingPaykitPaymentRequests() + } + } + + private func presentNextIncomingPaykitPaymentRequest() async { + guard !isPresentingPaykitPaymentRequest, + sheets.activeSheetConfiguration == nil, + app.contactPaymentContext == nil + else { return } + + let requests = paykitPaymentRequestManager.requestsForPresentation() + guard !requests.isEmpty else { return } + + isPresentingPaykitPaymentRequest = true + defer { isPresentingPaykitPaymentRequest = false } + + for request in requests { + do { + let result = try await PrivatePaykitService.shared.beginPaymentRequest(request) + guard sheets.activeSheetConfiguration == nil, app.contactPaymentContext == nil else { return } + guard case let .opened(paymentTarget, privatePaymentContext) = result else { continue } + + let contactPaymentContext = ContactPaymentContext( + publicKey: request.counterparty, + privatePaymentContext: privatePaymentContext, + incomingPaymentRequest: request + ) + guard app.claimContactPaymentContext(contactPaymentContext) else { return } + + do { + try await app.handleScannedData( + paymentTarget, + claimedContactPaymentContext: contactPaymentContext + ) + guard app.ownsContactPaymentContext(contactPaymentContext), + sheets.activeSheetConfiguration == nil + else { return } + guard PaymentNavigationHelper.appropriateSendRoute(app: app, currency: currency, settings: settings) != nil else { + app.resetSendState() + wallet.resetSendState(speed: settings.defaultTransactionSpeed) + continue + } + + guard paykitPaymentRequestManager.markPresentedIfPending(request) else { + app.resetSendState() + wallet.resetSendState(speed: settings.defaultTransactionSpeed) + continue + } + } catch is CancellationError { + if app.ownsContactPaymentContext(contactPaymentContext) { + app.resetSendState() + wallet.resetSendState(speed: settings.defaultTransactionSpeed) + } + return + } catch { + guard app.ownsContactPaymentContext(contactPaymentContext) else { return } + Logger.warn("Failed to present incoming Paykit payment request: \(error)", context: "AppScene") + app.resetSendState() + wallet.resetSendState(speed: settings.defaultTransactionSpeed) + continue + } + + wallet.sendAmountSats = request.amountSats + + let route: SendRoute = app.lnurlPayData == nil ? .confirm : .lnurlPayConfirm + sheets.showSheet(.send, data: SendConfig(view: route)) + return + } catch is CancellationError { + return + } catch { + Logger.warn("Failed to present incoming Paykit payment request: \(error)", context: "AppScene") + } + } + } + private func retryPendingPaykitEndpointRemoval() async { if PublicPaykitService.isCleanupPending { do { @@ -753,6 +873,7 @@ struct AppScene: View { // to display balances (MoneyText returns "0" if rates are nil) Task { await currency.refresh() + await refreshIncomingPaykitPaymentRequests() } // Restart node if necessary (e.g. create/restore was skipped due to offline) diff --git a/Bitkit/Resources/Localization/ar.lproj/Localizable.strings b/Bitkit/Resources/Localization/ar.lproj/Localizable.strings index e0ba8110e..58349945b 100644 --- a/Bitkit/Resources/Localization/ar.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/ar.lproj/Localizable.strings @@ -58,3 +58,4 @@ "widgets__weather__condition__poor__description" = "إذا لم تكن في عجلة للتعامل، قد يكون من الأفضل الانتظار قليلاً."; "widgets__weather__current_fee" = "متوسط الرسوم الحالي"; "widgets__weather__next_block" = "تضمين الكتلة التالية"; +"wallet__payment_request" = "طلب دفع"; diff --git a/Bitkit/Resources/Localization/ca.lproj/Localizable.strings b/Bitkit/Resources/Localization/ca.lproj/Localizable.strings index 9ec216d6f..80ea17aa7 100644 --- a/Bitkit/Resources/Localization/ca.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/ca.lproj/Localizable.strings @@ -961,6 +961,7 @@ "wallet__boost_fee_recomended" = "La teva transacció pot liquidar-se més ràpid si incloues una comissió de xarxa addicional. Aquí tens una recomanació:"; "wallet__boost_swipe" = "Llisca per impulsar"; "wallet__payment_received" = "Rebut Bitcoin"; +"wallet__payment_request" = "Sol·licitud de pagament"; "wallet__instant_payment_received" = "Rebut Bitcoin instantani"; "wallet__filter_title" = "Selecciona rang"; "wallet__filter_clear" = "Neteja"; diff --git a/Bitkit/Resources/Localization/cs.lproj/Localizable.strings b/Bitkit/Resources/Localization/cs.lproj/Localizable.strings index d80cbbeee..e39522383 100644 --- a/Bitkit/Resources/Localization/cs.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/cs.lproj/Localizable.strings @@ -1101,6 +1101,7 @@ "wallet__boost_recomended_button" = "Použít navrhovaný poplatek"; "wallet__boost_swipe" = "Přejetím posilte"; "wallet__payment_received" = "bitcoin přijatý"; +"wallet__payment_request" = "Žádost o platbu"; "wallet__instant_payment_received" = "Přijatý okamžitý bitcoin"; "wallet__error_create_tx" = "Vytvoření transakce se nezdařilo"; "wallet__error_create_tx_msg" = "Došlo k chybě. Zkuste to prosím znovu {raw}"; diff --git a/Bitkit/Resources/Localization/de.lproj/Localizable.strings b/Bitkit/Resources/Localization/de.lproj/Localizable.strings index 9d4d7c1f1..12432122b 100644 --- a/Bitkit/Resources/Localization/de.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/de.lproj/Localizable.strings @@ -1098,6 +1098,7 @@ "wallet__boost_recomended_button" = "Empfohlene Gebühr verwenden"; "wallet__boost_swipe" = "Zum Beschleunigen wischen"; "wallet__payment_received" = "Bitcoin empfangen"; +"wallet__payment_request" = "Zahlungsanforderung"; "wallet__instant_payment_received" = "Sofortiger Bitcoin empfangen"; "wallet__error_create_tx" = "Transaktionserstellung fehlgeschlagen"; "wallet__error_create_tx_msg" = "Ein Fehler ist aufgetreten. Bitte versuche es erneut {raw}"; diff --git a/Bitkit/Resources/Localization/el.lproj/Localizable.strings b/Bitkit/Resources/Localization/el.lproj/Localizable.strings index 8cf90bbc2..e59f33aa7 100644 --- a/Bitkit/Resources/Localization/el.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/el.lproj/Localizable.strings @@ -823,6 +823,7 @@ "wallet__boost_fee_recomended" = "Η συναλλαγή σας μπορεί να ολοκληρωθεί πιο γρήγορα αν συμπεριλάβετε ένα επιπλέον τέλος δικτύου. Ακολουθεί μια σύσταση:"; "wallet__boost_swipe" = "Σύρετε για Ενίσχυση"; "wallet__payment_received" = "Bitcoin Λήφθηκε"; +"wallet__payment_request" = "Αίτημα πληρωμής"; "wallet__instant_payment_received" = "Άμεσο Bitcoin Λήφθηκε"; "wallet__filter_title" = "Επιλογή Εύρους"; "wallet__filter_clear" = "Εκκαθάριση"; diff --git a/Bitkit/Resources/Localization/en.lproj/Localizable.strings b/Bitkit/Resources/Localization/en.lproj/Localizable.strings index 7ba1e28c6..5d9281591 100644 --- a/Bitkit/Resources/Localization/en.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/en.lproj/Localizable.strings @@ -1428,6 +1428,7 @@ "wallet__boost_recomended_button" = "Use Suggested Fee"; "wallet__boost_swipe" = "Swipe To Boost"; "wallet__payment_received" = "Received Bitcoin"; +"wallet__payment_request" = "Payment Request"; "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/Resources/Localization/es-419.lproj/Localizable.strings b/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings index 5492ac7ed..99068d03a 100644 --- a/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings @@ -1112,6 +1112,7 @@ "wallet__boost_recomended_button" = "Usar Tasa sugerida"; "wallet__boost_swipe" = "Deslizar para impulsar"; "wallet__payment_received" = "Bitcoin recibido"; +"wallet__payment_request" = "Solicitud de pago"; "wallet__instant_payment_received" = "Bitcoin instantáneo recibido"; "wallet__error_create_tx" = "Fallo en la creación de la transacción"; "wallet__error_create_tx_msg" = "Se ha producido un error. Vuelva a intentarlo {raw}"; diff --git a/Bitkit/Resources/Localization/es.lproj/Localizable.strings b/Bitkit/Resources/Localization/es.lproj/Localizable.strings index 0b12c0b57..e7b937b0a 100644 --- a/Bitkit/Resources/Localization/es.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/es.lproj/Localizable.strings @@ -987,6 +987,7 @@ "wallet__boost_recomended_button" = "Usar Comisión Sugerida"; "wallet__boost_swipe" = "Deslizar para impulsar"; "wallet__payment_received" = "Bitcoin recibido"; +"wallet__payment_request" = "Solicitud de pago"; "wallet__instant_payment_received" = "Bitcoin Instantáneo Recibido"; "wallet__error_create_tx_msg" = "Ocurrió un error. Por favor, intente de nuevo {raw}"; "wallet__filter_title" = "Seleccione Rango"; diff --git a/Bitkit/Resources/Localization/fr.lproj/Localizable.strings b/Bitkit/Resources/Localization/fr.lproj/Localizable.strings index 87608b030..c4505329f 100644 --- a/Bitkit/Resources/Localization/fr.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/fr.lproj/Localizable.strings @@ -1126,6 +1126,7 @@ "wallet__boost_recomended_button" = "Utilise les frais suggérés"; "wallet__boost_swipe" = "Balayer pour booster"; "wallet__payment_received" = "Bitcoin reçus"; +"wallet__payment_request" = "Demande de paiement"; "wallet__instant_payment_received" = "Réception de bitcoins instantanés"; "wallet__error_create_tx" = "Échec de la création d\'une transaction"; "wallet__error_create_tx_msg" = "Une erreur s\'est produite. Veuillez réessayer {raw}"; diff --git a/Bitkit/Resources/Localization/it.lproj/Localizable.strings b/Bitkit/Resources/Localization/it.lproj/Localizable.strings index 55d18ed64..c8e5c4dbf 100644 --- a/Bitkit/Resources/Localization/it.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/it.lproj/Localizable.strings @@ -1083,6 +1083,7 @@ "wallet__boost_recomended_button" = "Utilizza la tariffa suggerita"; "wallet__boost_swipe" = "Scorri per Potenziare"; "wallet__payment_received" = "Bitcoin ricevuti"; +"wallet__payment_request" = "Richiesta di pagamento"; "wallet__instant_payment_received" = "Ricevuti Bitcoin istantanei"; "wallet__error_create_tx" = "Creazione della transazione non riuscita"; "wallet__error_create_tx_msg" = "Si è verificato un errore. Per favore riprova {raw}"; diff --git a/Bitkit/Resources/Localization/nl.lproj/Localizable.strings b/Bitkit/Resources/Localization/nl.lproj/Localizable.strings index 0fd72a586..f3f7f44b7 100644 --- a/Bitkit/Resources/Localization/nl.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/nl.lproj/Localizable.strings @@ -1121,6 +1121,7 @@ "wallet__boost_recomended_button" = "Voorgestelde Vergoeding"; "wallet__boost_swipe" = "Veeg Om Te Boosten"; "wallet__payment_received" = "Bitcoin Ontvangen"; +"wallet__payment_request" = "Betaalverzoek"; "wallet__instant_payment_received" = "Direct Bitcoin Ontvangen"; "wallet__error_create_tx" = "Aanmaak Transactie Mislukt"; "wallet__error_create_tx_msg" = "Er is een fout opgetreden. Probeer opnieuw {raw}"; diff --git a/Bitkit/Resources/Localization/pl.lproj/Localizable.strings b/Bitkit/Resources/Localization/pl.lproj/Localizable.strings index 874d63449..1b7ad6e50 100644 --- a/Bitkit/Resources/Localization/pl.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/pl.lproj/Localizable.strings @@ -1127,6 +1127,7 @@ "wallet__boost_recomended_button" = "Zastosuj sugerowaną opłatę"; "wallet__boost_swipe" = "Przesuń, aby przyspieszyć"; "wallet__payment_received" = "Otrzymano Bitcoinów"; +"wallet__payment_request" = "Żądanie płatności"; "wallet__instant_payment_received" = "Otrzymano bitcoin natychmiastowo"; "wallet__error_create_tx" = "Tworzenie transakcji nie powiodło się"; "wallet__error_create_tx_msg" = "Wystąpił błąd. Proszę spróbować ponownie {raw}"; diff --git a/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings b/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings index 8548e12e1..6cbfe9e7f 100644 --- a/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings @@ -1128,6 +1128,7 @@ "wallet__boost_recomended_button" = "Usar Taxa Sugerida"; "wallet__boost_swipe" = "Arraste para acelerar"; "wallet__payment_received" = "Bitcoin Recebido"; +"wallet__payment_request" = "Solicitação de pagamento"; "wallet__instant_payment_received" = "Bitcoin Recebido Instantaneamente"; "wallet__error_create_tx" = "Falha na Criação da Transação"; "wallet__error_create_tx_msg" = "Ocorreu um erro. Por favor, tente novamente {raw}"; diff --git a/Bitkit/Resources/Localization/pt.lproj/Localizable.strings b/Bitkit/Resources/Localization/pt.lproj/Localizable.strings index 80c2ab138..ce44c8d3b 100644 --- a/Bitkit/Resources/Localization/pt.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/pt.lproj/Localizable.strings @@ -36,3 +36,4 @@ "widgets__weather__condition__poor__description" = "Se você não estiver com pressa para fazer uma transação, talvez seja melhor esperar um pouco."; "widgets__weather__current_fee" = "Tarifa média atual"; "widgets__weather__next_block" = "Inclusão no próximo bloco"; +"wallet__payment_request" = "Pedido de pagamento"; diff --git a/Bitkit/Resources/Localization/ru.lproj/Localizable.strings b/Bitkit/Resources/Localization/ru.lproj/Localizable.strings index 42005cbf1..e57ed92e4 100644 --- a/Bitkit/Resources/Localization/ru.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/ru.lproj/Localizable.strings @@ -1117,6 +1117,7 @@ "wallet__boost_recomended_button" = "Использовать рекомендованную комиссию"; "wallet__boost_swipe" = "Ускорить"; "wallet__payment_received" = "Получен Биткоин"; +"wallet__payment_request" = "Запрос на оплату"; "wallet__instant_payment_received" = "Получен Мгновенный Биткоин"; "wallet__error_create_tx" = "Ошибка создания транзакции"; "wallet__error_create_tx_msg" = "Произошла ошибка. Пожалуйста, попробуйте снова {raw}"; diff --git a/Bitkit/Services/PaykitPaymentRequestService.swift b/Bitkit/Services/PaykitPaymentRequestService.swift new file mode 100644 index 000000000..27e992419 --- /dev/null +++ b/Bitkit/Services/PaykitPaymentRequestService.swift @@ -0,0 +1,367 @@ +import Foundation +import Paykit + +struct PaykitPaymentRequest: Identifiable, Equatable { + struct ID: Hashable { + let paymentRequestId: String + let counterparty: String + let counterpartyReceiverPath: String + } + + let paymentRequestId: String + let counterparty: String + let counterpartyReceiverPath: String + let amountValue: String + let amountSats: UInt64 + let paymentReference: String + let expiresAt: Date? + let acceptedPaymentEndpointIdentifiers: [String] + let metadata: String + + var id: ID { + ID( + paymentRequestId: paymentRequestId, + counterparty: counterparty, + counterpartyReceiverPath: counterpartyReceiverPath + ) + } + + init?(record: Paykit.PaymentRequestRecord, now: Date) { + guard record.localRole == .payer, + record.state == .proposed, + let terms = record.terms, + terms.recurrence == nil, + terms.amount.asset == "btc", + let amountSats = Self.sats(fromBitcoinAmount: terms.amount.value), + amountSats <= UInt64.max / 1000 + else { return nil } + + let acceptedPaymentEndpointIdentifiers = Self.supportedEndpointIdentifiers( + terms.acceptedPaymentEndpointIdentifiers + ) + guard !acceptedPaymentEndpointIdentifiers.isEmpty else { return nil } + + let expiresAt: Date? + if let proposalExpiresAt = terms.proposalExpiresAt { + guard let parsedExpiration = Self.parseDate(proposalExpiresAt), parsedExpiration > now else { + return nil + } + expiresAt = parsedExpiration + } else { + expiresAt = nil + } + + paymentRequestId = record.paymentRequestId + counterparty = record.counterparty + counterpartyReceiverPath = record.counterpartyReceiverPath + amountValue = terms.amount.value + self.amountSats = amountSats + paymentReference = terms.paymentReference.exportText() + self.expiresAt = expiresAt + self.acceptedPaymentEndpointIdentifiers = acceptedPaymentEndpointIdentifiers + metadata = terms.metadata.exportText() + } + + func isExpired(at date: Date) -> Bool { + expiresAt.map { $0 <= date } ?? false + } + + func acceptsLightningInvoiceAmount(milliSatoshis: UInt64?) -> Bool { + guard let milliSatoshis else { return true } + let (requestedMilliSatoshis, overflow) = amountSats.multipliedReportingOverflow(by: 1000) + return !overflow && milliSatoshis == requestedMilliSatoshis + } + + func acceptsPaymentAmount(_ amountSats: UInt64) -> Bool { + amountSats == self.amountSats + } + + private static func supportedEndpointIdentifiers(_ identifiers: [String]) -> [String] { + var seen = Set() + return identifiers.filter { identifier in + guard seen.insert(identifier).inserted, + let methodId = PublicPaykitService.MethodId(rawValue: identifier) + else { return false } + + if let network = methodId.onchainNetwork { + return network == Env.network + } + + return methodId == .bitcoinLightningBolt11 || methodId == .bitcoinLightningLnurl + } + } + + private static func sats(fromBitcoinAmount amount: String) -> UInt64? { + let components = amount.split(separator: ".", maxSplits: 1, omittingEmptySubsequences: false) + let digits = components.joined() + guard digits.utf8.allSatisfy({ $0 >= 48 && $0 <= 57 }), + !digits.isEmpty + else { return nil } + + let wholeBtc = components[0].isEmpty ? 0 : UInt64(components[0]) + guard let wholeBtc else { return nil } + + var fraction = components.count == 2 ? String(components[1]) : "" + while fraction.last == "0" { + fraction.removeLast() + } + guard fraction.count <= 8 else { return nil } + + let fractionSats = UInt64(fraction.padding(toLength: 8, withPad: "0", startingAt: 0)) ?? 0 + let (wholeSats, wholeOverflow) = wholeBtc.multipliedReportingOverflow(by: 100_000_000) + let (amountSats, totalOverflow) = wholeSats.addingReportingOverflow(fractionSats) + guard !wholeOverflow, !totalOverflow, amountSats > 0 else { return nil } + return amountSats + } + + private static func parseDate(_ timestamp: String) -> Date? { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + if let date = formatter.date(from: timestamp) { + return date + } + formatter.formatOptions = [.withInternetDateTime] + return formatter.date(from: timestamp) + } +} + +enum PaykitPaymentRequestError: Error, Equatable { + case requestUnavailable + case requestExpired + case operationInProgress +} + +protocol PaykitPaymentRequestSdkHandling: Sendable { + func processPendingPrivateMessages() async throws -> [Paykit.OutboundPrivateCounterpartySendReport] + func receivePrivateMessagesFromLinkedPeers() async throws -> [Paykit.PrivateStreamCounterpartyIntakeReport] + func actionableReceivedPaymentRequests() async throws -> [Paykit.PaymentRequestRecord] + func acceptPaymentRequest( + counterparty: String, + counterpartyReceiverPath: String, + paymentRequestId: String + ) async throws -> Paykit.PaymentRequestRecord +} + +extension PaykitSdkService: PaykitPaymentRequestSdkHandling {} + +struct PaykitPaymentRequestService { + private let sdk: any PaykitPaymentRequestSdkHandling + private let now: @Sendable () -> Date + private let logWarning: @Sendable (String) -> Void + + init( + sdk: any PaykitPaymentRequestSdkHandling = PaykitSdkService.shared, + now: @escaping @Sendable () -> Date = { Date() }, + logWarning: @escaping @Sendable (String) -> Void = { + Logger.warn($0, context: "PaykitPaymentRequest") + } + ) { + self.sdk = sdk + self.now = now + self.logWarning = logWarning + } + + func synchronize() async throws -> [PaykitPaymentRequest] { + try await processPendingMessages() + let intakeReports = try await sdk.receivePrivateMessagesFromLinkedPeers() + logIntakeFailures(intakeReports) + let synchronizationDate = now() + return try await sdk.actionableReceivedPaymentRequests().compactMap { + PaykitPaymentRequest(record: $0, now: synchronizationDate) + } + } + + func accept(_ request: PaykitPaymentRequest) async throws { + guard !request.isExpired(at: now()) else { + throw PaykitPaymentRequestError.requestExpired + } + + _ = try await sdk.acceptPaymentRequest( + counterparty: request.counterparty, + counterpartyReceiverPath: request.counterpartyReceiverPath, + paymentRequestId: request.paymentRequestId + ) + try? await processPendingMessages() + } + + private func processPendingMessages() async throws { + 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())" + ) + } + } catch is CancellationError { + throw CancellationError() + } catch { + logWarning("Failed to deliver pending Paykit private messages: \(error)") + } + } + + private func logIntakeFailures(_ reports: [Paykit.PrivateStreamCounterpartyIntakeReport]) { + for report in reports { + guard let error = report.error else { continue } + logWarning( + "Failed to receive Paykit private messages from \(PubkyPublicKeyFormat.redacted(report.counterparty)): \(error.redactedContext())" + ) + } + } +} + +@Observable +@MainActor +final class PaykitPaymentRequestManager { + private(set) var pendingRequests: [PaykitPaymentRequest] = [] + + private let service: PaykitPaymentRequestService + private let now: @Sendable () -> Date + private let logWarning: @Sendable (String) -> Void + private var processingRequestIds: Set = [] + private var presentedRequestIds: Set = [] + private var refreshTask: Task? + private var expirationTask: Task? + private var refreshGeneration = 0 + private var stateGeneration = 0 + + init( + service: PaykitPaymentRequestService? = nil, + now: @escaping @Sendable () -> Date = { Date() }, + logWarning: @escaping @Sendable (String) -> Void = { + Logger.warn($0, context: "PaykitPaymentRequest") + } + ) { + self.service = service ?? PaykitPaymentRequestService(now: now, logWarning: logWarning) + self.now = now + self.logWarning = logWarning + } + + func refresh() async { + if let refreshTask { + await refreshTask.value + return + } + + refreshGeneration += 1 + let generation = refreshGeneration + let task = Task { [weak self] in + guard let self else { return } + await performRefresh(generation: generation) + } + refreshTask = task + await task.value + + guard generation == refreshGeneration else { return } + refreshTask = nil + } + + func accept(_ request: PaykitPaymentRequest) async throws { + try await perform(request) { + try await service.accept($0) + } + } + + func clear() { + stateGeneration += 1 + invalidateRefresh() + expirationTask?.cancel() + expirationTask = nil + pendingRequests = [] + processingRequestIds = [] + presentedRequestIds = [] + } + + func requestsForPresentation() -> [PaykitPaymentRequest] { + pendingRequests.filter { !presentedRequestIds.contains($0.id) } + } + + func markPresentedIfPending(_ request: PaykitPaymentRequest) -> Bool { + discardExpiredRequests() + guard pendingRequests.contains(where: { $0.id == request.id }) else { return false } + presentedRequestIds.insert(request.id) + return true + } + + private func performRefresh(generation: Int) async { + do { + let requests = try await service.synchronize() + guard generation == refreshGeneration else { return } + pendingRequests = requests + presentedRequestIds.formIntersection(requests.map(\.id)) + discardExpiredRequests() + } catch is CancellationError { + return + } catch { + guard generation == refreshGeneration else { return } + discardExpiredRequests() + logWarning("Failed to refresh incoming Paykit payment requests: \(error)") + } + } + + private func perform( + _ request: PaykitPaymentRequest, + operation: (PaykitPaymentRequest) async throws -> Void + ) async throws { + guard !request.isExpired(at: now()) else { + discardExpiredRequests() + throw PaykitPaymentRequestError.requestExpired + } + guard pendingRequests.contains(where: { $0.id == request.id }) else { + throw PaykitPaymentRequestError.requestUnavailable + } + let actionGeneration = stateGeneration + guard processingRequestIds.insert(request.id).inserted else { + throw PaykitPaymentRequestError.operationInProgress + } + defer { + if actionGeneration == stateGeneration { + processingRequestIds.remove(request.id) + } + } + + do { + try await operation(request) + guard actionGeneration == stateGeneration else { return } + invalidateRefresh() + pendingRequests.removeAll { $0.id == request.id } + discardExpiredRequests() + } catch is CancellationError { + throw CancellationError() + } catch { + guard actionGeneration == stateGeneration else { throw error } + invalidateRefresh() + await refresh() + throw error + } + } + + private func invalidateRefresh() { + refreshGeneration += 1 + refreshTask?.cancel() + refreshTask = nil + } + + private func discardExpiredRequests() { + pendingRequests.removeAll { $0.isExpired(at: now()) } + presentedRequestIds.formIntersection(pendingRequests.map(\.id)) + scheduleExpiration() + } + + private func scheduleExpiration() { + expirationTask?.cancel() + expirationTask = nil + + guard let nextExpiration = pendingRequests.compactMap(\.expiresAt).min() else { return } + let delay = max(0, nextExpiration.timeIntervalSince(now())) + expirationTask = Task { [weak self] in + do { + try await Task.sleep(for: .seconds(delay)) + } catch { + return + } + guard !Task.isCancelled else { return } + self?.discardExpiredRequests() + } + } +} diff --git a/Bitkit/Services/PrivatePaykitService+Backup.swift b/Bitkit/Services/PrivatePaykitService+Backup.swift index 3aefb0e78..9235b6c4f 100644 --- a/Bitkit/Services/PrivatePaykitService+Backup.swift +++ b/Bitkit/Services/PrivatePaykitService+Backup.swift @@ -7,7 +7,19 @@ extension PrivatePaykitService { guard await PubkyService.currentPublicKey() != nil else { return nil } - return try await PaykitSdkService.shared.exportBackupState() + let backup = try await Backup( + sdkState: PaykitSdkService.shared.exportBackupState(), + consumedPrivatePaymentListVersions: state.contacts.compactMapValues { contactState in + contactState.consumedPrivatePaymentListVersionsByReceiverPath.isEmpty + ? nil + : contactState.consumedPrivatePaymentListVersionsByReceiverPath + } + ) + let data = try JSONEncoder().encode(backup) + guard let encoded = String(data: data, encoding: .utf8) else { + throw CocoaError(.fileWriteInapplicableStringEncoding) + } + return encoded } func restoreBackup(_ backup: String?) async throws { @@ -18,7 +30,11 @@ extension PrivatePaykitService { state = PrivatePaykitState(contacts: [:]) knownSavedContactKeys.removeAll() if let backup { - try await PaykitSdkService.shared.restoreBackupState(backup) + let decoded = try JSONDecoder().decode(Backup.self, from: Data(backup.utf8)) + try await PaykitSdkService.shared.restoreBackupState(decoded.sdkState) + for (publicKey, versions) in decoded.consumedPrivatePaymentListVersions { + state.contacts[publicKey, default: ContactState()].consumedPrivatePaymentListVersionsByReceiverPath = versions + } } else { await PaykitSdkService.shared.clearState() } diff --git a/Bitkit/Services/PrivatePaykitService+Contacts.swift b/Bitkit/Services/PrivatePaykitService+Contacts.swift index 4107ff85e..059ed35a0 100644 --- a/Bitkit/Services/PrivatePaykitService+Contacts.swift +++ b/Bitkit/Services/PrivatePaykitService+Contacts.swift @@ -177,7 +177,10 @@ extension PrivatePaykitService { let savedKeys = Set(normalizedSavedContactKeys(publicKeys)) knownSavedContactKeys = savedKeys - let staleKeys = Set(state.contacts.keys).subtracting(savedKeys) + let staleKeys: Set = Set(state.contacts.compactMap { publicKey, contactState in + guard !savedKeys.contains(publicKey), contactState.hasContactOwnedCacheState else { return nil } + return publicKey + }) let cleanupKeys = staleKeys.union(Self.pendingDeletedContactCleanupKeys().subtracting(savedKeys)) guard !cleanupKeys.isEmpty else { return } @@ -485,11 +488,11 @@ extension PrivatePaykitService { } } - func schedulePrivatePaymentRecovery(for publicKey: String) { + func schedulePrivatePaymentRecovery(for publicKey: String, receiverPath: String) { guard let publicKey = PubkyPublicKeyFormat.normalized(publicKey) else { return } schedulePendingPrivateMessageDrainRetries( reason: "payment recovery", - retryKeys: [PrivateMessageDrainRetryKey(publicKey: publicKey, receiverPath: PaykitReceiverPath.wallet)] + retryKeys: [PrivateMessageDrainRetryKey(publicKey: publicKey, receiverPath: receiverPath)] ) } diff --git a/Bitkit/Services/PrivatePaykitService+Endpoints.swift b/Bitkit/Services/PrivatePaykitService+Endpoints.swift index 95df5d34a..0b618442d 100644 --- a/Bitkit/Services/PrivatePaykitService+Endpoints.swift +++ b/Bitkit/Services/PrivatePaykitService+Endpoints.swift @@ -108,7 +108,7 @@ extension PrivatePaykitService { from endpoints: [PublicPaykitService.Endpoint], publicKey: String, receiverPath: String - ) -> [PaymentEndpointReservationInput] { + ) -> [PrivatePaymentEndpointReservationInput] { endpoints.map { endpoint in let paymentHash = localInvoice(for: publicKey, receiverPath: receiverPath)?.takeIfBolt11(endpoint)?.paymentHash let attribution = [ @@ -117,7 +117,7 @@ extension PrivatePaykitService { "receiver_path": receiverPath, ].merging(paymentHash.map { ["payment_hash": $0] } ?? [:]) { current, _ in current } - return PaymentEndpointReservationInput( + return PrivatePaymentEndpointReservationInput( reservationId: reservationId(for: endpoint, publicKey: publicKey, receiverPath: receiverPath), identifier: endpoint.methodId.rawValue, payload: endpoint.rawPayload, diff --git a/Bitkit/Services/PrivatePaykitService+Errors.swift b/Bitkit/Services/PrivatePaykitService+Errors.swift index cea9e05dc..c600f9f49 100644 --- a/Bitkit/Services/PrivatePaykitService+Errors.swift +++ b/Bitkit/Services/PrivatePaykitService+Errors.swift @@ -2,13 +2,19 @@ import Foundation import LDKNode enum PrivatePaykitError: LocalizedError { + case invalidPublicKey case privateUnavailable + case paymentListAlreadyConsumed case routeHintsUnavailable var errorDescription: String? { switch self { + case .invalidPublicKey: + "The contact public key is invalid." case .privateUnavailable: "Private Paykit is not available." + case .paymentListAlreadyConsumed: + "Private payment details are no longer available." case .routeHintsUnavailable: "A reachable private Lightning endpoint is not available yet." } diff --git a/Bitkit/Services/PrivatePaykitService+Models.swift b/Bitkit/Services/PrivatePaykitService+Models.swift index c284fb59a..a251ed266 100644 --- a/Bitkit/Services/PrivatePaykitService+Models.swift +++ b/Bitkit/Services/PrivatePaykitService+Models.swift @@ -9,11 +9,20 @@ extension PrivatePaykitService { struct ContactState: Codable { var cachedResolvedEndpoints: [StoredPaymentEntry] = [] + var consumedPrivatePaymentListVersionsByReceiverPath: [String: UInt64] = [:] var localInvoicesByReceiverPath: [String: StoredInvoice] = [:] var receivedInvoicePaymentHashes: [String] = [] var publishedPrivatePaymentReceiverPaths: [String] = [] var hasCacheState: Bool { + !publishedPrivatePaymentReceiverPaths.isEmpty || + !cachedResolvedEndpoints.isEmpty || + !consumedPrivatePaymentListVersionsByReceiverPath.isEmpty || + !localInvoicesByReceiverPath.isEmpty || + !receivedInvoicePaymentHashes.isEmpty + } + + var hasContactOwnedCacheState: Bool { !publishedPrivatePaymentReceiverPaths.isEmpty || !cachedResolvedEndpoints.isEmpty || !localInvoicesByReceiverPath.isEmpty || @@ -41,4 +50,9 @@ extension PrivatePaykitService { var paymentHash: String var expiresAt: Double } + + struct Backup: Codable { + let sdkState: String + let consumedPrivatePaymentListVersions: [String: [String: UInt64]] + } } diff --git a/Bitkit/Services/PrivatePaykitService+Payments.swift b/Bitkit/Services/PrivatePaykitService+Payments.swift index 486b81f66..2b87deb2c 100644 --- a/Bitkit/Services/PrivatePaykitService+Payments.swift +++ b/Bitkit/Services/PrivatePaykitService+Payments.swift @@ -18,48 +18,96 @@ extension PrivatePaykitService { return try await PublicPaykitService.beginPayment(to: publicKey) } - return try await beginPrivateOrPublicPayment(to: normalizedKey, wallet: wallet) - } - - private func beginPrivateOrPublicPayment(to publicKey: String, wallet: WalletViewModel) async throws -> PublicPaykitPaymentLaunchResult { - let hasLiveSession = await hasLiveSessionForCurrentProfile() - - if hasLiveSession, await canPublishPrivateEndpoints(wallet: wallet) { + if await canPublishPrivateEndpoints(wallet: wallet) { _ = await refreshSavedContactEndpointsReturningError( - for: [publicKey], + for: [normalizedKey], wallet: wallet, forceRefreshLightning: false, requireImmediatePublication: false ) } + var result = try await beginContactPayment(to: normalizedKey, receiverPath: PaykitReceiverPath.wallet) + for delay in Self.privatePaymentResolutionRetryDelays { + guard case .waitingForUpdatedPaymentList = result else { return result } + try await Task.sleep(nanoseconds: delay) + result = try await beginContactPayment(to: normalizedKey, receiverPath: PaykitReceiverPath.wallet) + } + return result + } + + func beginPaymentRequest(_ request: PaykitPaymentRequest) async throws -> PublicPaykitPaymentLaunchResult { + guard !request.isExpired(at: Date()) else { + throw PaykitPaymentRequestError.requestExpired + } + guard let publicKey = PubkyPublicKeyFormat.normalized(request.counterparty) else { + throw PrivatePaykitError.invalidPublicKey + } + + return try await beginContactPayment( + to: publicKey, + receiverPath: request.counterpartyReceiverPath, + paymentRequest: request + ) + } + + private func beginContactPayment( + to publicKey: String, + receiverPath: String, + paymentRequest: PaykitPaymentRequest? = nil + ) async throws -> PublicPaykitPaymentLaunchResult { + let consumedVersion = state.contacts[publicKey]?.consumedPrivatePaymentListVersionsByReceiverPath[receiverPath] + let amount = paymentRequest.map { + PaymentAmountContext(value: $0.amountValue, asset: "btc") + } + do { - let prepared = try await PaykitSdkService.shared.prepareAndResolveContactPayment( + let prepared = try await PaykitSdkService.shared.prepareAndResolvePrivateContactPayment( counterparty: publicKey, - receiverPath: PaykitReceiverPath.wallet, - includePublicEndpoints: true + receiverPath: receiverPath, + amount: amount, + afterPrivatePaymentListVersion: consumedVersion ) let resolution = prepared.resolution - let privateEndpoints = resolvedEndpoints(from: resolution, source: .privatePaymentList) + let linkState = try await currentLinkState( + publicKey: publicKey, + receiverPath: receiverPath, + preparedState: prepared.linkReport?.state + ) + + if paymentRequest == nil, canUsePublicPayment(linkState: linkState, resolution: resolution) { + return try await PublicPaykitService.beginPayment(to: publicKey) + } + + let privateEndpoints = resolvedEndpoints(from: resolution) cacheResolvedEndpoints(privateEndpoints, publicKey: publicKey) + let acceptedIdentifiers = paymentRequest.map { Set($0.acceptedPaymentEndpointIdentifiers) } + let acceptedEndpoints = privateEndpoints.filter { endpoint in + acceptedIdentifiers?.contains(endpoint.methodId.rawValue) ?? true + } - let payableEndpoints = await privatePayableEndpoints(from: privateEndpoints, publicKey: publicKey) + let payableEndpoints = await privatePayableEndpoints(from: acceptedEndpoints, publicKey: publicKey) - if !payableEndpoints.isEmpty { - return .opened(paymentRequest: PublicPaykitService.paymentRequest(from: payableEndpoints)) + if !payableEndpoints.isEmpty, let paymentListVersion = resolution.privatePaymentListVersion { + return .opened( + paymentRequest: PublicPaykitService.paymentRequest(from: payableEndpoints), + privatePaymentContext: PrivatePaykitPaymentContext( + receiverPath: receiverPath, + paymentListVersion: paymentListVersion + ) + ) } - if resolution.privateState == .recoveryPending { - schedulePrivatePaymentRecovery(for: publicKey) + if resolution.state == .recoveryPending { + schedulePrivatePaymentRecovery(for: publicKey, receiverPath: receiverPath) } - let publicEndpoints = resolvedEndpoints(from: resolution, source: .publicPaymentEndpoint) - let publicPayableEndpoints = await PublicPaykitService.payableEndpoints(from: publicEndpoints) - if !publicPayableEndpoints.isEmpty { - return .opened(paymentRequest: PublicPaykitService.paymentRequest(from: publicPayableEndpoints)) + if resolution.status == .waitingForUpdatedPaymentList { + schedulePrivatePaymentRecovery(for: publicKey, receiverPath: receiverPath) + return .waitingForUpdatedPaymentList } - return (privateEndpoints.isEmpty && publicEndpoints.isEmpty) ? .noEndpoint : .notOpened + return acceptedEndpoints.isEmpty ? .noEndpoint : .notOpened } catch { if error is CancellationError || Task.isCancelled { throw error @@ -69,12 +117,67 @@ extension PrivatePaykitService { "Failed to resolve Paykit contact payment for \(PubkyPublicKeyFormat.redacted(publicKey)): \(error)", context: "PrivatePaykit" ) + + let linkState = try await currentLinkState(publicKey: publicKey, receiverPath: receiverPath) + guard paymentRequest == nil, canUsePublicPayment(linkState: linkState) else { + throw error + } return try await PublicPaykitService.beginPayment(to: publicKey) } } - private func hasLiveSessionForCurrentProfile() async -> Bool { - guard let status = try? await PaykitSdkService.shared.identityStatus() else { return false } + func consumePrivatePaymentList(publicKey: String, context: PrivatePaykitPaymentContext) throws { + guard let publicKey = PubkyPublicKeyFormat.normalized(publicKey) else { + throw PrivatePaykitError.invalidPublicKey + } + + var contactState = state.contacts[publicKey, default: ContactState()] + if let consumedVersion = contactState.consumedPrivatePaymentListVersionsByReceiverPath[context.receiverPath], + context.paymentListVersion <= consumedVersion + { + throw PrivatePaykitError.paymentListAlreadyConsumed + } + + contactState.consumedPrivatePaymentListVersionsByReceiverPath[context.receiverPath] = context.paymentListVersion + contactState.cachedResolvedEndpoints.removeAll() + state.contacts[publicKey] = contactState + try persistStateOrThrow(markWalletBackup: true) + } + + private func currentLinkState( + publicKey: String, + receiverPath: String, + preparedState: LinkedPeerState? = nil + ) async throws -> LinkedPeerState? { + if let preparedState { + return preparedState + } + + return try await PaykitSdkService.shared.linkedPeers().first { + PubkyPublicKeyFormat.matches($0.counterparty, publicKey) && $0.counterpartyReceiverPath == receiverPath + }?.state + } + + private func canUsePublicPayment( + linkState: LinkedPeerState?, + resolution: PrivateContactPaymentResolution? = nil + ) -> Bool { + if let resolution, + resolution.status == .waitingForUpdatedPaymentList || resolution.state != .noPrivateEndpoint + { + return false + } + + switch linkState { + case nil, .notLinked, .linking: + return true + case .linked, .recoveryRequired, .blocked, .unknown: + return false + } + } + + private func hasLiveSessionForCurrentProfile() async throws -> Bool { + guard let status = try await PaykitSdkService.shared.identityStatus() else { return false } return status.liveSessionAvailable } @@ -175,11 +278,15 @@ extension PrivatePaykitService { persistState(markWalletBackup: true) } - private func resolvedEndpoints(from resolution: ContactPaymentResolution, source: PaymentEndpointSource) -> [PublicPaykitService.Endpoint] { + private func resolvedEndpoints(from resolution: PrivateContactPaymentResolution) -> [PublicPaykitService.Endpoint] { resolution.payableEndpoints.compactMap { - guard $0.source == source else { return nil } - return PublicPaykitService.parseEndpoint(identifier: $0.identifier, payload: $0.target.payload) } } } + +private extension PrivatePaykitService { + static var privatePaymentResolutionRetryDelays: ArraySlice { + privateMessageDrainRetryDelays.prefix(3) + } +} diff --git a/Bitkit/Services/PrivatePaykitService+State.swift b/Bitkit/Services/PrivatePaykitService+State.swift index 9e4af1614..5d195fee1 100644 --- a/Bitkit/Services/PrivatePaykitService+State.swift +++ b/Bitkit/Services/PrivatePaykitService+State.swift @@ -18,19 +18,29 @@ extension PrivatePaykitService { func clearContactState(publicKey: String) async { guard let normalizedKey = PubkyPublicKeyFormat.normalized(publicKey) else { return } - state.contacts[normalizedKey] = nil + let consumedVersions = state.contacts[normalizedKey]?.consumedPrivatePaymentListVersionsByReceiverPath ?? [:] + if consumedVersions.isEmpty { + state.contacts[normalizedKey] = nil + } else { + var contactState = ContactState() + contactState.consumedPrivatePaymentListVersionsByReceiverPath = consumedVersions + state.contacts[normalizedKey] = contactState + } await PrivatePaykitAddressReservationStore.shared.clearContactAssignment(publicKey: normalizedKey) persistState(markWalletBackup: true) } func persistState(markWalletBackup: Bool = false) { do { - let data = try JSONEncoder().encode(state) - UserDefaults.standard.set(data, forKey: Self.cacheStateKey) + try persistStateOrThrow(markWalletBackup: markWalletBackup) } catch { Logger.error("Failed to persist private Paykit cache state: \(error)", context: "PrivatePaykit") } + } + func persistStateOrThrow(markWalletBackup: Bool = false) throws { + let data = try JSONEncoder().encode(state) + UserDefaults.standard.set(data, forKey: Self.cacheStateKey) if markWalletBackup { markWalletBackupDataChanged() } diff --git a/Bitkit/Services/PubkyService.swift b/Bitkit/Services/PubkyService.swift index 924abb5e8..3ad14422c 100644 --- a/Bitkit/Services/PubkyService.swift +++ b/Bitkit/Services/PubkyService.swift @@ -602,7 +602,7 @@ actor PaykitSdkService { func syncPublicEndpoints(_ endpoints: [PublicPaykitService.Endpoint]) async throws -> EndpointSyncReport { try await withStateRevisionTracking { sdk in - try await sdk.syncPublicEndpointsWithReceivingDetails(receivingDetails: endpoints.map(\.paykitReceivingDetail)) + try await sdk.syncPublicEndpointsWithReceivingDetails(receivingDetails: endpoints.map(\.paykitPublicReceivingDetail)) } } @@ -637,15 +637,37 @@ actor PaykitSdkService { } } - func receivePrivateMessagesFromLinkedPeers() async throws { + @discardableResult + func receivePrivateMessagesFromLinkedPeers() async throws -> [PrivateStreamCounterpartyIntakeReport] { try await withStateRevisionTracking { sdk in - _ = try await sdk.receivePrivateMessagesFromLinkedPeers() + try await sdk.receivePrivateMessagesFromLinkedPeers() } } - func processPendingPrivateMessages() async throws { + @discardableResult + func processPendingPrivateMessages() async throws -> [OutboundPrivateCounterpartySendReport] { try await withStateRevisionTracking { sdk in - _ = try await sdk.processPendingPrivateMessages() + try await sdk.processPendingPrivateMessages() + } + } + + func actionableReceivedPaymentRequests() async throws -> [Paykit.PaymentRequestRecord] { + try await operationLock.withLock { + try await handle().actionableReceivedPaymentRequests() + } + } + + func acceptPaymentRequest( + counterparty: String, + counterpartyReceiverPath: String, + paymentRequestId: String + ) async throws -> Paykit.PaymentRequestRecord { + try await withStateRevisionTracking { sdk in + try await sdk.acceptPaymentRequest( + counterparty: counterparty, + counterpartyReceiverPath: counterpartyReceiverPath, + paymentRequestId: paymentRequestId + ) } } @@ -661,17 +683,18 @@ actor PaykitSdkService { } } - func prepareAndResolveContactPayment( + func prepareAndResolvePrivateContactPayment( counterparty: String, receiverPath: String, - includePublicEndpoints: Bool - ) async throws -> PreparedContactPayment { + amount: PaymentAmountContext? = nil, + afterPrivatePaymentListVersion: UInt64? + ) async throws -> PreparedPrivateContactPayment { try await withStateRevisionTracking { sdk in - try await sdk.prepareAndResolveContactPayment( + try await sdk.prepareAndResolvePrivateContactPayment( counterparty: counterparty, counterpartyReceiverPath: receiverPath, - amount: nil, - includePublicEndpoints: includePublicEndpoints, + amount: amount, + afterPrivatePaymentListVersion: afterPrivatePaymentListVersion, maxAdvanceSteps: 8 ) } @@ -680,7 +703,7 @@ actor PaykitSdkService { func resolvePublicContactPayment( counterparty: String, receiverPath: String - ) async throws -> ContactPaymentResolution { + ) async throws -> PublicContactPaymentResolution { try await operationLock.withLock { try await handle().resolvePublicContactPayment(counterparty: counterparty, counterpartyReceiverPath: receiverPath, amount: nil) } @@ -860,7 +883,7 @@ actor PaykitSdkService { let status = try await sdk.identityStatus() return Paykit.PaykitReceiverCapabilities( privatePayments: status?.liveSessionAvailable == true, - paymentRequests: false, + paymentRequests: status?.liveSessionAvailable == true, receipts: false, outgoingPayments: true ) @@ -958,8 +981,8 @@ private final class PaykitSdkOperationLock: @unchecked Sendable { } extension PublicPaykitService.Endpoint { - var paykitReceivingDetail: ReceivingDetail { - ReceivingDetail( + var paykitPublicReceivingDetail: PublicReceivingDetail { + PublicReceivingDetail( identifier: methodId.rawValue, payload: PaymentPayload(text: rawPayload) ) @@ -1172,19 +1195,43 @@ final class PaykitReceiverNoiseKeyStore: @unchecked Sendable { } private final class PaykitSdkPaymentAdapter: SdkPaymentAdapter, @unchecked Sendable { - func currentReceivingDetails(scope: ReceivingDetailScope) throws -> [ReceivingDetail] { + func currentPublicReceivingDetails() throws -> [PublicReceivingDetail] { [] } - func reserveReceivingDetails(counterparty _: String, counterpartyReceiverPath _: String) throws -> ReceivingDetailReservationResponse { - ReceivingDetailReservationResponse(kind: .useCurrentReceivingDetails, reservations: []) + func currentPrivateReceivingDetails(counterparty _: String, counterpartyReceiverPath _: String) throws -> [PrivateReceivingDetail] { + [] } - func cancelReceivingDetailReservation(cancellation _: PaymentEndpointReservationCancellation) throws { + func reservePrivateReceivingDetails( + counterparty _: String, + counterpartyReceiverPath _: String + ) throws -> PrivateReceivingDetailReservationResponse { + PrivateReceivingDetailReservationResponse(kind: .useCurrentReceivingDetails, reservations: []) + } + + func cancelPrivateReceivingDetailReservation(cancellation _: PrivatePaymentEndpointReservationCancellation) throws { // Keeping unused reserved addresses/invoices out of reusable receive pools is safer than reusing leaked details. } - func selectPaymentEndpointIds(request: PaymentEndpointSelectionRequest) throws -> [String] { + func selectPublicPaymentEndpointIds(request: PublicPaymentEndpointSelectionRequest) throws -> [String] { + let parsed = request.candidates.compactMap { candidate -> (id: String, endpoint: PublicPaykitService.Endpoint)? in + guard let endpoint = PublicPaykitService.parseEndpoint(candidate: candidate) else { + return nil + } + return (candidate.candidateId, endpoint) + } + + return PublicPaykitService.MethodId.payablePreferenceOrder.flatMap { methodId in + parsed.compactMap { $0.endpoint.methodId == methodId ? $0.id : nil } + } + } + + func buildPublicPaymentTarget(endpoint: PublicPaymentEndpointCandidate) throws -> PaymentTarget { + PaymentTarget(payload: endpoint.payload) + } + + func selectPrivatePaymentEndpointIds(request: PrivatePaymentEndpointSelectionRequest) throws -> [String] { let parsed = request.candidates.compactMap { candidate -> (id: String, endpoint: PublicPaykitService.Endpoint)? in guard let endpoint = PublicPaykitService.parseEndpoint(candidate: candidate) else { return nil @@ -1197,7 +1244,7 @@ private final class PaykitSdkPaymentAdapter: SdkPaymentAdapter, @unchecked Senda } } - func buildPaymentTarget(endpoint: PaymentEndpointCandidate) throws -> PaymentTarget { + func buildPrivatePaymentTarget(endpoint: PrivatePaymentEndpointCandidate) throws -> PaymentTarget { PaymentTarget(payload: endpoint.payload) } } diff --git a/Bitkit/Services/PublicPaykitService.swift b/Bitkit/Services/PublicPaykitService.swift index 6cbf288bb..72274ba5b 100644 --- a/Bitkit/Services/PublicPaykitService.swift +++ b/Bitkit/Services/PublicPaykitService.swift @@ -26,16 +26,22 @@ enum PublicPaykitError: LocalizedError { } } +struct PrivatePaykitPaymentContext: Equatable { + let receiverPath: String + let paymentListVersion: UInt64 +} + enum PublicPaykitPaymentLaunchResult { - case opened(paymentRequest: String) + case opened(paymentRequest: String, privatePaymentContext: PrivatePaykitPaymentContext?) case noEndpoint case notOpened + case waitingForUpdatedPaymentList var contactPaymentFailureMessageKey: String? { switch self { case .opened: nil - case .noEndpoint: + case .noEndpoint, .waitingForUpdatedPaymentList: "slashtags__error_pay_empty_msg" case .notOpened: "slashtags__error_pay_not_opened_msg" @@ -234,7 +240,11 @@ enum PublicPaykitService { parseEndpoint(methodId: identifier, endpointData: payload.exportText()) } - static func parseEndpoint(candidate: PaymentEndpointCandidate) -> Endpoint? { + static func parseEndpoint(candidate: PublicPaymentEndpointCandidate) -> Endpoint? { + parseEndpoint(identifier: candidate.identifier, payload: candidate.payload) + } + + static func parseEndpoint(candidate: PrivatePaymentEndpointCandidate) -> Endpoint? { parseEndpoint(identifier: candidate.identifier, payload: candidate.payload) } @@ -334,7 +344,7 @@ enum PublicPaykitService { return endpoints.isEmpty ? .noEndpoint : .notOpened } - return .opened(paymentRequest: paymentRequest(from: payableEndpoints)) + return .opened(paymentRequest: paymentRequest(from: payableEndpoints), privatePaymentContext: nil) } static func paymentRequest(from endpoints: [Endpoint]) -> String { diff --git a/Bitkit/ViewModels/AppViewModel.swift b/Bitkit/ViewModels/AppViewModel.swift index 5598341ce..a8b8c4ecd 100644 --- a/Bitkit/ViewModels/AppViewModel.swift +++ b/Bitkit/ViewModels/AppViewModel.swift @@ -11,7 +11,22 @@ struct SendSheetPendingResolution: Equatable { } struct ContactPaymentContext: Equatable { + let id: UUID let publicKey: String + let privatePaymentContext: PrivatePaykitPaymentContext? + let incomingPaymentRequest: PaykitPaymentRequest? + + init( + id: UUID = UUID(), + publicKey: String, + privatePaymentContext: PrivatePaykitPaymentContext? = nil, + incomingPaymentRequest: PaykitPaymentRequest? = nil + ) { + self.id = id + self.publicKey = publicKey + self.privatePaymentContext = privatePaymentContext + self.incomingPaymentRequest = incomingPaymentRequest + } } enum ManualEntryValidationResult: Equatable { @@ -88,6 +103,7 @@ class AppViewModel: ObservableObject { private let coreService: CoreService private let sheetViewModel: SheetViewModel private let navigationViewModel: NavigationViewModel + private var scannedDataHandlingId: UUID? private var manualEntryValidationSequence: UInt64 = 0 // Combine infrastructure for debounced validation @@ -369,9 +385,22 @@ extension AppViewModel { // MARK: Scanning/pasting handling extension AppViewModel { - func handleScannedData(_ uri: String) async throws { + func handleScannedData(_ uri: String, claimedContactPaymentContext: ContactPaymentContext? = nil) async throws { + let handlingId = claimedContactPaymentContext?.id ?? UUID() + if let claimedContactPaymentContext { + guard ownsContactPaymentContext(claimedContactPaymentContext), scannedDataHandlingId == nil else { + throw CancellationError() + } + } + scannedDataHandlingId = handlingId + defer { + if scannedDataHandlingId == handlingId { + scannedDataHandlingId = nil + } + } + // Reset send state before handling new data - resetSendState() + resetSendState(preservingContactPaymentContext: claimedContactPaymentContext != nil) let uri = uri.removingLightningSchemes() @@ -397,6 +426,7 @@ extension AppViewModel { } let data = try await decode(invoice: uri) + try ensureScannedDataHandlingOwnership(handlingId, claimedContactPaymentContext: claimedContactPaymentContext) switch data { // BIP21 (Unified) invoice handling @@ -416,7 +446,9 @@ extension AppViewModel { if let lnInvoice = invoice.params?["lightning"] { // Lightning invoice param found, prefer lightning payment if invoice is valid - if case let .lightning(lightningInvoice) = try await decode(invoice: lnInvoice) { + let lightningData = try await decode(invoice: lnInvoice) + try ensureScannedDataHandlingOwnership(handlingId, claimedContactPaymentContext: claimedContactPaymentContext) + if case let .lightning(lightningInvoice) = lightningData { // Check lightning invoice network let lnNetwork = NetworkValidationHelper.convertNetworkType(lightningInvoice.networkType) let lnNetworkMatch = !NetworkValidationHelper.isNetworkMismatch(addressNetwork: lnNetwork, currentNetwork: Env.network) @@ -561,6 +593,16 @@ extension AppViewModel { } } + private func ensureScannedDataHandlingOwnership( + _ handlingId: UUID, + claimedContactPaymentContext: ContactPaymentContext? + ) throws { + guard scannedDataHandlingId == handlingId else { throw CancellationError() } + if let claimedContactPaymentContext { + guard ownsContactPaymentContext(claimedContactPaymentContext) else { throw CancellationError() } + } + } + private func handleBTCPayConnection(_ setup: SamRockSetupRequest) { guard setup.requestsBitcoinOnchain else { toast( @@ -699,13 +741,25 @@ extension AppViewModel { navigationViewModel.navigate(.fundManual(nodeUri: url)) } - func resetSendState() { + func claimContactPaymentContext(_ context: ContactPaymentContext) -> Bool { + guard contactPaymentContext == nil, scannedDataHandlingId == nil else { return false } + contactPaymentContext = context + return true + } + + func ownsContactPaymentContext(_ context: ContactPaymentContext) -> Bool { + contactPaymentContext?.id == context.id + } + + func resetSendState(preservingContactPaymentContext: Bool = false) { scannedLightningInvoice = nil scannedOnchainInvoice = nil selectedWalletToPayFrom = .onchain // Reset to default lnurlPayData = nil lnurlWithdrawData = nil - contactPaymentContext = nil + if !preservingContactPaymentContext { + contactPaymentContext = nil + } } } diff --git a/Bitkit/Views/Contacts/AddContactView.swift b/Bitkit/Views/Contacts/AddContactView.swift index 8856a674e..036f1bed0 100644 --- a/Bitkit/Views/Contacts/AddContactView.swift +++ b/Bitkit/Views/Contacts/AddContactView.swift @@ -273,9 +273,9 @@ struct AddContactView: View { let result = try await PublicPaykitService.beginPayment(to: normalizedPublicKey) switch result { - case let .opened(paymentRequest): + case let .opened(paymentRequest, _): _ = await openContactPayment(paymentRequest: paymentRequest, publicKey: normalizedPublicKey) - case .noEndpoint, .notOpened: + case .noEndpoint, .notOpened, .waitingForUpdatedPaymentList: if let messageKey = result.contactPaymentFailureMessageKey { app.toast( type: .warning, @@ -296,9 +296,22 @@ struct AddContactView: View { @MainActor private func openContactPayment(paymentRequest: String, publicKey: String) async -> Bool { + let contactPaymentContext = ContactPaymentContext(publicKey: publicKey) + guard app.claimContactPaymentContext(contactPaymentContext) else { return false } + do { - try await app.handleScannedData(paymentRequest) + try await app.handleScannedData( + paymentRequest, + claimedContactPaymentContext: contactPaymentContext + ) + } catch is CancellationError { + if app.ownsContactPaymentContext(contactPaymentContext) { + app.resetSendState() + } + return false } catch { + guard app.ownsContactPaymentContext(contactPaymentContext) else { return false } + app.resetSendState() Logger.warn("Failed to decode contact payment request: \(error)", context: "AddContactView") app.toast( type: .warning, @@ -308,12 +321,13 @@ struct AddContactView: View { return false } + guard app.ownsContactPaymentContext(contactPaymentContext) else { return false } guard let route = PaymentNavigationHelper.contactPaymentRoute(app: app, currency: currency, settings: settings) else { + app.resetSendState() return false } navigation.navigateBack() - app.contactPaymentContext = ContactPaymentContext(publicKey: publicKey) sheets.showSheet(.send, data: SendConfig(view: route)) return true } diff --git a/Bitkit/Views/Contacts/ContactDetailView.swift b/Bitkit/Views/Contacts/ContactDetailView.swift index f98c66b1c..a4db4c8b8 100644 --- a/Bitkit/Views/Contacts/ContactDetailView.swift +++ b/Bitkit/Views/Contacts/ContactDetailView.swift @@ -270,9 +270,9 @@ struct ContactDetailView: View { let result = try await PrivatePaykitService.shared.beginSavedContactPayment(to: publicKey, wallet: wallet) switch result { - case let .opened(paymentRequest): - _ = await openContactPayment(paymentRequest: paymentRequest) - case .noEndpoint, .notOpened: + case let .opened(paymentRequest, privatePaymentContext): + _ = await openContactPayment(paymentRequest: paymentRequest, privatePaymentContext: privatePaymentContext) + case .noEndpoint, .notOpened, .waitingForUpdatedPaymentList: if let messageKey = result.contactPaymentFailureMessageKey { app.toast( type: .warning, @@ -292,10 +292,26 @@ struct ContactDetailView: View { } @MainActor - private func openContactPayment(paymentRequest: String) async -> Bool { + private func openContactPayment(paymentRequest: String, privatePaymentContext: PrivatePaykitPaymentContext?) async -> Bool { + let contactPaymentContext = ContactPaymentContext( + publicKey: publicKey, + privatePaymentContext: privatePaymentContext + ) + guard app.claimContactPaymentContext(contactPaymentContext) else { return false } + do { - try await app.handleScannedData(paymentRequest) + try await app.handleScannedData( + paymentRequest, + claimedContactPaymentContext: contactPaymentContext + ) + } catch is CancellationError { + if app.ownsContactPaymentContext(contactPaymentContext) { + app.resetSendState() + } + return false } catch { + guard app.ownsContactPaymentContext(contactPaymentContext) else { return false } + app.resetSendState() Logger.warn("Failed to decode contact payment request: \(error)", context: "ContactDetailView") app.toast( type: .warning, @@ -305,11 +321,12 @@ struct ContactDetailView: View { return false } + guard app.ownsContactPaymentContext(contactPaymentContext) else { return false } guard let route = PaymentNavigationHelper.contactPaymentRoute(app: app, currency: currency, settings: settings) else { + app.resetSendState() return false } - app.contactPaymentContext = ContactPaymentContext(publicKey: publicKey) sheets.showSheet(.send, data: SendConfig(view: route)) return true } diff --git a/Bitkit/Views/Wallets/Send/LnurlPayConfirm.swift b/Bitkit/Views/Wallets/Send/LnurlPayConfirm.swift index bc34ed903..8726dd6ad 100644 --- a/Bitkit/Views/Wallets/Send/LnurlPayConfirm.swift +++ b/Bitkit/Views/Wallets/Send/LnurlPayConfirm.swift @@ -11,6 +11,7 @@ struct LnurlPayConfirm: View { @Binding var navigationPath: [SendRoute] let requestPinCheck: () async -> Bool + let acceptIncomingPaymentRequest: () async throws -> Void @State private var showWarningAlert = false @State private var alertContinuation: CheckedContinuation? @@ -26,7 +27,7 @@ struct LnurlPayConfirm: View { var body: some View { VStack { SheetHeader( - title: t("wallet__lnurl_p_title"), + title: app.contactPaymentContext?.incomingPaymentRequest == nil ? t("wallet__lnurl_p_title") : t("wallet__payment_request"), showBackButton: true, action: AnyView(SendContactHeaderAvatar()) ) @@ -191,9 +192,15 @@ struct LnurlPayConfirm: View { } let amountMsats = lnurlPayData.callbackAmountMsats(userSats: wallet.sendAmountSats) - let contactPublicKey = app.contactPaymentContext?.publicKey + let contactPaymentContext = app.contactPaymentContext + let contactPublicKey = contactPaymentContext?.publicKey do { + try validateIncomingPaymentRequest(contactPaymentContext, amountMsats: amountMsats) + try await acceptIncomingPaymentRequest() + try validateIncomingPaymentRequest(contactPaymentContext, amountMsats: amountMsats) + try await consumePrivatePaymentListIfNeeded(contactPaymentContext) + // Fetch the Lightning invoice from LNURL let bolt11 = try await LnurlHelper.fetchLnurlInvoice( data: lnurlPayData, @@ -233,4 +240,27 @@ struct LnurlPayConfirm: View { } } } + + private func validateIncomingPaymentRequest(_ context: ContactPaymentContext?, amountMsats: UInt64) throws { + guard let context, let request = context.incomingPaymentRequest else { return } + guard !request.isExpired(at: Date()) else { throw PaykitPaymentRequestError.requestExpired } + guard app.ownsContactPaymentContext(context) else { throw PaykitPaymentRequestError.requestUnavailable } + guard let amountSats = wallet.sendAmountSats, + request.acceptsPaymentAmount(amountSats), + request.acceptsLightningInvoiceAmount(milliSatoshis: amountMsats) + else { + throw LnurlPayInvoiceMismatchError() + } + } + + private func consumePrivatePaymentListIfNeeded(_ contactPaymentContext: ContactPaymentContext?) async throws { + guard let contactPaymentContext, + let privatePaymentContext = contactPaymentContext.privatePaymentContext + else { return } + + try await PrivatePaykitService.shared.consumePrivatePaymentList( + publicKey: contactPaymentContext.publicKey, + context: privatePaymentContext + ) + } } diff --git a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift index 9d1aae658..0ea3f80f7 100644 --- a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift +++ b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift @@ -1,4 +1,5 @@ import BitkitCore +import LDKNode import SwiftUI struct SendConfirmationView: View { @@ -14,6 +15,7 @@ struct SendConfirmationView: View { @Binding var navigationPath: [SendRoute] let requestPinCheck: () async -> Bool + let acceptIncomingPaymentRequest: () async throws -> Void @State private var showDetails = false @State private var showingBiometricError = false @@ -81,6 +83,7 @@ struct SendConfirmationView: View { } private var canEditAmount: Bool { + guard app.contactPaymentContext?.incomingPaymentRequest == nil else { return false } guard app.selectedWalletToPayFrom == .lightning else { return true } guard let invoice = app.scannedLightningInvoice else { return true } @@ -98,7 +101,7 @@ struct SendConfirmationView: View { var body: some View { VStack(alignment: .leading, spacing: 0) { SheetHeader( - title: t("wallet__send_review"), + title: app.contactPaymentContext?.incomingPaymentRequest == nil ? t("wallet__send_review") : t("wallet__payment_request"), showBackButton: !navigationPath.isEmpty, action: AnyView(SendContactHeaderAvatar()) ) @@ -483,9 +486,15 @@ struct SendConfirmationView: View { private func performPayment() async throws { var createdMetadataPaymentId: String? = nil - let contactPublicKey = app.contactPaymentContext?.publicKey + let contactPaymentContext = app.contactPaymentContext + let contactPublicKey = contactPaymentContext?.publicKey do { + try validateIncomingPaymentRequestContext(contactPaymentContext) + try validateIncomingPaymentRequestAmounts(contactPaymentContext) + try await acceptIncomingPaymentRequest() + try validateIncomingPaymentRequestContext(contactPaymentContext) + if app.selectedWalletToPayFrom == .lightning, let invoice = app.scannedLightningInvoice { let amount = wallet.sendAmountSats ?? invoice.amountSatoshis // Set the amount for other screens @@ -501,28 +510,15 @@ struct SendConfirmationView: View { // native millisatoshi precision instead of our truncated satoshi value. let paymentSats: UInt64? = invoice.amountSatoshis == 0 ? amount : nil do { + try await consumePrivatePaymentListIfNeeded(contactPaymentContext) try await wallet.sendWithTimeout( bolt11: invoice.bolt11, sats: paymentSats, onTimeout: { - if let contactPublicKey { - Task { - await PrivatePaykitService.shared.discardRemoteLightningEndpoints( - publicKey: contactPublicKey, - paymentHashes: [paymentHash] - ) - } - } app.addPendingPaymentHash(paymentHash, contactPublicKey: contactPublicKey) navigationPath.append(.pending(paymentHash: paymentHash)) } ) - if let contactPublicKey { - await PrivatePaykitService.shared.discardRemoteLightningEndpoints( - publicKey: contactPublicKey, - paymentHashes: [paymentHash] - ) - } await syncContactForActivity(paymentId: paymentHash, contactPublicKey: contactPublicKey) Logger.info("Lightning payment successful: \(paymentHash)") navigationPath.append(.success(paymentId: paymentHash)) @@ -530,24 +526,13 @@ struct SendConfirmationView: View { // onTimeout callback already navigated to .pending; suppress throw return } catch { - if let contactPublicKey { - await PrivatePaykitService.shared.discardRemoteLightningEndpoints( - publicKey: contactPublicKey, - paymentHashes: [paymentHash] - ) - } throw error } } else if app.selectedWalletToPayFrom == .onchain, let invoice = app.scannedOnchainInvoice { let amount = wallet.sendAmountSats ?? invoice.amountSatoshis let useMaxAmount = await shouldUseMaxOnchainSend(address: invoice.address, amountSats: amount) + try await consumePrivatePaymentListIfNeeded(contactPaymentContext) let txid = try await wallet.send(address: invoice.address, sats: amount, isMaxAmount: useMaxAmount) - if let contactPublicKey { - await PrivatePaykitService.shared.discardRemoteOnchainEndpoints( - publicKey: contactPublicKey, - addresses: [invoice.address] - ) - } // Create pre-activity metadata for tags and activity address await createPreActivityMetadata(paymentId: txid, address: invoice.address, txId: txid, feeRate: wallet.selectedFeeRateSatsPerVByte) @@ -590,6 +575,47 @@ struct SendConfirmationView: View { } } + private func validateIncomingPaymentRequestContext(_ context: ContactPaymentContext?) throws { + guard let context, let request = context.incomingPaymentRequest else { return } + guard !request.isExpired(at: Date()) else { throw PaykitPaymentRequestError.requestExpired } + guard app.ownsContactPaymentContext(context) else { throw PaykitPaymentRequestError.requestUnavailable } + } + + private func validateIncomingPaymentRequestAmounts(_ context: ContactPaymentContext?) throws { + guard let request = context?.incomingPaymentRequest else { return } + + let paymentAmount = switch app.selectedWalletToPayFrom { + case .lightning: + wallet.sendAmountSats ?? app.scannedLightningInvoice?.amountSatoshis + case .onchain: + wallet.sendAmountSats ?? app.scannedOnchainInvoice?.amountSatoshis + } + + guard let paymentAmount, request.acceptsPaymentAmount(paymentAmount) else { + throw LnurlPayInvoiceMismatchError() + } + guard app.selectedWalletToPayFrom == .lightning else { return } + guard let invoice = app.scannedLightningInvoice else { + throw LnurlPayInvoiceMismatchError() + } + let parsedInvoice = try Bolt11Invoice.fromStr(invoiceStr: invoice.bolt11) + guard request.acceptsLightningInvoiceAmount(milliSatoshis: parsedInvoice.amountMilliSatoshis()) + else { + throw LnurlPayInvoiceMismatchError() + } + } + + private func consumePrivatePaymentListIfNeeded(_ contactPaymentContext: ContactPaymentContext?) async throws { + guard let contactPaymentContext, + let privatePaymentContext = contactPaymentContext.privatePaymentContext + else { return } + + try await PrivatePaykitService.shared.consumePrivatePaymentList( + publicKey: contactPaymentContext.publicKey, + context: privatePaymentContext + ) + } + private func syncContactForActivity(paymentId: String, contactPublicKey: String?) async { guard let contactPublicKey else { return diff --git a/Bitkit/Views/Wallets/Send/SendContactSelectView.swift b/Bitkit/Views/Wallets/Send/SendContactSelectView.swift index 3ea377eba..11c571f7b 100644 --- a/Bitkit/Views/Wallets/Send/SendContactSelectView.swift +++ b/Bitkit/Views/Wallets/Send/SendContactSelectView.swift @@ -61,9 +61,13 @@ struct SendContactSelectView: View { let result = try await PrivatePaykitService.shared.beginSavedContactPayment(to: contact.publicKey, wallet: wallet) switch result { - case let .opened(paymentRequest): - _ = await openContactPayment(paymentRequest: paymentRequest, publicKey: contact.publicKey) - case .noEndpoint, .notOpened: + case let .opened(paymentRequest, privatePaymentContext): + _ = await openContactPayment( + paymentRequest: paymentRequest, + publicKey: contact.publicKey, + privatePaymentContext: privatePaymentContext + ) + case .noEndpoint, .notOpened, .waitingForUpdatedPaymentList: if let messageKey = result.contactPaymentFailureMessageKey { app.toast( type: .warning, @@ -79,10 +83,30 @@ struct SendContactSelectView: View { } @MainActor - private func openContactPayment(paymentRequest: String, publicKey: String) async -> Bool { + private func openContactPayment( + paymentRequest: String, + publicKey: String, + privatePaymentContext: PrivatePaykitPaymentContext? + ) async -> Bool { + let contactPaymentContext = ContactPaymentContext( + publicKey: publicKey, + privatePaymentContext: privatePaymentContext + ) + guard app.claimContactPaymentContext(contactPaymentContext) else { return false } + do { - try await app.handleScannedData(paymentRequest) + try await app.handleScannedData( + paymentRequest, + claimedContactPaymentContext: contactPaymentContext + ) + } catch is CancellationError { + if app.ownsContactPaymentContext(contactPaymentContext) { + app.resetSendState() + } + return false } catch { + guard app.ownsContactPaymentContext(contactPaymentContext) else { return false } + app.resetSendState() Logger.warn("Failed to decode contact payment request: \(error)", context: "SendContactSelectView") app.toast( type: .warning, @@ -92,11 +116,12 @@ struct SendContactSelectView: View { return false } + guard app.ownsContactPaymentContext(contactPaymentContext) else { return false } guard let route = PaymentNavigationHelper.contactPaymentRoute(app: app, currency: currency, settings: settings) else { + app.resetSendState() return false } - app.contactPaymentContext = ContactPaymentContext(publicKey: publicKey) navigationPath.append(route) return true } diff --git a/Bitkit/Views/Wallets/Send/SendSheet.swift b/Bitkit/Views/Wallets/Send/SendSheet.swift index feb4d56f0..0b54bfa84 100644 --- a/Bitkit/Views/Wallets/Send/SendSheet.swift +++ b/Bitkit/Views/Wallets/Send/SendSheet.swift @@ -48,6 +48,7 @@ struct SendSheet: View { @EnvironmentObject private var sheets: SheetViewModel @EnvironmentObject private var tagManager: TagManager @EnvironmentObject private var wallet: WalletViewModel + @Environment(PaykitPaymentRequestManager.self) private var paykitPaymentRequestManager let config: SendSheetItem @@ -125,9 +126,16 @@ struct SendSheet: View { .onAppear { tagManager.clearSelectedTags() wallet.resetSendState(speed: settings.defaultTransactionSpeed) + if let request = app.contactPaymentContext?.incomingPaymentRequest { + wallet.sendAmountSats = request.amountSats + } hasValidatedAfterSync = false syncTimedOut = false + if app.contactPaymentContext?.incomingPaymentRequest != nil, !shouldShowSyncOverlay { + validatePaymentAfterSync() + } + // A plain Send open (TabBar) must not inherit invoice state from an earlier scan, // e.g. one abandoned behind the sync overlay. Invoice-carrying opens use other routes. if config.initialRoute == .options { @@ -261,6 +269,47 @@ struct SendSheet: View { /// For onchain: validates balance and shows error if insufficient /// Pass `ignoreChannelWait: true` to validate even while channels are unusable (sync timeout). private func validatePaymentAfterSync(ignoreChannelWait: Bool = false) { + let requestedAmount = app.contactPaymentContext?.incomingPaymentRequest?.amountSats + + if let lnurlPayData = app.lnurlPayData, let requestedAmount { + let minimumAmount = max(1, lnurlPayData.minSendableSat) + guard requestedAmount >= minimumAmount else { + app.toast( + type: .error, + title: t("wallet__lnurl_pay__error_min__title"), + description: t( + "wallet__lnurl_pay__error_min__description", + variables: ["amount": CurrencyFormatter.formatSats(minimumAmount)] + ), + accessibilityIdentifier: "LnurlPayAmountTooLowToast" + ) + sheets.hideSheet() + hasValidatedAfterSync = true + return + } + guard requestedAmount <= lnurlPayData.maxSendableSat else { + app.toast( + type: .error, + title: t("wallet__lnurl_pay__error_max__title"), + description: t("wallet__lnurl_pay__error_max__description"), + accessibilityIdentifier: "LnurlPayAmountTooHighToast" + ) + sheets.hideSheet() + hasValidatedAfterSync = true + return + } + guard LightningService.shared.canSend(amountSats: requestedAmount) else { + let spendingBalance = LightningService.shared.balances?.totalLightningBalanceSats ?? 0 + showInsufficientSpendingToast(invoiceAmount: requestedAmount, spendingBalance: spendingBalance) + sheets.hideSheet() + hasValidatedAfterSync = true + return + } + + hasValidatedAfterSync = true + return + } + // Validate lightning payment if present if let lightningInvoice = app.scannedLightningInvoice { // For lightning, if we have channels but none are usable yet, wait for them @@ -274,7 +323,8 @@ struct SendSheet: View { } // Check if we can afford the lightning payment - let canSend = LightningService.shared.canSend(amountSats: lightningInvoice.amountSatoshis) + let paymentAmount = requestedAmount ?? lightningInvoice.amountSatoshis + let canSend = LightningService.shared.canSend(amountSats: paymentAmount) if !canSend { // For unified invoices, fall back to onchain @@ -287,7 +337,7 @@ struct SendSheet: View { // Validate onchain balance BEFORE navigating let onchainBalance = LightningService.shared.balances?.spendableOnchainBalanceSats ?? 0 guard validateOnchainBalanceAndDismissIfInsufficient( - invoiceAmount: onchainInvoice.amountSatoshis, + invoiceAmount: requestedAmount ?? onchainInvoice.amountSatoshis, onchainBalance: onchainBalance ) else { hasValidatedAfterSync = true @@ -296,13 +346,15 @@ struct SendSheet: View { // Onchain balance is sufficient → navigate to amount screen // (the sheet may have opened with .confirm or .quickpay route) - navigationPath = [.amount] + if requestedAmount == nil { + navigationPath = [.amount] + } hasValidatedAfterSync = true return } else { // For pure lightning invoices, show error toast and dismiss sheet let spendingBalance = LightningService.shared.balances?.totalLightningBalanceSats ?? 0 - showInsufficientSpendingToast(invoiceAmount: lightningInvoice.amountSatoshis, spendingBalance: spendingBalance) + showInsufficientSpendingToast(invoiceAmount: paymentAmount, spendingBalance: spendingBalance) sheets.hideSheet() hasValidatedAfterSync = true return @@ -318,7 +370,7 @@ struct SendSheet: View { if let onchainInvoice = app.scannedOnchainInvoice { let onchainBalance = LightningService.shared.balances?.spendableOnchainBalanceSats ?? 0 guard validateOnchainBalanceAndDismissIfInsufficient( - invoiceAmount: onchainInvoice.amountSatoshis, + invoiceAmount: requestedAmount ?? onchainInvoice.amountSatoshis, onchainBalance: onchainBalance ) else { hasValidatedAfterSync = true @@ -366,7 +418,11 @@ struct SendSheet: View { case .utxoSelection: SendUtxoSelectionView(navigationPath: $navigationPath) case .confirm: - SendConfirmationView(navigationPath: $navigationPath, requestPinCheck: requestPinCheck) + SendConfirmationView( + navigationPath: $navigationPath, + requestPinCheck: requestPinCheck, + acceptIncomingPaymentRequest: acceptIncomingPaymentRequest + ) case .feeRate: SendFeeRate(navigationPath: $navigationPath) case .feeCustom: @@ -386,7 +442,11 @@ struct SendSheet: View { case .lnurlPayAmount: LnurlPayAmount(navigationPath: $navigationPath) case .lnurlPayConfirm: - LnurlPayConfirm(navigationPath: $navigationPath, requestPinCheck: requestPinCheck) + LnurlPayConfirm( + navigationPath: $navigationPath, + requestPinCheck: requestPinCheck, + acceptIncomingPaymentRequest: acceptIncomingPaymentRequest + ) case .lnurlWithdrawAmount: LnurlWithdrawAmount { navigationPath.append(.lnurlWithdrawConfirm) @@ -399,6 +459,11 @@ struct SendSheet: View { LnurlWithdrawFailure(amount: amount) } } + + private func acceptIncomingPaymentRequest() async throws { + guard let request = app.contactPaymentContext?.incomingPaymentRequest else { return } + try await paykitPaymentRequestManager.accept(request) + } } private struct SendComingSoonView: View { diff --git a/BitkitTests/PaykitPaymentRequestServiceTests.swift b/BitkitTests/PaykitPaymentRequestServiceTests.swift new file mode 100644 index 000000000..662d645cf --- /dev/null +++ b/BitkitTests/PaykitPaymentRequestServiceTests.swift @@ -0,0 +1,572 @@ +@testable import Bitkit +import Foundation +import Paykit +import XCTest + +@MainActor +final class PaykitPaymentRequestServiceTests: XCTestCase { + func testContactPaymentContextClaimIsExclusiveAndIdentityBased() { + let app = AppViewModel() + let first = ContactPaymentContext(publicKey: "pubkycontact") + let second = ContactPaymentContext(publicKey: "pubkycontact") + + XCTAssertTrue(app.claimContactPaymentContext(first)) + XCTAssertTrue(app.ownsContactPaymentContext(first)) + XCTAssertFalse(app.ownsContactPaymentContext(second)) + XCTAssertFalse(app.claimContactPaymentContext(second)) + + app.resetSendState(preservingContactPaymentContext: true) + XCTAssertTrue(app.ownsContactPaymentContext(first)) + app.resetSendState() + XCTAssertFalse(app.ownsContactPaymentContext(first)) + XCTAssertTrue(app.claimContactPaymentContext(second)) + } + + func testRefreshMapsSupportedOneTimeBitcoinRequest() async throws { + let now = Date(timeIntervalSince1970: 1_800_000_000) + let currentOnchain = PublicPaykitService.MethodId.onchainMethodId(network: Env.network, scriptType: .p2wpkh) + let otherOnchain: PublicPaykitService.MethodId = Env.network == .bitcoin ? .testnetOnchainP2wpkh : .bitcoinOnchainP2wpkh + let record = try paymentRequestRecord( + amount: "0.00100000000", + expiresAt: timestamp(now.addingTimeInterval(60)), + endpoints: [ + PublicPaykitService.MethodId.bitcoinLightningBolt11.rawValue, + PublicPaykitService.MethodId.bitcoinLightningBolt11.rawValue, + currentOnchain.rawValue, + otherOnchain.rawValue, + "btc-unsupported-method", + ], + metadata: #"{"order":"123"}"# + ) + let sdk = PaymentRequestSdkMock(records: [record]) + let clock = PaymentRequestTestClock(now) + let manager = paymentRequestManager(sdk: sdk, clock: clock) + + await manager.refresh() + + let request = try XCTUnwrap(manager.pendingRequests.first) + XCTAssertEqual(manager.pendingRequests.count, 1) + XCTAssertEqual(request.paymentRequestId, record.paymentRequestId) + XCTAssertEqual(request.amountValue, "0.00100000000") + XCTAssertEqual(request.amountSats, 100_000) + XCTAssertEqual(request.paymentReference, "invoice-123") + XCTAssertEqual(request.metadata, #"{"order":"123"}"#) + XCTAssertEqual( + request.acceptedPaymentEndpointIdentifiers, + [PublicPaykitService.MethodId.bitcoinLightningBolt11.rawValue, currentOnchain.rawValue] + ) + } + + func testRefreshDropsExpiredAndUnsupportedRequests() async throws { + let now = Date(timeIntervalSince1970: 1_800_000_000) + let recurrence = PaymentRequestRecurrence( + every: 1, + unit: "month", + startsAt: timestamp(now), + anchor: timestamp(now), + endsAt: nil + ) + let records = try [ + paymentRequestRecord(id: "valid"), + paymentRequestRecord(id: "sdk-expired", state: .proposalExpired), + paymentRequestRecord(id: "timestamp-expired", expiresAt: timestamp(now)), + paymentRequestRecord(id: "malformed-expiry", expiresAt: "not-a-timestamp"), + paymentRequestRecord(id: "wrong-role", role: .payee), + paymentRequestRecord(id: "recurring", recurrence: recurrence), + paymentRequestRecord(id: "wrong-asset", asset: "usd"), + paymentRequestRecord(id: "sub-satoshi", amount: "0.000000001"), + paymentRequestRecord(id: "zero", amount: "0"), + paymentRequestRecord(id: "unsupported-endpoint", endpoints: ["btc-unsupported-method"]), + ] + let sdk = PaymentRequestSdkMock(records: records) + let clock = PaymentRequestTestClock(now) + let manager = paymentRequestManager(sdk: sdk, clock: clock) + + await manager.refresh() + + XCTAssertEqual(manager.pendingRequests.map(\.paymentRequestId), ["valid"]) + } + + func testRefreshRejectsAmountsOutsideTheAppPaymentRange() async throws { + let records = try [ + paymentRequestRecord(id: "one-sat", amount: "0.00000001"), + paymentRequestRecord(id: "millisatoshi-safe-max", amount: "184467440.73709551"), + paymentRequestRecord(id: "millisatoshi-overflow", amount: "184467440.73709552"), + paymentRequestRecord(id: "int-max", amount: "92233720368.54775807"), + paymentRequestRecord(id: "int-overflow", amount: "92233720368.54775808"), + paymentRequestRecord(id: "uint64-max", amount: "184467440737.09551615"), + paymentRequestRecord(id: "uint64-overflow", amount: "184467440737.09551616"), + ] + let manager = paymentRequestManager(sdk: PaymentRequestSdkMock(records: records)) + + await manager.refresh() + + XCTAssertEqual(manager.pendingRequests.map(\.paymentRequestId), ["one-sat", "millisatoshi-safe-max"]) + XCTAssertEqual(manager.pendingRequests.map(\.amountSats), [1, UInt64.max / 1000]) + } + + func testPaymentAndLightningInvoiceAmountsMustMatchRequest() throws { + let request = try XCTUnwrap(PaykitPaymentRequest( + record: paymentRequestRecord(amount: "0.000025"), + now: Date() + )) + + XCTAssertTrue(request.acceptsPaymentAmount(2500)) + XCTAssertFalse(request.acceptsPaymentAmount(0)) + XCTAssertFalse(request.acceptsPaymentAmount(2501)) + XCTAssertTrue(request.acceptsLightningInvoiceAmount(milliSatoshis: nil)) + XCTAssertTrue(request.acceptsLightningInvoiceAmount(milliSatoshis: 2_500_000)) + XCTAssertFalse(request.acceptsLightningInvoiceAmount(milliSatoshis: 2_499_999)) + XCTAssertFalse(request.acceptsLightningInvoiceAmount(milliSatoshis: 2_500_001)) + } + + func testRefreshContinuesWhenPendingResponseDeliveryFails() async throws { + let sdk = try PaymentRequestSdkMock(records: [paymentRequestRecord()]) + await sdk.failNextProcess() + let manager = paymentRequestManager(sdk: sdk) + + await manager.refresh() + + XCTAssertEqual(manager.pendingRequests.count, 1) + let snapshot = await sdk.snapshot() + XCTAssertEqual(snapshot.processCallCount, 1) + XCTAssertEqual(snapshot.receiveCallCount, 1) + } + + func testFailedRefreshKeepsPreviouslyLoadedRequests() async throws { + let sdk = try PaymentRequestSdkMock(records: [paymentRequestRecord()]) + let manager = paymentRequestManager(sdk: sdk) + await manager.refresh() + await sdk.setRecords([]) + await sdk.setReceiveError(.receive) + + await manager.refresh() + + XCTAssertEqual(manager.pendingRequests.count, 1) + } + + func testManagerDropsRequestWhenItExpiresWithoutAnotherRefresh() async throws { + let expiresAt = Date().addingTimeInterval(2) + let sdk = try PaymentRequestSdkMock(records: [paymentRequestRecord(expiresAt: timestamp(expiresAt))]) + let now: @Sendable () -> Date = { Date() } + let manager = PaykitPaymentRequestManager( + service: PaykitPaymentRequestService(sdk: sdk, now: now, logWarning: { _ in }), + now: now, + logWarning: { _ in } + ) + await manager.refresh() + + XCTAssertEqual(manager.pendingRequests.count, 1) + try await waitUntil(timeout: .seconds(5)) { manager.pendingRequests.isEmpty } + } + + func testPresentedRequestRemainsPendingWithoutBeingPresentedAgain() async throws { + let sdk = try PaymentRequestSdkMock(records: [paymentRequestRecord()]) + let manager = paymentRequestManager(sdk: sdk) + await manager.refresh() + let request = try XCTUnwrap(manager.requestsForPresentation().first) + + XCTAssertTrue(manager.markPresentedIfPending(request)) + await manager.refresh() + + XCTAssertEqual(manager.pendingRequests, [request]) + XCTAssertTrue(manager.requestsForPresentation().isEmpty) + } + + func testExpiredRequestCannotBeMarkedPresented() async throws { + let now = Date(timeIntervalSince1970: 1_800_000_000) + let clock = PaymentRequestTestClock(now) + let sdk = try PaymentRequestSdkMock(records: [paymentRequestRecord(expiresAt: timestamp(now.addingTimeInterval(60)))]) + let manager = paymentRequestManager(sdk: sdk, clock: clock) + await manager.refresh() + let request = try XCTUnwrap(manager.requestsForPresentation().first) + clock.advance(by: 61) + + XCTAssertFalse(manager.markPresentedIfPending(request)) + XCTAssertTrue(manager.pendingRequests.isEmpty) + } + + func testAcceptQueuesResponseAndRemovesRequest() async throws { + let sharedId = "550e8400-e29b-41d4-a716-446655440000" + let firstRecord = try paymentRequestRecord(id: sharedId) + let secondRecord = try paymentRequestRecord( + id: sharedId, + counterpartyReceiverPath: PaykitReceiverPath.wallet + ) + let thirdRecord = try paymentRequestRecord(id: sharedId, counterparty: "pubkyother") + let fourthRecord = try paymentRequestRecord(id: "650e8400-e29b-41d4-a716-446655440000") + let remainingIds = [secondRecord, thirdRecord, fourthRecord].map { + PaykitPaymentRequest.ID( + paymentRequestId: $0.paymentRequestId, + counterparty: $0.counterparty, + counterpartyReceiverPath: $0.counterpartyReceiverPath + ) + } + let sdk = PaymentRequestSdkMock(records: [firstRecord, secondRecord, thirdRecord, fourthRecord]) + let manager = paymentRequestManager(sdk: sdk) + await manager.refresh() + let request = try XCTUnwrap(manager.pendingRequests.first(where: { + $0.paymentRequestId == firstRecord.paymentRequestId && + $0.counterparty == firstRecord.counterparty && + $0.counterpartyReceiverPath == firstRecord.counterpartyReceiverPath + })) + + try await manager.accept(request) + + XCTAssertEqual(manager.pendingRequests.map(\.id), remainingIds) + let snapshot = await sdk.snapshot() + XCTAssertEqual( + snapshot.acceptedRequests, + [PaymentRequestInvocation( + counterparty: request.counterparty, + counterpartyReceiverPath: request.counterpartyReceiverPath, + paymentRequestId: request.paymentRequestId + )] + ) + } + + func testAcceptRechecksExpirationImmediatelyBeforeAction() async throws { + let now = Date(timeIntervalSince1970: 1_800_000_000) + let clock = PaymentRequestTestClock(now) + let sdk = try PaymentRequestSdkMock(records: [paymentRequestRecord(expiresAt: timestamp(now.addingTimeInterval(60)))]) + let manager = paymentRequestManager(sdk: sdk, clock: clock) + await manager.refresh() + let request = try XCTUnwrap(manager.pendingRequests.first) + clock.advance(by: 61) + + do { + try await manager.accept(request) + XCTFail("Expected the expired request to be rejected locally") + } catch { + XCTAssertEqual(error as? PaykitPaymentRequestError, .requestExpired) + } + + XCTAssertTrue(manager.pendingRequests.isEmpty) + let snapshot = await sdk.snapshot() + XCTAssertTrue(snapshot.acceptedRequests.isEmpty) + } + + func testQueuedAcceptanceSucceedsWhenImmediateDeliveryFails() async throws { + let sdk = try PaymentRequestSdkMock(records: [paymentRequestRecord()]) + let manager = paymentRequestManager(sdk: sdk) + await manager.refresh() + let request = try XCTUnwrap(manager.pendingRequests.first) + await sdk.failNextProcess() + + try await manager.accept(request) + + XCTAssertTrue(manager.pendingRequests.isEmpty) + let snapshot = await sdk.snapshot() + XCTAssertEqual(snapshot.acceptedRequests.map(\.paymentRequestId), [request.paymentRequestId]) + XCTAssertEqual(snapshot.processCallCount, 2) + } + + func testQueuedAcceptanceSucceedsWhenImmediateDeliveryIsCancelled() async throws { + let sdk = try PaymentRequestSdkMock(records: [paymentRequestRecord()]) + let manager = paymentRequestManager(sdk: sdk) + await manager.refresh() + let request = try XCTUnwrap(manager.pendingRequests.first) + await sdk.cancelNextProcess() + + try await manager.accept(request) + + XCTAssertTrue(manager.pendingRequests.isEmpty) + let snapshot = await sdk.snapshot() + XCTAssertEqual(snapshot.acceptedRequests.map(\.paymentRequestId), [request.paymentRequestId]) + XCTAssertEqual(snapshot.processCallCount, 2) + } + + func testAcceptInvalidatesAnOverlappingRefreshSnapshot() 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.pauseNextPaymentRequestList() + + let refreshTask = Task { await manager.refresh() } + try await waitUntil { await sdk.paymentRequestListIsPaused() } + try await manager.accept(request) + await sdk.resumePaymentRequestList() + await refreshTask.value + + XCTAssertTrue(manager.pendingRequests.isEmpty) + } + + func testClearSuppressesStateChangesFromAnInFlightAccept() 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.pauseNextAccept() + + let acceptTask = Task { try await manager.accept(request) } + try await waitUntil { await sdk.acceptIsPaused() } + manager.clear() + await sdk.resumeAccept() + try await acceptTask.value + + XCTAssertTrue(manager.pendingRequests.isEmpty) + let snapshot = await sdk.snapshot() + XCTAssertEqual(snapshot.receiveCallCount, 1) + } + + private func paymentRequestManager( + sdk: PaymentRequestSdkMock, + clock: PaymentRequestTestClock = PaymentRequestTestClock(Date()) + ) -> PaykitPaymentRequestManager { + let now: @Sendable () -> Date = { clock.now() } + return PaykitPaymentRequestManager( + service: PaykitPaymentRequestService(sdk: sdk, now: now, logWarning: { _ in }), + now: now, + logWarning: { _ in } + ) + } + + private func paymentRequestRecord( + id: String = "550e8400-e29b-41d4-a716-446655440000", + counterparty: String = "pubkypayee", + counterpartyReceiverPath: String = PaykitReceiverPath.server, + state: PaymentRequestLifecycleState = .proposed, + role: PaymentRequestLocalRole? = .payer, + amount: String = "0.001", + asset: String = "btc", + expiresAt: String? = nil, + recurrence: PaymentRequestRecurrence? = nil, + endpoints: [String] = [PublicPaykitService.MethodId.bitcoinLightningBolt11.rawValue], + metadata: String = "{}" + ) throws -> PaymentRequestRecord { + try PaymentRequestRecord( + counterparty: counterparty, + counterpartyReceiverPath: counterpartyReceiverPath, + paymentRequestId: id, + localRole: role, + state: state, + proposalStreamItemId: 1, + proposalOutboundMessageId: nil, + proposalOutboundStatus: nil, + proposalEventId: "650e8400-e29b-41d4-a716-446655440000", + terms: PaymentRequestTerms( + amount: PaymentRequestAmount(value: amount, asset: asset), + paymentReference: PaymentReference(text: "invoice-123"), + proposalExpiresAt: expiresAt, + recurrence: recurrence, + acceptedPaymentEndpointIdentifiers: endpoints, + metadata: PrivateJsonObject(text: metadata) + ), + acceptedEventId: nil, + acceptedOutboundStatus: nil, + rejectedEventId: nil, + rejectedOutboundStatus: nil, + canceledEventId: nil, + canceledOutboundStatus: nil, + paymentProofs: [], + lastStreamItemId: 1, + lastOutboundMessageId: nil, + lastOutboundStatus: nil, + lastEventAt: "2027-01-15T08:00:00Z", + invalidReason: nil + ) + } + + private func timestamp(_ date: Date) -> String { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return formatter.string(from: date) + } +} + +private actor PaymentRequestSdkMock: PaykitPaymentRequestSdkHandling { + private var records: [PaymentRequestRecord] + private var processCallCount = 0 + private var receiveCallCount = 0 + private var processFailuresRemaining = 0 + private var processCancellationsRemaining = 0 + private var receiveError: PaymentRequestSdkMockError? + private var acceptedRequests: [PaymentRequestInvocation] = [] + private var shouldPauseNextPaymentRequestList = false + private var isPaymentRequestListPaused = false + private var paymentRequestListContinuation: CheckedContinuation? + private var shouldPauseNextAccept = false + private var isAcceptPaused = false + private var acceptContinuation: CheckedContinuation? + + init(records: [PaymentRequestRecord]) { + self.records = records + } + + func processPendingPrivateMessages() throws -> [OutboundPrivateCounterpartySendReport] { + processCallCount += 1 + if processCancellationsRemaining > 0 { + processCancellationsRemaining -= 1 + throw CancellationError() + } + guard processFailuresRemaining > 0 else { return [] } + processFailuresRemaining -= 1 + throw PaymentRequestSdkMockError.process + } + + func receivePrivateMessagesFromLinkedPeers() throws -> [PrivateStreamCounterpartyIntakeReport] { + receiveCallCount += 1 + if let receiveError { + throw receiveError + } + return [] + } + + func actionableReceivedPaymentRequests() async -> [PaymentRequestRecord] { + let snapshot = records + guard shouldPauseNextPaymentRequestList else { return snapshot } + + shouldPauseNextPaymentRequestList = false + isPaymentRequestListPaused = true + await withCheckedContinuation { paymentRequestListContinuation = $0 } + isPaymentRequestListPaused = false + return snapshot + } + + func acceptPaymentRequest( + counterparty: String, + counterpartyReceiverPath: String, + paymentRequestId: String + ) async throws -> PaymentRequestRecord { + if shouldPauseNextAccept { + shouldPauseNextAccept = false + isAcceptPaused = true + await withCheckedContinuation { acceptContinuation = $0 } + isAcceptPaused = false + } + + let record = try removeRecord( + counterparty: counterparty, + counterpartyReceiverPath: counterpartyReceiverPath, + id: paymentRequestId + ) + acceptedRequests.append(PaymentRequestInvocation( + counterparty: counterparty, + counterpartyReceiverPath: counterpartyReceiverPath, + paymentRequestId: paymentRequestId + )) + return record + } + + func failNextProcess() { + processFailuresRemaining += 1 + } + + func cancelNextProcess() { + processCancellationsRemaining += 1 + } + + func pauseNextPaymentRequestList() { + shouldPauseNextPaymentRequestList = true + } + + func paymentRequestListIsPaused() -> Bool { + isPaymentRequestListPaused + } + + func resumePaymentRequestList() { + paymentRequestListContinuation?.resume() + paymentRequestListContinuation = nil + } + + func pauseNextAccept() { + shouldPauseNextAccept = true + } + + func acceptIsPaused() -> Bool { + isAcceptPaused + } + + func resumeAccept() { + acceptContinuation?.resume() + acceptContinuation = nil + } + + func setRecords(_ records: [PaymentRequestRecord]) { + self.records = records + } + + func setReceiveError(_ error: PaymentRequestSdkMockError?) { + receiveError = error + } + + func snapshot() -> PaymentRequestSdkSnapshot { + PaymentRequestSdkSnapshot( + processCallCount: processCallCount, + receiveCallCount: receiveCallCount, + acceptedRequests: acceptedRequests + ) + } + + private func removeRecord( + counterparty: String, + counterpartyReceiverPath: String, + id: String + ) throws -> PaymentRequestRecord { + guard let index = records.firstIndex(where: { + $0.counterparty == counterparty && + $0.counterpartyReceiverPath == counterpartyReceiverPath && + $0.paymentRequestId == id + }) else { + throw PaymentRequestSdkMockError.requestMissing + } + return records.remove(at: index) + } +} + +private struct PaymentRequestSdkSnapshot { + let processCallCount: Int + let receiveCallCount: Int + let acceptedRequests: [PaymentRequestInvocation] +} + +private struct PaymentRequestInvocation: Equatable { + let counterparty: String + let counterpartyReceiverPath: String + let paymentRequestId: String +} + +private enum PaymentRequestSdkMockError: Error { + case process + case receive + case requestMissing +} + +private final class PaymentRequestTestClock: @unchecked Sendable { + private let lock = NSLock() + private var date: Date + + init(_ date: Date) { + self.date = date + } + + func now() -> Date { + lock.lock() + defer { lock.unlock() } + return date + } + + func advance(by interval: TimeInterval) { + lock.lock() + defer { lock.unlock() } + date = date.addingTimeInterval(interval) + } +} + +private enum PaymentRequestTestError: Error { + case timedOut +} + +@MainActor +private func waitUntil( + timeout: Duration = .seconds(2), + condition: @MainActor () async -> Bool +) async throws { + let clock = ContinuousClock() + let deadline = clock.now.advanced(by: timeout) + while await !condition() { + guard clock.now < deadline else { throw PaymentRequestTestError.timedOut } + try await Task.sleep(for: .milliseconds(10)) + } +} diff --git a/BitkitTests/PrivatePaykitServiceTests.swift b/BitkitTests/PrivatePaykitServiceTests.swift index bd1c265aa..1e4c86099 100644 --- a/BitkitTests/PrivatePaykitServiceTests.swift +++ b/BitkitTests/PrivatePaykitServiceTests.swift @@ -2,6 +2,21 @@ import XCTest final class PrivatePaykitServiceTests: XCTestCase { + func testReceiverNoiseDerivationMatchesCrossPlatformVector() { + let seed = ( + "c55257c360c07c72029aebc1b53c05ed0362ada38ead3e3e9efa3708e534955" + + "31f09a6987599d18264c1e1c92f2cf141630c7a3c4ab7c81b2f001698e7463b04" + ).hexaData + + let key = PaykitReceiverNoiseKeyDerivation.derive( + seed: seed, + network: "bitcoin", + receiverPath: PaykitReceiverPath.wallet + ) + + XCTAssertEqual(key.hex, "500f4799bbb2d02103e3b74b365ddb478a3187333c053fa9eb62f4052ba6a327") + } + func testDuplicatePaymentErrorClassificationUsesWrappedAppErrorReason() { XCTAssertTrue( PrivatePaykitService.isDuplicatePaymentError( @@ -192,6 +207,90 @@ final class PrivatePaykitServiceTests: XCTestCase { XCTAssertEqual(decodedContact.receivedInvoicePaymentHashes, ["received-hash"]) XCTAssertEqual(decodedContact.publishedPrivatePaymentReceiverPaths, [PaykitReceiverPath.wallet]) } + + func testConsumingPrivatePaymentListClearsEndpointsAndRejectsSameVersionForPair() async throws { + let defaults = UserDefaults.standard + let previousState = defaults.data(forKey: PrivatePaykitService.cacheStateKey) + defaults.removeObject(forKey: PrivatePaykitService.cacheStateKey) + defer { + if let previousState { + defaults.set(previousState, forKey: PrivatePaykitService.cacheStateKey) + } else { + defaults.removeObject(forKey: PrivatePaykitService.cacheStateKey) + } + } + + let service = PrivatePaykitService() + let publicKey = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" + let endpoint = PublicPaykitService.Endpoint( + methodId: .bitcoinLightningLnurl, + value: "lnurl1private", + min: nil, + max: nil, + rawPayload: #"{"value":"lnurl1private"}"# + ) + let context = PrivatePaykitPaymentContext(receiverPath: PaykitReceiverPath.wallet, paymentListVersion: 7) + + await service.cacheResolvedEndpoints([endpoint], publicKey: publicKey) + try await service.consumePrivatePaymentList(publicKey: publicKey, context: context) + + let contactState = await service.testContactState(publicKey: publicKey) + XCTAssertTrue(contactState?.cachedResolvedEndpoints.isEmpty == true) + XCTAssertEqual(contactState?.consumedPrivatePaymentListVersionsByReceiverPath[PaykitReceiverPath.wallet], 7) + + do { + try await service.consumePrivatePaymentList(publicKey: publicKey, context: context) + XCTFail("Expected the private payment list to be consumed only once") + } catch PrivatePaykitError.paymentListAlreadyConsumed { + // Expected. + } + } + + func testClearingContactStatePreservesConsumedPrivatePaymentListVersions() async throws { + let defaults = UserDefaults.standard + let previousState = defaults.data(forKey: PrivatePaykitService.cacheStateKey) + defaults.removeObject(forKey: PrivatePaykitService.cacheStateKey) + defer { + if let previousState { + defaults.set(previousState, forKey: PrivatePaykitService.cacheStateKey) + } else { + defaults.removeObject(forKey: PrivatePaykitService.cacheStateKey) + } + } + + let service = PrivatePaykitService() + let publicKey = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" + let endpoint = PublicPaykitService.Endpoint( + methodId: .bitcoinLightningLnurl, + value: "lnurl1private", + min: nil, + max: nil, + rawPayload: #"{"value":"lnurl1private"}"# + ) + let context = PrivatePaykitPaymentContext(receiverPath: PaykitReceiverPath.server, paymentListVersion: 9) + + await service.cacheResolvedEndpoints([endpoint], publicKey: publicKey) + try await service.consumePrivatePaymentList(publicKey: publicKey, context: context) + await service.clearContactState(publicKey: publicKey) + + let contactState = await service.testContactState(publicKey: publicKey) + XCTAssertEqual(contactState?.consumedPrivatePaymentListVersionsByReceiverPath, [PaykitReceiverPath.server: 9]) + XCTAssertFalse(contactState?.hasContactOwnedCacheState == true) + } + + func testPrivatePaymentRecoveryUsesRequestedReceiverPath() async { + let service = PrivatePaykitService() + let publicKey = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" + + await service.schedulePrivatePaymentRecovery(for: publicKey, receiverPath: PaykitReceiverPath.server) + + let retryKeys = await service.testPendingMessageDrainRetryKeys() + XCTAssertEqual( + retryKeys, + [PrivateMessageDrainRetryKey(publicKey: publicKey, receiverPath: PaykitReceiverPath.server)] + ) + await service.clearTestPendingMessageDrainRetries() + } } private extension PrivatePaykitService { @@ -199,4 +298,18 @@ private extension PrivatePaykitService { state.contacts[publicKey] = ContactState() state.contacts[publicKey]?.localInvoicesByReceiverPath[PaykitReceiverPath.wallet] = invoice } + + func testContactState(publicKey: String) -> ContactState? { + state.contacts[publicKey] + } + + func testPendingMessageDrainRetryKeys() -> Set { + pendingMessageDrainRetryKeys + } + + func clearTestPendingMessageDrainRetries() { + pendingMessageDrainRetryTask?.cancel() + pendingMessageDrainRetryTask = nil + pendingMessageDrainRetryKeys.removeAll() + } } diff --git a/BitkitTests/PublicPaykitServiceTests.swift b/BitkitTests/PublicPaykitServiceTests.swift index 7fb6a263d..a5e3b566c 100644 --- a/BitkitTests/PublicPaykitServiceTests.swift +++ b/BitkitTests/PublicPaykitServiceTests.swift @@ -152,9 +152,18 @@ final class PublicPaykitServiceTests: XCTestCase { } func testPaymentLaunchResultFailureMessageKeys() { - XCTAssertNil(PublicPaykitPaymentLaunchResult.opened(paymentRequest: "bitcoin:bcrt1ptest").contactPaymentFailureMessageKey) + XCTAssertNil( + PublicPaykitPaymentLaunchResult.opened( + paymentRequest: "bitcoin:bcrt1ptest", + privatePaymentContext: nil + ).contactPaymentFailureMessageKey + ) XCTAssertEqual(PublicPaykitPaymentLaunchResult.noEndpoint.contactPaymentFailureMessageKey, "slashtags__error_pay_empty_msg") XCTAssertEqual(PublicPaykitPaymentLaunchResult.notOpened.contactPaymentFailureMessageKey, "slashtags__error_pay_not_opened_msg") + XCTAssertEqual( + PublicPaykitPaymentLaunchResult.waitingForUpdatedPaymentList.contactPaymentFailureMessageKey, + "slashtags__error_pay_empty_msg" + ) } func testPayableEndpointsFiltersInvalidDecodedEndpoints() async { diff --git a/changelog.d/next/637.added.md b/changelog.d/next/637.added.md new file mode 100644 index 000000000..94f414e75 --- /dev/null +++ b/changelog.d/next/637.added.md @@ -0,0 +1 @@ +Incoming Paykit payment requests can now be reviewed and approved through the existing payment flow.