diff --git a/Bitkit/MainNavView.swift b/Bitkit/MainNavView.swift index 952848b33..f901371ad 100644 --- a/Bitkit/MainNavView.swift +++ b/Bitkit/MainNavView.swift @@ -865,8 +865,10 @@ struct MainNavView: View { ) } } catch { - Logger.error(error, context: "Failed to read data from clipboard") - showScanError() + if !(error is CancellationError) { + Logger.error(error, context: "Failed to read data from clipboard") + showScanError() + } } // Clear stored URI after processing @@ -976,6 +978,8 @@ struct MainNavView: View { sheetViewModel: sheets ) } + } catch is CancellationError { + return } catch { Logger.error(error, context: "Failed to handle deeplink") showScanError() diff --git a/Bitkit/Managers/ScannerManager.swift b/Bitkit/Managers/ScannerManager.swift index a10a75f20..b8444d1b2 100644 --- a/Bitkit/Managers/ScannerManager.swift +++ b/Bitkit/Managers/ScannerManager.swift @@ -48,15 +48,15 @@ class ScannerManager: ObservableObject { self.hwWalletManager = hwWalletManager } - func handleScan(_ uri: String, context: ScannerContext) async { - guard !isHandlingScan else { return } + func handleScan(_ uri: String, context: ScannerContext, isCurrent: () -> Bool = { true }) async { + guard !isHandlingScan, isCurrent() else { return } isHandlingScan = true defer { isHandlingScan = false } - await processScan(uri, context: context) + await processScan(uri, context: context, isCurrent: isCurrent) } - private func processScan(_ uri: String, context: ScannerContext) async { + private func processScan(_ uri: String, context: ScannerContext, isCurrent: () -> Bool) async { let uri = uri.trimmingCharacters(in: .whitespacesAndNewlines) guard !uri.isEmpty else { return } @@ -66,21 +66,21 @@ class ScannerManager: ObservableObject { handleAddContactScan(uri) case .main: playScanSuccess() - await handleMainScan(uri) + await handleMainScan(uri, isCurrent: isCurrent) case .send: - await processSendScan(uri) { _ in } + await processSendScan(uri, isCurrent: isCurrent) { _ in } case .electrum: playScanSuccess() await handleElectrumScan(uri) } } - func handleScan(_ payload: QRCodePayload, context: ScannerContext) async { + func handleScan(_ payload: QRCodePayload, context: ScannerContext, isCurrent: () -> Bool = { true }) async { guard let uri = payload.string else { showUnsupportedQRCodeError() return } - await handleScan(uri, context: context) + await handleScan(uri, context: context, isCurrent: isCurrent) } private func handleAddContactScan(_ input: String) { @@ -93,7 +93,7 @@ class ScannerManager: ObservableObject { navigation?.navigate(.addContact(publicKey: input)) } - private func handleMainScan(_ uri: String) async { + private func handleMainScan(_ uri: String, isCurrent: () -> Bool) async { guard let app else { return } do { @@ -113,9 +113,10 @@ class ScannerManager: ObservableObject { try await app.handleScannedData( uri, - alternativeOnchainBalanceSats: hwWalletManager?.maximumFundingBalanceSats ?? 0 + alternativeOnchainBalanceSats: hwWalletManager?.maximumFundingBalanceSats ?? 0, + isCurrentScan: isCurrent ) - guard shouldOpenPaymentFlow(for: uri) else { return } + guard isCurrent(), shouldOpenPaymentFlow(for: uri) else { return } if let currency, let settings, let sheets { PaymentNavigationHelper.openPaymentSheet( @@ -126,6 +127,7 @@ class ScannerManager: ObservableObject { ) } } catch { + guard isCurrent(), !(error is CancellationError) else { return } Logger.error(error, context: "Failed to read data from QR") app.toast( type: .error, @@ -164,18 +166,20 @@ class ScannerManager: ObservableObject { func handleSendScan( _ uri: String, scope: ScanHandlingScope = .unrestricted, + isCurrent: () -> Bool = { true }, completion: @escaping (SendRoute?) -> Void ) async { - guard !isHandlingScan else { return } + guard !isHandlingScan, isCurrent() else { return } isHandlingScan = true defer { isHandlingScan = false } - await processSendScan(uri, scope: scope, completion: completion) + await processSendScan(uri, scope: scope, isCurrent: isCurrent, completion: completion) } private func processSendScan( _ uri: String, scope: ScanHandlingScope = .unrestricted, + isCurrent: () -> Bool = { true }, completion: @escaping (SendRoute?) -> Void ) async { guard let app, let currency, let settings else { @@ -212,8 +216,11 @@ class ScannerManager: ObservableObject { try await app.handleScannedData( uri, scope: scope, - alternativeOnchainBalanceSats: hwWalletManager?.maximumFundingBalanceSats ?? 0 + alternativeOnchainBalanceSats: hwWalletManager?.maximumFundingBalanceSats ?? 0, + sendPresentationID: sheets?.activeSheetConfiguration?.presentationID, + isCurrentScan: isCurrent ) + guard isCurrent() else { return } guard shouldOpenPaymentFlow(for: uri) else { completion(nil) return @@ -227,6 +234,7 @@ class ScannerManager: ObservableObject { completion(route) } catch { + guard isCurrent(), !(error is CancellationError) else { return } Logger.error(error, context: "Failed to read data from QR") app.toast( type: .error, @@ -240,6 +248,7 @@ class ScannerManager: ObservableObject { func handleSendScan( _ payload: QRCodePayload, scope: ScanHandlingScope = .unrestricted, + isCurrent: () -> Bool = { true }, completion: @escaping (SendRoute?) -> Void ) async { guard let uri = payload.string else { @@ -247,7 +256,7 @@ class ScannerManager: ObservableObject { completion(nil) return } - await handleSendScan(uri, scope: scope, completion: completion) + await handleSendScan(uri, scope: scope, isCurrent: isCurrent, completion: completion) } private func shouldOpenPaymentFlow(for uri: String) -> Bool { @@ -311,34 +320,59 @@ class ScannerManager: ObservableObject { scope: ScanHandlingScope = .unrestricted, completion: @escaping (SendRoute?) -> Void = { _ in } ) async { - guard let app, let item else { return } + guard let item else { return } + await handleImageScan(context: context, scope: scope, decode: { + try await QRCodeImageDecoder.decode(item) + }, completion: completion) + } + + func handleImageScan( + context: ScannerContext, + scope: ScanHandlingScope = .unrestricted, + decode: () async throws -> QRCodePayload, + completion: @escaping (SendRoute?) -> Void = { _ in } + ) async { + guard let app else { return } + let presentationID = sheets?.activeSheetConfiguration?.presentationID + let navigationPath = navigation?.path + let inputGeneration = app.sendStateGeneration + func isCurrentOrigin() -> Bool { + !Task.isCancelled && sheets?.activeSheetConfiguration?.presentationID == presentationID && + navigation?.path == navigationPath + } do { - let payload = try await QRCodeImageDecoder.decode(item) + let payload = try await decode() + guard isCurrentOrigin(), context != .send || app.sendStateGeneration == inputGeneration else { return } if context == .send { - await handleSendScan(payload, scope: scope, completion: completion) + await handleSendScan(payload, scope: scope, isCurrent: isCurrentOrigin, completion: completion) } else { - await handleScan(payload, context: context) + await handleScan(payload, context: context, isCurrent: isCurrentOrigin) } - } catch QRCodeImageDecoderError.invalidImage { - app.toast( - type: .error, - title: t("common__error"), - description: t("other__qr_error_load_image") - ) - } catch QRCodeImageDecoderError.noQRCode { - app.toast( - type: .error, - title: t("other__qr_error_no_qr_title"), - description: t("other__qr_error_no_qr_description") - ) } catch { - Logger.error(error, context: "Failed to process image") - app.toast( - type: .error, - title: t("other__qr_error_detection_title"), - description: t("other__qr_error_detection_description") - ) + guard isCurrentOrigin(), context != .send || app.sendStateGeneration == inputGeneration, + !(error is CancellationError) else { return } + switch error { + case QRCodeImageDecoderError.invalidImage: + app.toast( + type: .error, + title: t("common__error"), + description: t("other__qr_error_load_image") + ) + case QRCodeImageDecoderError.noQRCode: + app.toast( + type: .error, + title: t("other__qr_error_no_qr_title"), + description: t("other__qr_error_no_qr_description") + ) + default: + Logger.error(error, context: "Failed to process image") + app.toast( + type: .error, + title: t("other__qr_error_detection_title"), + description: t("other__qr_error_detection_description") + ) + } } } diff --git a/Bitkit/Utilities/QuickPayPaymentCoordinator.swift b/Bitkit/Utilities/QuickPayPaymentCoordinator.swift index b40abc93c..f1552bb33 100644 --- a/Bitkit/Utilities/QuickPayPaymentCoordinator.swift +++ b/Bitkit/Utilities/QuickPayPaymentCoordinator.swift @@ -5,6 +5,12 @@ import LDKNode final class QuickPayPaymentCoordinator { static let shared = QuickPayPaymentCoordinator() + struct SessionID: Equatable { + let presentationID: UUID + let sendStateGeneration: UUID + let attempt: Int + } + struct Presentation { var appendRoute: (SendRoute) -> Void var replaceQuickPay: (SendRoute) -> Void @@ -44,6 +50,10 @@ final class QuickPayPaymentCoordinator { private var operations: [String: InFlightOp] = [:] private var generation = UUID() private var payRequested = false + private var sessionID: SessionID? + private var activePresentation: Presentation? + /// Settlement can remove the ledger entry before dispatch returns its UI result. + private var currentOperation: InFlightOp? var liveSubmittingHashes: Set { Set(operations.values.map(\.invoiceHash)) @@ -71,6 +81,10 @@ final class QuickPayPaymentCoordinator { func detach() { generation = UUID() payRequested = false + sessionID = nil + activePresentation = nil + currentOperation?.presentation = nil + currentOperation = nil for op in uniqueOps { op.presentation = nil } @@ -81,19 +95,31 @@ final class QuickPayPaymentCoordinator { wallet: WalletViewModel, settings: SettingsViewModel, currency: CurrencyViewModel, - presentation: Presentation + presentation: Presentation, + sessionID: SessionID? = nil ) { + if let sessionID { + if self.sessionID == sessionID { + activePresentation = presentation + currentOperation?.presentation = presentation + return + } + detach() + self.sessionID = sessionID + } guard !payRequested else { return } payRequested = true + activePresentation = presentation let capturedGeneration = generation + let sendStateGeneration = app.sendStateGeneration Task { await run( generation: capturedGeneration, + sendStateGeneration: sendStateGeneration, app: app, wallet: wallet, settings: settings, - currency: currency, - presentation: presentation + currency: currency ) } } @@ -190,13 +216,13 @@ final class QuickPayPaymentCoordinator { private func run( generation: UUID, + sendStateGeneration: UUID, app: AppViewModel, wallet: WalletViewModel, settings: SettingsViewModel, - currency: CurrencyViewModel, - presentation: Presentation + currency: CurrencyViewModel ) async { - guard generation == self.generation else { return } + guard generation == self.generation, app.sendStateGeneration == sendStateGeneration else { return } var bolt11: String? do { @@ -211,12 +237,14 @@ final class QuickPayPaymentCoordinator { bolt11 = scannedInvoice.bolt11 } } catch { - guard generation == self.generation else { return } + guard generation == self.generation, app.sendStateGeneration == sendStateGeneration, + let presentation = activePresentation else { return } fail(presentation, error: error, bolt11: nil) return } - guard generation == self.generation else { return } + guard generation == self.generation, app.sendStateGeneration == sendStateGeneration, + let presentation = activePresentation else { return } guard let bolt11 else { fail(presentation, error: AppError(message: t("common__error_body"), debugMessage: "No Lightning invoice found"), bolt11: nil) return @@ -233,6 +261,7 @@ final class QuickPayPaymentCoordinator { app.beginQuickPay(paymentHash: invoiceHash) if let existing = operations[invoiceHash] { + currentOperation = existing existing.presentation = presentation if existing.emitted { replayPending(existing) @@ -463,6 +492,7 @@ final class QuickPayPaymentCoordinator { } private func register(_ op: InFlightOp) { + currentOperation = op operations[op.invoiceHash] = op if let paymentId = op.paymentId, !paymentId.isEmpty, paymentId != op.invoiceHash { operations[paymentId] = op diff --git a/Bitkit/ViewModels/AppViewModel.swift b/Bitkit/ViewModels/AppViewModel.swift index 330a38f44..b273656da 100644 --- a/Bitkit/ViewModels/AppViewModel.swift +++ b/Bitkit/ViewModels/AppViewModel.swift @@ -94,6 +94,7 @@ struct ScanPaymentState { struct ScanPaymentOperations { let state: () -> ScanPaymentState let canSendLightning: (_ amountSats: UInt64) -> Bool + var decodeInvoice: (String) async throws -> BitkitCore.Scanner = { try await decode(invoice: $0) } @MainActor static func live(lightningService: LightningService) -> ScanPaymentOperations { @@ -159,6 +160,9 @@ class AppViewModel: ObservableObject { // LNURL @Published var lnurlPayData: LnurlPayData? @Published var lnurlWithdrawData: LnurlWithdrawData? + @Published private(set) var sendStateGeneration = UUID() + // Only a scan started inside this presentation may transfer cleanup ownership to it. + private(set) var sendStateOwnerID: UUID? @Published private(set) var pendingDeepLinkURL: URL? /// The next link, held while a Pubky sign-in link waits or its approval sheet is open, so it cannot drop the sign-in. @@ -358,7 +362,11 @@ class AppViewModel: ObservableObject { private let navigationViewModel: NavigationViewModel private let scanPaymentOperations: ScanPaymentOperations private let usdtBalance: () -> UInt64? - private var scannedDataHandlingId: UUID? + @Published private var scannedDataHandlingId: UUID? + var isHandlingScannedData: Bool { + scannedDataHandlingId != nil + } + private var manualEntryValidationSequence: UInt64 = 0 // Combine infrastructure for debounced validation @@ -740,8 +748,15 @@ extension AppViewModel { _ uri: String, claimedContactPaymentContext: ContactPaymentContext? = nil, scope: ScanHandlingScope = .unrestricted, - alternativeOnchainBalanceSats: UInt64 = 0 + alternativeOnchainBalanceSats: UInt64 = 0, + sendPresentationID: UUID? = nil, + isCurrentScan: () -> Bool = { true } ) async throws { + guard isCurrentScan(), !Task.isCancelled else { throw CancellationError() } + var expectedGeneration = sendStateGeneration + func isCurrentScanGeneration() -> Bool { + isCurrentScan() && sendStateGeneration == expectedGeneration + } let handlingId = claimedContactPaymentContext?.id ?? UUID() if let claimedContactPaymentContext { guard ownsContactPaymentContext(claimedContactPaymentContext), scannedDataHandlingId == nil else { @@ -757,15 +772,17 @@ extension AppViewModel { } if let context = claimedContactPaymentContext, context.requiresAssetSelection, scope != .onchainPayments { - resetSendState(preservingContactPaymentContext: true) + resetSendState(preservingContactPaymentContext: true, ownerID: sendPresentationID) + expectedGeneration = sendStateGeneration for endpoint in context.endpoints where endpoint.methodId != .usdtArbitrum { - switch try await decode(invoice: endpoint.paymentRequest) { + let data = try await scanPaymentOperations.decodeInvoice(endpoint.paymentRequest) + try ensureScannedDataHandlingOwnership(handlingId, claimedContactPaymentContext: context, isCurrentScan: isCurrentScanGeneration) + switch data { case let .onChain(invoice): scannedOnchainInvoice = invoice case let .lightning(invoice): scannedLightningInvoice = invoice case let .lnurlPay(data): lnurlPayData = data default: break } - try ensureScannedDataHandlingOwnership(handlingId, claimedContactPaymentContext: context) } let state = scanPaymentOperations.state() let lightningMethod: PublicPaykitService.MethodId = lnurlPayData == nil ? .bitcoinLightningBolt11 : .bitcoinLightningLnurl @@ -820,8 +837,12 @@ extension AppViewModel { ) return } - let data = try await decode(invoice: uri) - try ensureScannedDataHandlingOwnership(handlingId, claimedContactPaymentContext: claimedContactPaymentContext) + let data = try await scanPaymentOperations.decodeInvoice(uri) + try ensureScannedDataHandlingOwnership( + handlingId, + claimedContactPaymentContext: claimedContactPaymentContext, + isCurrentScan: isCurrentScanGeneration + ) guard ShopPaymentRequest.isSupported(data) else { throw ScanHandlingError.unsupportedRequest } prevalidatedPaymentRequest = data } else { @@ -829,7 +850,8 @@ extension AppViewModel { } // Reset send state before handling new data - resetSendState(preservingContactPaymentContext: claimedContactPaymentContext != nil) + resetSendState(preservingContactPaymentContext: claimedContactPaymentContext != nil, ownerID: sendPresentationID) + expectedGeneration = sendStateGeneration if scope == .unrestricted, let samRockSetup = SamRockSetupRequest.parse(uri) { handleBTCPayConnection(samRockSetup) @@ -873,8 +895,12 @@ extension AppViewModel { if let prevalidatedPaymentRequest { data = prevalidatedPaymentRequest } else { - data = try await decode(invoice: uri) - try ensureScannedDataHandlingOwnership(handlingId, claimedContactPaymentContext: claimedContactPaymentContext) + data = try await scanPaymentOperations.decodeInvoice(uri) + try ensureScannedDataHandlingOwnership( + handlingId, + claimedContactPaymentContext: claimedContactPaymentContext, + isCurrentScan: isCurrentScanGeneration + ) } let paymentState = scanPaymentOperations.state() @@ -903,8 +929,12 @@ extension AppViewModel { if scope != .onchainPayments, let lnInvoice = invoice.params?["lightning"] { // Lightning invoice param found, prefer lightning payment if invoice is valid - let lightningData = try await decode(invoice: lnInvoice) - try ensureScannedDataHandlingOwnership(handlingId, claimedContactPaymentContext: claimedContactPaymentContext) + let lightningData = try await scanPaymentOperations.decodeInvoice(lnInvoice) + try ensureScannedDataHandlingOwnership( + handlingId, + claimedContactPaymentContext: claimedContactPaymentContext, + isCurrentScan: isCurrentScanGeneration + ) if case let .lightning(lightningInvoice) = lightningData { // Check lightning invoice network let lnNetwork = NetworkValidationHelper.convertNetworkType(lightningInvoice.networkType) @@ -1079,9 +1109,10 @@ extension AppViewModel { private func ensureScannedDataHandlingOwnership( _ handlingId: UUID, - claimedContactPaymentContext: ContactPaymentContext? + claimedContactPaymentContext: ContactPaymentContext?, + isCurrentScan: () -> Bool ) throws { - guard scannedDataHandlingId == handlingId else { throw CancellationError() } + guard scannedDataHandlingId == handlingId, isCurrentScan(), !Task.isCancelled else { throw CancellationError() } if let claimedContactPaymentContext { guard ownsContactPaymentContext(claimedContactPaymentContext) else { throw CancellationError() } } @@ -1303,7 +1334,9 @@ extension AppViewModel { paykitUsesUsdt || scannedLightningInvoice != nil || scannedOnchainInvoice != nil || lnurlPayData != nil } - func resetSendState(preservingContactPaymentContext: Bool = false) { + func resetSendState(preservingContactPaymentContext: Bool = false, ownerID: UUID? = nil) { + sendStateOwnerID = ownerID + sendStateGeneration = UUID() paykitUsesUsdt = false paykitReviewedAmount = nil paykitPaymentTerms = nil diff --git a/Bitkit/ViewModels/SheetViewModel.swift b/Bitkit/ViewModels/SheetViewModel.swift index 98cc53de7..6cc694616 100644 --- a/Bitkit/ViewModels/SheetViewModel.swift +++ b/Bitkit/ViewModels/SheetViewModel.swift @@ -36,13 +36,35 @@ struct SheetConfiguration { let id: SheetID let data: Any? let presentationID = UUID() + let sendState: SendPresentationState? + + init(id: SheetID, data: Any?) { + self.id = id + self.data = data + if id == .send { + let item = data as? SendSheetItem + let route = (data as? SendConfig)?.initialRoute ?? item?.initialRoute ?? .options + sendState = item?.state ?? SendPresentationState(initialRoute: route) + } else { + sendState = nil + } + } } class SheetViewModel: ObservableObject { /// How long a closing sheet animates out before the next sheet may be shown. static let replacementDelaySeconds: TimeInterval = 0.7 - @Published var activeSheetConfiguration: SheetConfiguration? = nil + @Published var activeSheetConfiguration: SheetConfiguration? = nil { + didSet { + if oldValue?.presentationID != activeSheetConfiguration?.presentationID { + let cleanup = oldValue?.sendState?.onDismiss + oldValue?.sendState?.onDismiss = nil + cleanup?() + } + } + } + @Published var hardwareConnectHandlesPairing = false @Published private(set) var isReplacingSheet = false @Published private(set) var replacingSheetID: SheetID? @@ -498,6 +520,8 @@ class SheetViewModel: ObservableObject { let sendConfig = config.data as? SendConfig let initialRoute = sendConfig?.initialRoute ?? .options return SendSheetItem( + presentationID: config.presentationID, + state: config.sendState, initialRoute: initialRoute, hardwareWalletId: sendConfig?.hardwareWalletId, preparation: sendConfig?.preparation diff --git a/Bitkit/Views/Subscriptions/SubscriptionsView.swift b/Bitkit/Views/Subscriptions/SubscriptionsView.swift index 24e25c5b1..f5e64aad8 100644 --- a/Bitkit/Views/Subscriptions/SubscriptionsView.swift +++ b/Bitkit/Views/Subscriptions/SubscriptionsView.swift @@ -631,7 +631,10 @@ struct SubscriptionSheet: View { case let .cancel(subscription): cancel(subscription) case let .payment(sendRoute): - SendSheet(config: SendSheetItem(initialRoute: sendRoute), isEmbedded: true) + SendSheet( + config: SendSheetItem(presentationID: sheets.activeSheetConfiguration?.presentationID, initialRoute: sendRoute), + isEmbedded: true + ) case .allowanceContact: AllowanceContactView { route = .allowanceSet($0) } case let .allowanceSet(contact): diff --git a/Bitkit/Views/Wallets/Send/LnurlPayAmount.swift b/Bitkit/Views/Wallets/Send/LnurlPayAmount.swift index 6cd15e0fc..07e14e413 100644 --- a/Bitkit/Views/Wallets/Send/LnurlPayAmount.swift +++ b/Bitkit/Views/Wallets/Send/LnurlPayAmount.swift @@ -7,11 +7,17 @@ struct LnurlPayAmount: View { @Binding var navigationPath: [SendRoute] - @State private var amountViewModel = AmountInputViewModel() + @State private var amountViewModel: AmountInputViewModel + + init(navigationPath: Binding<[SendRoute]>, amountViewModel: AmountInputViewModel? = nil) { + _navigationPath = navigationPath + _amountViewModel = State(initialValue: amountViewModel ?? AmountInputViewModel()) + } var maxAmount: UInt64 { // TODO: subtract fee - min(app.lnurlPayData!.maxSendableSat, UInt64(wallet.totalLightningSats)) + guard let data = app.lnurlPayData else { return 0 } + return min(data.maxSendableSat, UInt64(wallet.totalLightningSats)) } var amount: UInt64 { @@ -19,7 +25,7 @@ struct LnurlPayAmount: View { } var isValid: Bool { - amount >= 0 && amount <= maxAmount + app.lnurlPayData != nil && amount >= 0 && amount <= maxAmount } var body: some View { @@ -90,8 +96,9 @@ struct LnurlPayAmount: View { } private func showMaxExceededToast() { + guard let data = app.lnurlPayData else { return } // The cap is min(invoice max, spending balance); word the message for whichever bound is hit. - let isInvoiceCapped = app.lnurlPayData!.maxSendableSat < UInt64(wallet.totalLightningSats) + let isInvoiceCapped = data.maxSendableSat < UInt64(wallet.totalLightningSats) app.toast( type: .warning, @@ -103,7 +110,8 @@ struct LnurlPayAmount: View { } private func onContinue() { - let minSendableSats = max(1, app.lnurlPayData!.minSendableSat) + guard let data = app.lnurlPayData else { return } + let minSendableSats = max(1, data.minSendableSat) if amount < minSendableSats { app.toast( diff --git a/Bitkit/Views/Wallets/Send/LnurlPayConfirm.swift b/Bitkit/Views/Wallets/Send/LnurlPayConfirm.swift index 8b6b2be27..5f001edb5 100644 --- a/Bitkit/Views/Wallets/Send/LnurlPayConfirm.swift +++ b/Bitkit/Views/Wallets/Send/LnurlPayConfirm.swift @@ -166,7 +166,7 @@ struct LnurlPayConfirm: View { ? t("subscriptions__swipe_to_subscribe_and_pay") : t("wallet__send_swipe"), accentColor: .greenAccent, - isLoading: hasStartedAutomaticPayment, + isLoading: hasStartedAutomaticPayment || isSubmittingPayment, isSubmitted: hasStartedAutomaticPayment ) { try await submitPayment() @@ -209,7 +209,21 @@ struct LnurlPayConfirm: View { } } + @MainActor + static func withSubmissionLock(isSubmitting: Binding, operation: () async throws -> Void) async throws { + guard !isSubmitting.wrappedValue else { return } + isSubmitting.wrappedValue = true + defer { isSubmitting.wrappedValue = false } + try await operation() + } + private func submitPayment() async throws { + try await Self.withSubmissionLock(isSubmitting: $isSubmittingPayment) { + try await authorizeAndPerformPayment() + } + } + + private func authorizeAndPerformPayment() async throws { // Check if we need to show warning for amounts over $100 USD if settings.warnWhenSendingOver100 { let sats: UInt64 = if let invoice = app.scannedLightningInvoice { @@ -263,8 +277,6 @@ struct LnurlPayConfirm: View { } private func performPayment() async throws { - isSubmittingPayment = true - defer { isSubmittingPayment = false } guard let lnurlPayData = app.lnurlPayData else { throw NSError(domain: "LNURL", code: -1, userInfo: [NSLocalizedDescriptionKey: "Missing LNURL pay data"]) } diff --git a/Bitkit/Views/Wallets/Send/SendContactSelectView.swift b/Bitkit/Views/Wallets/Send/SendContactSelectView.swift index 3ed4ab90a..7079e3254 100644 --- a/Bitkit/Views/Wallets/Send/SendContactSelectView.swift +++ b/Bitkit/Views/Wallets/Send/SendContactSelectView.swift @@ -56,6 +56,7 @@ struct SendContactSelectView: View { private func pay(_ contact: PubkyContact) async { guard selectedContactKey == nil else { return } + let presentationID = sheets.activeSheetConfiguration?.presentationID selectedContactKey = contact.publicKey defer { selectedContactKey = nil } @@ -68,7 +69,8 @@ struct SendContactSelectView: View { paymentRequest: paymentRequest, publicKey: contact.publicKey, privatePaymentContext: privatePaymentContext, - endpoints: endpoints + endpoints: endpoints, + presentationID: presentationID ) case .noEndpoint, .notOpened, .privateLinkPending, .waitingForUpdatedPaymentList: if let messageKey = result.contactPaymentFailureMessageKey { @@ -92,8 +94,10 @@ struct SendContactSelectView: View { paymentRequest: String, publicKey: String, privatePaymentContext: PrivatePaykitPaymentContext?, - endpoints: [PublicPaykitService.Endpoint] + endpoints: [PublicPaykitService.Endpoint], + presentationID: UUID? ) async -> Bool { + guard let presentationID, sheets.activeSheetConfiguration?.presentationID == presentationID else { return false } let contactPaymentContext = ContactPaymentContext( publicKey: publicKey, privatePaymentContext: privatePaymentContext, @@ -106,16 +110,17 @@ struct SendContactSelectView: View { paymentRequest, claimedContactPaymentContext: contactPaymentContext, scope: hwSend.isActive ? .onchainPayments : .unrestricted, - alternativeOnchainBalanceSats: hwWalletManager.maximumFundingBalanceSats + alternativeOnchainBalanceSats: hwWalletManager.maximumFundingBalanceSats, + sendPresentationID: presentationID ) } catch is CancellationError { if app.ownsContactPaymentContext(contactPaymentContext) { - app.resetSendState() + app.resetSendState(ownerID: presentationID) } return false } catch { guard app.ownsContactPaymentContext(contactPaymentContext) else { return false } - app.resetSendState() + app.resetSendState(ownerID: presentationID) Logger.warn("Failed to decode contact payment request: \(error)", context: "SendContactSelectView") app.toast( type: .warning, @@ -125,9 +130,10 @@ struct SendContactSelectView: View { return false } - guard app.ownsContactPaymentContext(contactPaymentContext) else { return false } + guard sheets.activeSheetConfiguration?.presentationID == presentationID, + app.ownsContactPaymentContext(contactPaymentContext) else { return false } guard let route = PaymentNavigationHelper.contactPaymentRoute(app: app, currency: currency, settings: settings) else { - app.resetSendState() + app.resetSendState(ownerID: presentationID) return false } diff --git a/Bitkit/Views/Wallets/Send/SendEnterManuallyView.swift b/Bitkit/Views/Wallets/Send/SendEnterManuallyView.swift index e1a053b2d..4571274d1 100644 --- a/Bitkit/Views/Wallets/Send/SendEnterManuallyView.swift +++ b/Bitkit/Views/Wallets/Send/SendEnterManuallyView.swift @@ -75,6 +75,7 @@ struct SendEnterManuallyView: View { let uri = app.normalizeManualEntry(app.manualEntryInput).removingLightningSchemes() guard !uri.isEmpty, app.isManualEntryInputValid else { return } + let presentationID = sheets.activeSheetConfiguration?.presentationID if let route = resolvePubkyRoute( input: uri, @@ -104,10 +105,12 @@ struct SendEnterManuallyView: View { Logger.error("Failed to set default fee rate: \(error)") } + guard sheets.activeSheetConfiguration?.presentationID == presentationID else { return } try await app.handleScannedData( uri, scope: hwSend.isActive ? .onchainPayments : .unrestricted, - alternativeOnchainBalanceSats: hwWalletManager.maximumFundingBalanceSats + alternativeOnchainBalanceSats: hwWalletManager.maximumFundingBalanceSats, + sendPresentationID: presentationID ) if let route = PaymentNavigationHelper.appropriateSendRoute( diff --git a/Bitkit/Views/Wallets/Send/SendQuickpay.swift b/Bitkit/Views/Wallets/Send/SendQuickpay.swift index b27a17cb8..c3f95a0d6 100644 --- a/Bitkit/Views/Wallets/Send/SendQuickpay.swift +++ b/Bitkit/Views/Wallets/Send/SendQuickpay.swift @@ -9,7 +9,8 @@ struct SendQuickpay: View { @Binding var navigationPath: [SendRoute] let routingCacheResetAttempted: Bool var replaceQuickPay: (SendRoute) -> Void - @State private var didStartPayment = false + let sessionID: QuickPayPaymentCoordinator.SessionID + let isCurrentPresentation: () -> Bool @State private var displayedSats: UInt64? private var paymentSats: UInt64? { @@ -39,11 +40,10 @@ struct SendQuickpay: View { .sheetBackground() .frame(maxWidth: .infinity, maxHeight: .infinity) .onAppear { + guard isCurrentPresentation(), !app.isHandlingScannedData else { return } if displayedSats == nil { displayedSats = app.lnurlPayData?.minSendableSat ?? app.scannedLightningInvoice?.amountSatoshis } - guard !didStartPayment else { return } - didStartPayment = true QuickPayPaymentCoordinator.shared.pay( app: app, wallet: wallet, @@ -54,11 +54,9 @@ struct SendQuickpay: View { replaceQuickPay: replaceQuickPay, addPendingPaymentHash: { app.addPendingPaymentHash($0) }, routingCacheResetAttempted: routingCacheResetAttempted - ) + ), + sessionID: sessionID ) } - .onDisappear { - QuickPayPaymentCoordinator.shared.detach() - } } } diff --git a/Bitkit/Views/Wallets/Send/SendSheet.swift b/Bitkit/Views/Wallets/Send/SendSheet.swift index d52a201e4..873227c3a 100644 --- a/Bitkit/Views/Wallets/Send/SendSheet.swift +++ b/Bitkit/Views/Wallets/Send/SendSheet.swift @@ -80,6 +80,50 @@ enum SendRoute: Hashable { case lnurlWithdrawFailure(amount: UInt64) } +@Observable +final class SendPresentationState { + var rootRoute: SendRoute + var rootRouteGeneration = 0 + var navigationPath: [SendRoute] = [] + var rootOverride: SendRoute? + var quickPaySession = 0 + let quickPayPresentationID = UUID() + var hasValidatedAfterSync = false + var incomingPaymentRequest: PaykitPaymentRequest? + var isSubmittingPayment = false + var pendingEmbeddedRetryRoute: SendRoute? + var routingCacheResetAttempted = false + var syncTimedOut = false + var isResumingAcceptedOrdinarySend = false + var pinCheckContinuations: [CheckedContinuation] = [] + var setupTask: Task? + var usdtDetails: String? + var isSetupPending = true + var isFallbackFundingPending = false + var ownedSendStateGeneration: UUID? + var didStartSetup = false + var didFinish = false + var onDismiss: (() -> Void)? + @ObservationIgnored var hwSend: HwSendCoordinator? + @ObservationIgnored private(set) var lnurlAmountInput: AmountInputViewModel? + @ObservationIgnored private var lnurlAmountGeneration: UUID? + + @MainActor + func amountInput(for generation: UUID) -> AmountInputViewModel { + if lnurlAmountGeneration == generation, let lnurlAmountInput { + return lnurlAmountInput + } + let input = AmountInputViewModel() + lnurlAmountInput = input + lnurlAmountGeneration = generation + return input + } + + init(initialRoute: SendRoute) { + rootRoute = initialRoute + } +} + struct SendConfig { let initialRoute: SendRoute let hardwareWalletId: String? @@ -100,13 +144,24 @@ struct SendConfig { } struct SendSheetItem: SheetItem { - let id: SheetID = .send + let state: SendPresentationState + let id: UUID + let presentationID: UUID? let size: SheetSize = .large let initialRoute: SendRoute let hardwareWalletId: String? let preparation: IncomingPaykitPaymentRequestPreparation? - init(initialRoute: SendRoute = .options, hardwareWalletId: String? = nil, preparation: IncomingPaykitPaymentRequestPreparation? = nil) { + init( + presentationID: UUID? = nil, + state: SendPresentationState? = nil, + initialRoute: SendRoute = .options, + hardwareWalletId: String? = nil, + preparation: IncomingPaykitPaymentRequestPreparation? = nil + ) { + self.presentationID = presentationID + self.state = state ?? SendPresentationState(initialRoute: initialRoute) + id = presentationID ?? UUID() self.initialRoute = initialRoute self.hardwareWalletId = hardwareWalletId self.preparation = preparation @@ -128,47 +183,48 @@ struct SendSheet: View { let config: SendSheetItem let isEmbedded: Bool - @State private var rootRoute: SendRoute - @State private var rootRouteGeneration = 0 - @State private var navigationPath: [SendRoute] = [] - @State private var rootOverride: SendRoute? - @State private var quickPaySession = 0 - @State private var hasValidatedAfterSync = false - @State private var incomingPaymentRequest: PaykitPaymentRequest? - @State private var isSubmittingPayment = false - @State private var pendingEmbeddedRetryRoute: SendRoute? - @State private var routingCacheResetAttempted = false - @State private var syncTimedOut = false - @State private var isResumingAcceptedOrdinarySend = false - @State private var pinCheckContinuations: [CheckedContinuation] = [] + @State private var flow: SendPresentationState @State private var hwSend: HwSendCoordinator - @State private var setupTask: Task? - @State private var usdtDetails: String? - /// Starts pending so a confirmation rendered before `setUpSend` runs does not read setup as finished. - @State private var isSetupPending = true - @State private var isFallbackFundingPending = false + + private var isCurrentPresentation: Bool { + guard let presentationID = config.presentationID else { + return sheets.activeSheetConfiguration?.id == (isEmbedded ? .subscription : .send) + } + return sheets.activeSheetConfiguration?.presentationID == presentationID + } init(config: SendSheetItem, isEmbedded: Bool = false) { self.config = config self.isEmbedded = isEmbedded - _rootRoute = State(initialValue: config.initialRoute) - _hwSend = State(initialValue: HwSendCoordinator(walletId: config.hardwareWalletId)) + _flow = State(initialValue: config.state) + let hwSend = config.state.hwSend ?? HwSendCoordinator(walletId: config.hardwareWalletId) + config.state.hwSend = hwSend + _hwSend = State(initialValue: hwSend) } private var currentRoot: SendRoute { - rootOverride ?? rootRoute + flow.rootOverride ?? flow.rootRoute } private var isPreparingRequest: Bool { - config.preparation != nil && incomingPaymentRequest == nil + config.preparation != nil && flow.incomingPaymentRequest == nil + } + + private var isMissingLnurlPayData: Bool { + guard app.lnurlPayData == nil, !app.isHandlingScannedData, !flow.isSubmittingPayment else { return false } + return Self.isLnurlPayRoute(flow.navigationPath.last ?? currentRoot) + } + + private static func isLnurlPayRoute(_ route: SendRoute) -> Bool { + route == .lnurlPayAmount || route == .lnurlPayConfirm } private var shouldDismissUnavailableRequest: Bool { - guard let request = incomingPaymentRequest else { return false } + guard let request = flow.incomingPaymentRequest else { return false } return Self.shouldDismissUnavailableRequest( root: currentRoot, - path: navigationPath, - isSubmittingPayment: isSubmittingPayment, + path: flow.navigationPath, + isSubmittingPayment: flow.isSubmittingPayment, isAvailable: paykitPaymentRequestManager.pendingRequests.contains(where: { $0.id == request.id }) || paykitPaymentRequestManager.isApprovedForPayment(request) ) @@ -192,8 +248,8 @@ struct SendSheet: View { /// If there are no channels at all, we should NOT wait behind the sync UI – that's a capacity issue, not a sync issue. /// For onchain: only need node running. private var shouldShowSyncOverlay: Bool { - if isPreparingRequest || hwSend.isActive || isResumingAcceptedOrdinarySend || app.paykitUsesUsdt || - (app.contactPaymentContext?.requiresAssetSelection == true && navigationPath.isEmpty) + if isPreparingRequest || hwSend.isActive || flow.isResumingAcceptedOrdinarySend || app.paykitUsesUsdt || + (app.contactPaymentContext?.requiresAssetSelection == true && flow.navigationPath.isEmpty) { return false } @@ -251,27 +307,37 @@ struct SendSheet: View { .sheet(isPresented: reconnectPairingBinding) { HardwarePairingSheet(config: HardwarePairingSheetItem()) } - .sheet(isPresented: Binding(get: { usdtDetails != nil }, set: { + .sheet(isPresented: Binding(get: { flow.usdtDetails != nil }, set: { if !$0 { - usdtDetails = nil + flow.usdtDetails = nil } })) { - if let id = usdtDetails { + if let id = flow.usdtDetails { UsdtActivityDetail(transferId: id) } } .offlineSheetOverlay( title: t("wallet__send_bitcoin"), - forceShow: syncTimedOut, - isEnabled: !isPreparingRequest && !isResumingAcceptedOrdinarySend + forceShow: flow.syncTimedOut, + isEnabled: !isPreparingRequest && !flow.isResumingAcceptedOrdinarySend ) .onChange(of: shouldDismissUnavailableRequest) { _, shouldDismiss in guard shouldDismiss else { return } sheets.hideSheetIfActive(isEmbedded ? .subscription : .send, reason: "Incoming payment request is no longer available") } + .onChange(of: isMissingLnurlPayData, initial: true) { _, isMissing in + guard isMissing, isCurrentPresentation else { return } + exitMissingLnurlPayment() + } .onChange(of: shouldShowSyncOverlay, initial: true) { _, isShowing in Logger.debug("shouldShowSyncOverlay: \(isShowing) (node: \(wallet.nodeLifecycleState))", context: "SendSheet") } + .onChange(of: app.sendStateGeneration) { + // External links can decode while this sheet is still open; only adopt our own scans. + if isCurrentPresentation, let ownerID = app.sendStateOwnerID, ownerID == config.presentationID { + flow.ownedSendStateGeneration = app.sendStateGeneration + } + } .task(id: config.preparation?.resolvedRoute) { if let preparation = config.preparation { guard let route = preparation.resolvedRoute, preparation.ownsSheet(sheets), @@ -279,7 +345,7 @@ struct SendSheet: View { app.contactPaymentContext == preparation.paymentContext, app.contactPaymentContext?.incomingPaymentRequest?.id == request.id else { return } - rootRoute = route + flow.rootRoute = route } setUpSend() } @@ -295,7 +361,7 @@ struct SendSheet: View { || app.lnurlPayData != nil guard hasScannedInvoice else { return } - if state == .running, !hasValidatedAfterSync { + if state == .running, !flow.hasValidatedAfterSync { validatePaymentAfterSync() } } @@ -309,7 +375,7 @@ struct SendSheet: View { || app.lnurlPayData != nil || app.selectedWalletToPayFrom == .lightning - if isLightningPayment, hasUsable, wallet.nodeLifecycleState == .running, !hasValidatedAfterSync { + if isLightningPayment, hasUsable, wallet.nodeLifecycleState == .running, !flow.hasValidatedAfterSync { validatePaymentAfterSync() } } @@ -320,13 +386,13 @@ struct SendSheet: View { // Reset the connection-issues overlay only once the sync wait actually resolves; // going offline merely pauses the timer and keeps the timed-out state. if !shouldShowSyncOverlay { - syncTimedOut = false + flow.syncTimedOut = false } return } try? await Task.sleep(for: .seconds(Self.syncTimeoutSeconds)) - guard !Task.isCancelled, syncTimeoutPhase.isActive, !hasValidatedAfterSync else { return } + guard !Task.isCancelled, syncTimeoutPhase.isActive, !flow.hasValidatedAfterSync else { return } handleSyncTimeout() } @@ -341,35 +407,49 @@ struct SendSheet: View { SendSyncScreen() .transition(.opacity) } else { - NavigationStack(path: $navigationPath) { + NavigationStack(path: $flow.navigationPath) { viewForRoute(currentRoot) .navigationDestination(for: SendRoute.self) { route in viewForRoute(route) } } - .id(rootRouteGeneration) + .id(flow.rootRouteGeneration) .transition(.opacity) } } private func cleanup() { - setupTask?.cancel() - setupTask = nil + // Content recreation keeps the presentation, its setup and its payment operation alive. + guard isEmbedded || !isCurrentPresentation, !flow.didFinish else { return } + flow.didFinish = true + flow.onDismiss = nil + flow.setupTask?.cancel() + flow.setupTask = nil hwSend.cancel() if isPreparingRequest { return } - if let request = incomingPaymentRequest ?? app.contactPaymentContext?.incomingPaymentRequest { + // The reset's owner is available synchronously, even before SwiftUI delivers onChange. + let ownsSendState = flow.ownedSendStateGeneration == app.sendStateGeneration || + (app.sendStateOwnerID != nil && app.sendStateOwnerID == config.presentationID) + if let request = flow.incomingPaymentRequest ?? (ownsSendState ? app.contactPaymentContext?.incomingPaymentRequest : nil) { Task { await paykitPaymentRequestManager.finishPayment(request) } } - incomingPaymentRequest = nil - app.resetSendState() + flow.incomingPaymentRequest = nil + // A scan may have replaced this send's state before its dismiss animation finishes. + guard ownsSendState else { return } + app.resetSendState(ownerID: config.presentationID) wallet.resetSendState(speed: settings.defaultTransactionSpeed) app.resetQuickPay() QuickPayPaymentCoordinator.shared.detach() } private func setUpSend() { + guard isCurrentPresentation, !flow.didStartSetup else { return } + flow.didStartSetup = true + if !isEmbedded { + flow.onDismiss = { cleanup() } + } wallet.resetSendState(speed: settings.defaultTransactionSpeed) if let walletId = config.hardwareWalletId { let balance = hwWalletManager.fundingBalance(walletId: walletId) @@ -384,7 +464,7 @@ struct SendSheet: View { tagManager.clearSelectedTags() } guard paykitPaymentRequestManager.markPresentedIfPending(request) else { - app.resetSendState() + app.resetSendState(ownerID: config.presentationID) sheets.hideSheetIfActive(isEmbedded ? .subscription : .send, reason: "Incoming payment request is no longer available") return } @@ -396,24 +476,25 @@ struct SendSheet: View { quoteId: app.paykitPaymentTerms?.quoteId ) wallet.sendAmountSats = payment?.amount.asset == .btc ? payment?.amount.atomic : nil - incomingPaymentRequest = request + flow.incomingPaymentRequest = request } else { tagManager.clearSelectedTags() } - hasValidatedAfterSync = false - syncTimedOut = false + flow.hasValidatedAfterSync = false + flow.syncTimedOut = false if currentRoot == .options { - app.resetSendState() + app.resetSendState(ownerID: config.presentationID) } - setupTask?.cancel() - isSetupPending = true - setupTask = Task { + flow.ownedSendStateGeneration = app.sendStateGeneration + flow.setupTask?.cancel() + flow.isSetupPending = true + flow.setupTask = Task { defer { if !Task.isCancelled { - isSetupPending = false + flow.isSetupPending = false } } - if incomingPaymentRequest == nil, config.hardwareWalletId == nil, + if flow.incomingPaymentRequest == nil, config.hardwareWalletId == nil, currentRoot == .options || app.selectedWalletToPayFrom == .onchain { do { @@ -422,12 +503,12 @@ struct SendSheet: View { ) { let context = OnchainSendPendingContext(attemptId: attempt.id, walletId: attempt.walletId, txid: attempt.txid) if attempt.requestId != nil || attempt.orderId != nil || attempt.status != .accepted { - hasValidatedAfterSync = true + flow.hasValidatedAfterSync = true replaceRootRoute(with: .onchainOperationPending(context, requestId: attempt.requestId)) return } - isResumingAcceptedOrdinarySend = true - hasValidatedAfterSync = true + flow.isResumingAcceptedOrdinarySend = true + flow.hasValidatedAfterSync = true app.selectedWalletToPayFrom = .onchain replaceRootRoute(with: Self.acceptedOrdinaryStartupRoute(attempt: attempt)) return @@ -443,7 +524,7 @@ struct SendSheet: View { } catch { Logger.error("Failed to set default fee rate: \(error)") } - if let request = incomingPaymentRequest, app.contactPaymentContext?.requiresAssetSelection != true || app.paykitPaymentTerms != nil { + if let request = flow.incomingPaymentRequest, app.contactPaymentContext?.requiresAssetSelection != true || app.paykitPaymentTerms != nil { guard isCurrentIncomingRequest(request.id), let amount = try? request.payment( using: app.paykitPaymentMethod, quoteId: app.paykitPaymentTerms?.quoteId @@ -468,7 +549,7 @@ struct SendSheet: View { if canFallBackToOnchain { validatePaymentAfterSync(ignoreChannelWait: true) } else { - syncTimedOut = true + flow.syncTimedOut = true } } @@ -541,7 +622,7 @@ struct SendSheet: View { if isEmbedded { replaceRootRoute(with: failureRoute) } else { - navigationPath.append(failureRoute) + flow.navigationPath.append(failureRoute) } return } @@ -566,23 +647,23 @@ 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) { - guard !isResumingAcceptedOrdinarySend else { return } + guard !flow.isResumingAcceptedOrdinarySend else { return } let result = performPaymentValidationAfterSync(ignoreChannelWait: ignoreChannelWait) - guard let route = pendingEmbeddedRetryRoute else { return } + guard let route = flow.pendingEmbeddedRetryRoute else { return } switch result { case .ready: - pendingEmbeddedRetryRoute = nil + flow.pendingEmbeddedRetryRoute = nil replaceRootRoute(with: route) case .failed: - pendingEmbeddedRetryRoute = nil + flow.pendingEmbeddedRetryRoute = nil case .waiting: break } } private func performPaymentValidationAfterSync(ignoreChannelWait: Bool) -> PaymentValidationResult { - if let request = incomingPaymentRequest, + if let request = flow.incomingPaymentRequest, let proof = paykitPaymentRequestManager.retainedHardwareRetries[request.id], proof.retainedHardwareRetry != nil, proof.onchainWalletId == hwSend.walletId, proof.onchainAddress == app.scannedOnchainInvoice?.address, @@ -591,8 +672,10 @@ struct SendSheet: View { // Original signed bytes only; new-payment balance admission does not apply. return .ready } - if app.paykitUsesUsdt || (app.contactPaymentContext?.requiresAssetSelection == true && currentRoot == .amount && navigationPath.isEmpty) { - hasValidatedAfterSync = true + if app + .paykitUsesUsdt || (app.contactPaymentContext?.requiresAssetSelection == true && currentRoot == .amount && flow.navigationPath.isEmpty) + { + flow.hasValidatedAfterSync = true return .ready } let request = app.contactPaymentContext?.incomingPaymentRequest @@ -613,7 +696,7 @@ struct SendSheet: View { ), accessibilityIdentifier: "LnurlPayAmountTooLowToast" ) - hasValidatedAfterSync = true + flow.hasValidatedAfterSync = true return .failed } guard requestedAmount <= lnurlPayData.maxSendableSat else { @@ -622,17 +705,17 @@ struct SendSheet: View { description: t("wallet__lnurl_pay__error_max__description"), accessibilityIdentifier: "LnurlPayAmountTooHighToast" ) - hasValidatedAfterSync = true + flow.hasValidatedAfterSync = true return .failed } guard LightningService.shared.canSend(amountSats: requestedAmount) else { let spendingBalance = LightningService.shared.balances?.totalLightningBalanceSats ?? 0 showInsufficientSpendingToast(invoiceAmount: requestedAmount, spendingBalance: spendingBalance) - hasValidatedAfterSync = true + flow.hasValidatedAfterSync = true return .failed } - hasValidatedAfterSync = true + flow.hasValidatedAfterSync = true return .ready } @@ -677,30 +760,30 @@ struct SendSheet: View { invoiceAmount: onchainAmount ?? onchainInvoice.amountSatoshis, onchainBalance: onchainBalance ) else { - hasValidatedAfterSync = true + flow.hasValidatedAfterSync = true return .failed } // Onchain balance is sufficient → navigate to amount screen // (the sheet may have opened with .confirm or .quickpay route) if requestedAmount == nil { - navigationPath = [.amount] + flow.navigationPath = [.amount] } if let onchainAmount { selectHardwareFundingSourceAfterOnchainFallback(amountSats: onchainAmount) } - hasValidatedAfterSync = true + flow.hasValidatedAfterSync = true return .ready } else { // For pure lightning invoices, show error toast and dismiss sheet let spendingBalance = LightningService.shared.balances?.totalLightningBalanceSats ?? 0 showInsufficientSpendingToast(invoiceAmount: paymentAmount, spendingBalance: spendingBalance) - hasValidatedAfterSync = true + flow.hasValidatedAfterSync = true return .failed } } else { // Lightning payment is valid, we're done - hasValidatedAfterSync = true + flow.hasValidatedAfterSync = true return .ready } } @@ -715,49 +798,49 @@ struct SendSheet: View { invoiceAmount: requestedAmount ?? onchainInvoice.amountSatoshis, onchainBalance: onchainBalance ) else { - hasValidatedAfterSync = true + flow.hasValidatedAfterSync = true return .failed } } - hasValidatedAfterSync = true + flow.hasValidatedAfterSync = true return .ready } private func requestPinCheck() async -> Bool { // Prevent stacking multiple PIN screens if already presented. - if navigationPath.last != .pin { - navigationPath.append(.pin) + if flow.navigationPath.last != .pin { + flow.navigationPath.append(.pin) } return await withCheckedContinuation { continuation in - pinCheckContinuations.append(continuation) + flow.pinCheckContinuations.append(continuation) } } private func resolvePinCheck(_ approved: Bool) { - let continuations = pinCheckContinuations - pinCheckContinuations.removeAll() + let continuations = flow.pinCheckContinuations + flow.pinCheckContinuations.removeAll() continuations.forEach { $0.resume(returning: approved) } - if navigationPath.last == .pin { - navigationPath.removeLast() + if flow.navigationPath.last == .pin { + flow.navigationPath.removeLast() } } private func isCurrentIncomingRequest(_ requestId: PaykitPaymentRequest.ID) -> Bool { !Task.isCancelled && sheets.activeSheetConfiguration?.id == (isEmbedded ? .subscription : .send) - && incomingPaymentRequest?.id == requestId + && flow.incomingPaymentRequest?.id == requestId && app.contactPaymentContext?.incomingPaymentRequest?.id == requestId } private func selectHardwareFundingSourceAfterOnchainFallback(amountSats: UInt64) { - guard let request = incomingPaymentRequest else { return } - isFallbackFundingPending = true + guard let request = flow.incomingPaymentRequest else { return } + flow.isFallbackFundingPending = true Task { _ = await selectHardwareFundingSourceIfNeeded(amountSats: amountSats, requestId: request.id) - isFallbackFundingPending = false + flow.isFallbackFundingPending = false } } @@ -771,7 +854,7 @@ struct SendSheet: View { let satsPerVByte = wallet.selectedFeeRateSatsPerVByte else { return true } - if let request = incomingPaymentRequest, + if let request = flow.incomingPaymentRequest, let proof = try? await PaykitPaymentProofService.shared.retainedHardwarePaymentForRetry(request: request), let walletId = proof.onchainWalletId { @@ -847,18 +930,18 @@ struct SendSheet: View { private func viewForRoute(_ route: SendRoute) -> some View { switch route { case .options: - SendOptionsView(navigationPath: $navigationPath, hwSend: hwSend) + SendOptionsView(navigationPath: $flow.navigationPath, hwSend: hwSend) case .contact: - SendContactSelectView(navigationPath: $navigationPath, hwSend: hwSend) + SendContactSelectView(navigationPath: $flow.navigationPath, hwSend: hwSend) case .comingSoon: SendComingSoonView() case .manual: - SendEnterManuallyView(navigationPath: $navigationPath, hwSend: hwSend) + SendEnterManuallyView(navigationPath: $flow.navigationPath, hwSend: hwSend) case .amount: - SendAmountView(navigationPath: $navigationPath, hwSend: hwSend) + SendAmountView(navigationPath: $flow.navigationPath, hwSend: hwSend) case let .usdt(recipient, amount): UsdtSendView(onDetails: { id in - usdtDetails = id + flow.usdtDetails = id }, initialRecipient: recipient, initialAmount: amount, embedded: true, paymentContext: app.contactPaymentContext, sendPayment: { quote in guard let context = app.contactPaymentContext else { throw PaykitPaymentRequestError.requestUnavailable } @@ -874,25 +957,25 @@ struct SendSheet: View { } guard app.ownsContactPaymentContext(context) else { throw PaykitPaymentRequestError.requestUnavailable } } - }, onBack: { navigationPath.removeLast() }, onClose: { sheets.hideSheet() }) + }, onBack: { flow.navigationPath.removeLast() }, onClose: { sheets.hideSheet() }) case let .usdtAddress(recipient, amount): UsdtSendView(onDetails: { id in - usdtDetails = id + flow.usdtDetails = id }, initialRecipient: recipient, initialAmount: amount, hosted: true, - onBack: { navigationPath.removeLast() }, onClose: { sheets.hideSheet() }) + onBack: { flow.navigationPath.removeLast() }, onClose: { sheets.hideSheet() }) case .utxoSelection: - SendUtxoSelectionView(navigationPath: $navigationPath) + SendUtxoSelectionView(navigationPath: $flow.navigationPath) case .confirm: SendConfirmationView( - navigationPath: $navigationPath, - isSubmittingPayment: $isSubmittingPayment, + navigationPath: $flow.navigationPath, + isSubmittingPayment: $flow.isSubmittingPayment, hwSend: hwSend, requestPinCheck: requestPinCheck, prepareIncomingPaymentRequest: prepareIncomingPaymentRequest, - routingCacheResetAttempted: routingCacheResetAttempted, + routingCacheResetAttempted: flow.routingCacheResetAttempted, preparingRequest: isPreparingRequest ? config.preparation?.request : nil, amountIsRoot: currentRoot == .amount, - isSetupPending: isSetupPending || isFallbackFundingPending + isSetupPending: flow.isSetupPending || flow.isFallbackFundingPending ) case .hardwareSign: let contactContext = app.contactPaymentContext @@ -900,7 +983,7 @@ struct SendSheet: View { let walletId = hwSend.walletId let paymentIdentity = pubkyProfile.publicKey HwSendSignView( - navigationPath: $navigationPath, + navigationPath: $flow.navigationPath, hwSend: hwSend, contactPaymentRequestId: contactContext?.incomingPaymentRequest?.id, contactPaymentIdentity: paymentIdentity, @@ -929,18 +1012,24 @@ struct SendSheet: View { } ) case .feeRate: - SendFeeRate(navigationPath: $navigationPath, hwSend: hwSend) + SendFeeRate(navigationPath: $flow.navigationPath, hwSend: hwSend) case .feeCustom: - SendFeeCustom(navigationPath: $navigationPath, hwSend: hwSend) + SendFeeCustom(navigationPath: $flow.navigationPath, hwSend: hwSend) case .tag: - SendTagScreen(navigationPath: $navigationPath) + SendTagScreen(navigationPath: $flow.navigationPath) case .quickpay: SendQuickpay( - navigationPath: $navigationPath, - routingCacheResetAttempted: routingCacheResetAttempted, - replaceQuickPay: replaceQuickPay(with:) + navigationPath: $flow.navigationPath, + routingCacheResetAttempted: flow.routingCacheResetAttempted, + replaceQuickPay: replaceQuickPay(with:), + sessionID: QuickPayPaymentCoordinator.SessionID( + presentationID: flow.quickPayPresentationID, + sendStateGeneration: app.sendStateGeneration, + attempt: flow.quickPaySession + ), + isCurrentPresentation: { isCurrentPresentation } ) - .id(quickPaySession) + .id(flow.quickPaySession) case .pin: SendPinScreen(onCancel: { resolvePinCheck(false) }, onPinVerified: { resolvePinCheck(true) }) case let .pending(paymentHash, retryRoute, paymentRequest, paykitPaymentRequestId): @@ -949,28 +1038,28 @@ struct SendSheet: View { retryRoute: retryRoute, paymentRequest: paymentRequest, paykitPaymentRequestId: paykitPaymentRequestId, - routingCacheResetAttempted: routingCacheResetAttempted, - navigationPath: $navigationPath + routingCacheResetAttempted: flow.routingCacheResetAttempted, + navigationPath: $flow.navigationPath ) case let .onchainPending(context): SendPendingScreen( paymentHash: nil, retryRoute: .confirm, paymentRequest: nil, paykitPaymentRequestId: nil, - routingCacheResetAttempted: routingCacheResetAttempted, ordinaryPendingContext: context, - requestPinCheck: requestPinCheck, navigationPath: $navigationPath + routingCacheResetAttempted: flow.routingCacheResetAttempted, ordinaryPendingContext: context, + requestPinCheck: requestPinCheck, navigationPath: $flow.navigationPath ) case let .onchainOperationPending(context, requestId): SendPendingScreen( paymentHash: nil, retryRoute: .confirm, paymentRequest: nil, paykitPaymentRequestId: requestId, - routingCacheResetAttempted: routingCacheResetAttempted, ordinaryPendingContext: context, - requestPinCheck: requestPinCheck, navigationPath: $navigationPath + routingCacheResetAttempted: flow.routingCacheResetAttempted, ordinaryPendingContext: context, + requestPinCheck: requestPinCheck, navigationPath: $flow.navigationPath ) case let .hardwarePending(requestId, walletId, transactionId, paymentIdentity): SendPendingScreen( paymentHash: nil, retryRoute: .confirm, paymentRequest: nil, paykitPaymentRequestId: requestId, - routingCacheResetAttempted: routingCacheResetAttempted, + routingCacheResetAttempted: flow.routingCacheResetAttempted, hardwareWalletId: walletId, hardwareTransactionId: transactionId, hardwarePaymentIdentity: paymentIdentity, - navigationPath: $navigationPath + navigationPath: $flow.navigationPath ) case let .success(paymentId, walletId): SendSuccess( @@ -983,7 +1072,7 @@ struct SendSheet: View { context: context, onRetryReady: { didResetRoutingCaches in if didResetRoutingCaches { - routingCacheResetAttempted = true + flow.routingCacheResetAttempted = true } if let requestId = context.incomingPaymentRequestId { await retryIncomingPaymentRequest( @@ -999,22 +1088,23 @@ struct SendSheet: View { : nil ) case .lnurlPayAmount: - LnurlPayAmount(navigationPath: $navigationPath) + LnurlPayAmount(navigationPath: $flow.navigationPath, amountViewModel: flow.amountInput(for: app.sendStateGeneration)) + .id(app.sendStateGeneration) case .lnurlPayConfirm: LnurlPayConfirm( - navigationPath: $navigationPath, - isSubmittingPayment: $isSubmittingPayment, + navigationPath: $flow.navigationPath, + isSubmittingPayment: $flow.isSubmittingPayment, requestPinCheck: requestPinCheck, prepareIncomingPaymentRequest: prepareIncomingPaymentRequest, - routingCacheResetAttempted: routingCacheResetAttempted + routingCacheResetAttempted: flow.routingCacheResetAttempted ) case .lnurlWithdrawAmount: LnurlWithdrawAmount { - navigationPath.append(.lnurlWithdrawConfirm) + flow.navigationPath.append(.lnurlWithdrawConfirm) } case .lnurlWithdrawConfirm: LnurlWithdrawConfirm { amount in - navigationPath.append(.lnurlWithdrawFailure(amount: amount)) + flow.navigationPath.append(.lnurlWithdrawFailure(amount: amount)) } case let .lnurlWithdrawFailure(amount): LnurlWithdrawFailure(amount: amount) @@ -1187,9 +1277,9 @@ struct SendSheet: View { private func replaceQuickPay(with route: SendRoute) { app.resetQuickPay() - let next = PaymentNavigationHelper.replacingQuickPay(in: navigationPath, root: currentRoot, with: route) - rootOverride = next.root == config.initialRoute ? nil : next.root - navigationPath = next.path + let next = PaymentNavigationHelper.replacingQuickPay(in: flow.navigationPath, root: currentRoot, with: route) + flow.rootOverride = next.root == config.initialRoute ? nil : next.root + flow.navigationPath = next.path } private var reconnectPairingBinding: Binding { @@ -1207,24 +1297,32 @@ struct SendSheet: View { let route = retryRoute.sendRoute if retryRoute == .quickpay { app.resetQuickPay() - quickPaySession += 1 + flow.quickPaySession += 1 } if route == config.initialRoute || currentRoot == route { if route == config.initialRoute { - rootOverride = nil + flow.rootOverride = nil } - navigationPath = [] + flow.navigationPath = [] return } - navigationPath = [route] + flow.navigationPath = [route] + } + + private func exitMissingLnurlPayment() { + if !Self.isLnurlPayRoute(currentRoot), let index = flow.navigationPath.firstIndex(where: Self.isLnurlPayRoute) { + flow.navigationPath.removeSubrange(index...) + } else { + sheets.hideSheetIfActive(isEmbedded ? .subscription : .send, reason: "LNURL payment data is no longer available") + } } private func replaceRootRoute(with route: SendRoute) { - rootOverride = nil - rootRoute = route - navigationPath = [] - rootRouteGeneration &+= 1 + flow.rootOverride = nil + flow.rootRoute = route + flow.navigationPath = [] + flow.rootRouteGeneration &+= 1 } @MainActor @@ -1242,7 +1340,7 @@ struct SendSheet: View { return } - app.resetSendState() + app.resetSendState(ownerID: config.presentationID) wallet.resetSendState(speed: settings.defaultTransactionSpeed) _ = paykitPaymentRequestManager.requestPresentation(request) sheets.hideSheet(reason: "Retrying incoming payment request with fresh private payment details") @@ -1252,7 +1350,7 @@ struct SendSheet: View { private func retryEmbeddedInitialSubscriptionPayment(_ request: PaykitPaymentRequest) async { guard sheets.activeSheetConfiguration?.id == .subscription else { return } - app.resetSendState() + app.resetSendState(ownerID: config.presentationID) wallet.resetSendState(speed: settings.defaultTransactionSpeed) let resolution: PublicPaykitPaymentLaunchResult @@ -1284,14 +1382,15 @@ struct SendSheet: View { try await app.handleScannedData( paymentTarget, claimedContactPaymentContext: context, - alternativeOnchainBalanceSats: hwWalletManager.maximumFundingBalanceSats + alternativeOnchainBalanceSats: hwWalletManager.maximumFundingBalanceSats, + sendPresentationID: config.presentationID ) } catch { guard sheets.activeSheetConfiguration?.id == .subscription, app.ownsContactPaymentContext(context) else { if app.ownsContactPaymentContext(context) { - app.resetSendState() + app.resetSendState(ownerID: config.presentationID) } return } @@ -1320,7 +1419,7 @@ struct SendSheet: View { app.ownsContactPaymentContext(context) else { if app.ownsContactPaymentContext(context) { - app.resetSendState() + app.resetSendState(ownerID: config.presentationID) } return } @@ -1341,8 +1440,8 @@ struct SendSheet: View { return } } - pendingEmbeddedRetryRoute = context.requiresAssetSelection ? .amount : app.lnurlPayData == nil ? .confirm : .lnurlPayConfirm - hasValidatedAfterSync = false + flow.pendingEmbeddedRetryRoute = context.requiresAssetSelection ? .amount : app.lnurlPayData == nil ? .confirm : .lnurlPayConfirm + flow.hasValidatedAfterSync = false guard !shouldShowSyncOverlay else { return } validatePaymentAfterSync() } @@ -1355,7 +1454,7 @@ struct SendSheet: View { ) { guard sheets.activeSheetConfiguration?.id == .subscription else { if let existingContext, app.ownsContactPaymentContext(existingContext) { - app.resetSendState() + app.resetSendState(ownerID: config.presentationID) } return } @@ -1373,7 +1472,7 @@ struct SendSheet: View { let failure = SendFailureContext( error: error, retryRoute: .confirm, - routingCacheResetAttempted: routingCacheResetAttempted, + routingCacheResetAttempted: flow.routingCacheResetAttempted, contactPaymentContext: context ) replaceRootRoute(with: .failure(failure)) diff --git a/BitkitTests/LnurlPayConfirmTests.swift b/BitkitTests/LnurlPayConfirmTests.swift index 12b689ed9..f878baf72 100644 --- a/BitkitTests/LnurlPayConfirmTests.swift +++ b/BitkitTests/LnurlPayConfirmTests.swift @@ -5,6 +5,173 @@ import XCTest @MainActor final class LnurlPayConfirmTests: XCTestCase { + func testRecreatedConfirmationCannotStartAnotherPaymentDuringAuthorizationOrDispatch() async throws { + for suspendedStage in ["authorization", "callback"] { + let flow = SendPresentationState(initialRoute: .lnurlPayConfirm) + let firstBinding = Binding(get: { flow.isSubmittingPayment }, set: { flow.isSubmittingPayment = $0 }) + let started = expectation(description: "First attempt suspended at \(suspendedStage)") + var continuation: CheckedContinuation? + var authorizations = 0 + var callbacks = 0 + var dispatches = 0 + let task = Task { + try await LnurlPayConfirm.withSubmissionLock(isSubmitting: firstBinding) { + authorizations += 1 + if suspendedStage == "authorization" { + await withCheckedContinuation { continuation = $0; started.fulfill() } + } + callbacks += 1 + if suspendedStage == "callback" { + await withCheckedContinuation { continuation = $0; started.fulfill() } + } + dispatches += 1 + } + } + await fulfillment(of: [started], timeout: 2) + XCTAssertTrue(flow.isSubmittingPayment) + // A recreated confirmation has fresh local state but binds the same presentation. + let remountedBinding = Binding(get: { flow.isSubmittingPayment }, set: { flow.isSubmittingPayment = $0 }) + try await LnurlPayConfirm.withSubmissionLock(isSubmitting: remountedBinding) { + authorizations += 1 + callbacks += 1 + dispatches += 1 + } + XCTAssertTrue(flow.isSubmittingPayment, "Rejected re-entry must not release the original attempt's lock") + XCTAssertEqual(authorizations, 1) + XCTAssertEqual(dispatches, 0) + try XCTUnwrap(continuation).resume() + try await task.value + XCTAssertEqual(callbacks, 1) + XCTAssertEqual(dispatches, 1) + XCTAssertFalse(flow.isSubmittingPayment) + } + } + + func testSubmissionFailureAndCancellationReleaseLockForExplicitRetry() async throws { + enum Failure: Error { case callback } + for error: Error in [Failure.callback, CancellationError()] { + let flow = SendPresentationState(initialRoute: .lnurlPayConfirm) + let binding = Binding(get: { flow.isSubmittingPayment }, set: { flow.isSubmittingPayment = $0 }) + do { + try await LnurlPayConfirm.withSubmissionLock(isSubmitting: binding) { throw error } + XCTFail("The original attempt's error must propagate") + } catch {} + XCTAssertFalse(flow.isSubmittingPayment) + var retries = 0 + try await LnurlPayConfirm.withSubmissionLock(isSubmitting: binding) { + XCTAssertTrue(flow.isSubmittingPayment) + retries += 1 + } + XCTAssertEqual(retries, 1) + XCTAssertFalse(flow.isSubmittingPayment) + } + } + + func testAutomaticConfirmationRemountDoesNotRepeatAuthorization() async throws { + snapshotAppDefaultsDomain() + snapshotAppGroupDefaults("home_screen_display_currency_code_v1", "home_screen_display_currency_symbol_v1") + let settings = SettingsViewModel.shared + let previousPinEnabled = settings.pinEnabled + defer { settings.pinEnabled = previousPinEnabled } + settings.pinEnabled = true + settings.requirePinForPayments = true + settings.useBiometrics = false + let app = AppViewModel() + app.contactPaymentContext = ContactPaymentContext(publicKey: "test-contact", isInitialSubscriptionPayment: true) + app.lnurlPayData = LnurlPayData( + uri: "https://example.com/pay", callback: "https://example.com/callback", + minSendable: 222_222, maxSendable: 222_222, + metadataStr: "[]", commentAllowed: nil, allowsNostr: false, nostrPubkey: nil + ) + let flow = SendPresentationState(initialRoute: .lnurlPayConfirm) + let wallet = WalletViewModel() + let sheets = SheetViewModel() + let currency = CurrencyViewModel(currencyService: OfflineCurrencyService()) + let manager = PaykitPaymentRequestManager() + let started = expectation(description: "Automatic confirmation awaiting PIN") + var pinContinuation: CheckedContinuation? + var pinRequests = 0 + func makeView() -> some View { + LnurlPayConfirm( + navigationPath: Binding(get: { flow.navigationPath }, set: { flow.navigationPath = $0 }), + isSubmittingPayment: Binding(get: { flow.isSubmittingPayment }, set: { flow.isSubmittingPayment = $0 }), + requestPinCheck: { + pinRequests += 1 + guard pinRequests == 1 else { return false } + return await withCheckedContinuation { pinContinuation = $0; started.fulfill() } + }, + prepareIncomingPaymentRequest: { XCTFail("Cancelled authorization must not prepare or dispatch") }, + routingCacheResetAttempted: false + ) + .environment(manager) + .environmentObject(app) + .environmentObject(wallet) + .environmentObject(sheets) + .environmentObject(currency) + .environmentObject(settings) + .environmentObject(ContactsManager()) + } + let host = UIHostingController(rootView: AnyView(makeView())) + let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 390, height: 844)) + window.rootViewController = host + window.makeKeyAndVisible() + defer { window.isHidden = true; window.rootViewController = nil } + host.view.layoutIfNeeded() + await fulfillment(of: [started], timeout: 3) + XCTAssertTrue(flow.isSubmittingPayment) + host.rootView = AnyView(Color.clear) + host.view.layoutIfNeeded() + await Task.yield() + host.rootView = AnyView(makeView().id(UUID())) + host.view.layoutIfNeeded() + try await Task.sleep(for: .milliseconds(150)) + XCTAssertEqual(pinRequests, 1) + XCTAssertTrue(flow.isSubmittingPayment) + try XCTUnwrap(pinContinuation).resume(returning: false) + let deadline = ContinuousClock.now + .seconds(2) + while flow.isSubmittingPayment, ContinuousClock.now < deadline { + try await Task.sleep(for: .milliseconds(10)) + } + XCTAssertFalse(flow.isSubmittingPayment) + } + + func testAmountScreenSurvivesContextResetDuringDismissal() async { + snapshotAppDefaultsDomain() + snapshotAppGroupDefaults("home_screen_display_currency_code_v1", "home_screen_display_currency_symbol_v1") + let app = AppViewModel() + let wallet = WalletViewModel() + app.lnurlPayData = LnurlPayData( + uri: "https://example.com/pay", callback: "https://example.com/callback", + minSendable: 149_500, maxSendable: 200_999, + metadataStr: "[]", commentAllowed: 12, allowsNostr: false, nostrPubkey: nil + ) + var path: [SendRoute] = [] + let view = LnurlPayAmount(navigationPath: Binding(get: { path }, set: { path = $0 })) + .environmentObject(app) + .environmentObject(wallet) + .environmentObject(CurrencyViewModel(currencyService: OfflineCurrencyService())) + .environmentObject(SettingsViewModel.shared) + .environmentObject(ContactsManager()) + let host = UIHostingController(rootView: view) + let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 390, height: 844)) + window.rootViewController = host + window.makeKeyAndVisible() + defer { + window.isHidden = true + window.rootViewController = nil + } + host.view.layoutIfNeeded() + await Task.yield() + app.resetSendState() + await Task.yield() + host.view.setNeedsLayout() + host.view.layoutIfNeeded() + XCTAssertGreaterThan(host.sizeThatFits(in: window.bounds.size).height, 0) + XCTAssertNil(app.lnurlPayData) + XCTAssertNil(wallet.sendAmountSats) + XCTAssertTrue(path.isEmpty) + } + func testFixedLnurlLocksReviewedAmountAndPreservesMillisatoshis() { let data = LnurlPayData( uri: "https://example.com/pay", callback: "https://example.com/callback", diff --git a/BitkitTests/PaykitPaymentRequestServiceTests.swift b/BitkitTests/PaykitPaymentRequestServiceTests.swift index 3ba1e4294..c00befe1d 100644 --- a/BitkitTests/PaykitPaymentRequestServiceTests.swift +++ b/BitkitTests/PaykitPaymentRequestServiceTests.swift @@ -4764,6 +4764,7 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { } func testBelowMinimumUnifiedRequestClosesSheetAfterSyncFallback() async throws { + snapshotAppDefaults("channelCount") let onchainMethod = PublicPaykitService.MethodId.onchainMethodId(network: Env.network, scriptType: .p2wpkh) let sdk = try PaymentRequestSdkMock(records: [paymentRequestRecord( amount: "0.000001", endpoints: [onchainMethod.rawValue, PublicPaykitService.MethodId.bitcoinLightningBolt11.rawValue] @@ -4774,6 +4775,9 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { let sheets = SheetViewModel() let app = AppViewModel(sheetViewModel: sheets, navigationViewModel: NavigationViewModel()) let wallet = PaymentRequestSendWallet() + // A persisted channel count would wait for peers instead of exercising the no-channel fallback. + wallet.channelCount = 0 + wallet.channels = [] let context = ContactPaymentContext(publicKey: request.counterparty, incomingPaymentRequest: request, endpoints: [ .init(methodId: onchainMethod, value: "bcrt1q6rhpng9evdsfnn833a4f4vej0asu6dk5srld6x", min: nil, max: nil, rawPayload: ""), ]) diff --git a/BitkitTests/QuickPayPaymentCoordinatorTests.swift b/BitkitTests/QuickPayPaymentCoordinatorTests.swift index 3d08772f2..7c3857e2a 100644 --- a/BitkitTests/QuickPayPaymentCoordinatorTests.swift +++ b/BitkitTests/QuickPayPaymentCoordinatorTests.swift @@ -543,6 +543,73 @@ final class QuickPayPaymentCoordinatorTests: XCTestCase { sendCont?.resume(returning: invoiceHash) } + func testSameSessionReattachesResultWithoutDispatchingAgain() async throws { + let app = appWithInvoice + let session = QuickPayPaymentCoordinator.SessionID( + presentationID: UUID(), sendStateGeneration: app.sendStateGeneration, attempt: 0 + ) + let started = expectation(description: "Dispatch suspended") + let delivered = expectation(description: "Reattached result delivered") + var continuation: CheckedContinuation? + var sendCount = 0 + let coordinator = QuickPayPaymentCoordinator(store: store, sendBolt11: { _ in + sendCount += 1 + await withCheckedContinuation { + continuation = $0 + started.fulfill() + } + throw NodeError.InvalidInvoice(message: "Fixture rejection") + }, listRows: { [] }) + defer { coordinator.detach() } + let wallet = WalletViewModel() + let currency = CurrencyViewModel(currencyService: OfflineCurrencyService()) + coordinator.pay(app: app, wallet: wallet, settings: settings, currency: currency, + presentation: presentation(onRoute: { _ in XCTFail("Retired callback must not navigate") }, onConfirm: {}), + sessionID: session) + await fulfillment(of: [started], timeout: 2) + coordinator.pay(app: app, wallet: wallet, settings: settings, currency: currency, + presentation: presentation(onRoute: { route in + guard case .failure = route else { return XCTFail("Expected rejection result") } + delivered.fulfill() + }, onConfirm: {}), sessionID: session) + try XCTUnwrap(continuation).resume() + await fulfillment(of: [delivered], timeout: 2) + XCTAssertEqual(sendCount, 1) + } + + func testSameSessionReattachesAfterEarlySettlement() async throws { + let invoiceHash = try Self.invoiceHash + let app = appWithInvoice + let session = QuickPayPaymentCoordinator.SessionID( + presentationID: UUID(), sendStateGeneration: app.sendStateGeneration, attempt: 0 + ) + let started = expectation(description: "Dispatch suspended before return") + let delivered = expectation(description: "Reattached settled result delivered") + var continuation: CheckedContinuation? + let coordinator = QuickPayPaymentCoordinator(store: store, sendBolt11: { _ in + await withCheckedContinuation { + continuation = $0 + started.fulfill() + } + return invoiceHash + }, listRows: { [] }) + defer { coordinator.detach() } + let wallet = WalletViewModel() + let currency = CurrencyViewModel(currencyService: OfflineCurrencyService()) + coordinator.pay(app: app, wallet: wallet, settings: settings, currency: currency, + presentation: presentation(onRoute: { _ in XCTFail("Retired callback must not navigate") }, onConfirm: {}), + sessionID: session) + await fulfillment(of: [started], timeout: 2) + XCTAssertTrue(coordinator.complete(paymentId: nil, paymentHash: invoiceHash, success: true).wasQuickPay) + coordinator.pay(app: app, wallet: wallet, settings: settings, currency: currency, + presentation: presentation(onRoute: { route in + guard case .success = route else { return XCTFail("Expected settled result") } + delivered.fulfill() + }, onConfirm: {}), sessionID: session) + try XCTUnwrap(continuation).resume() + await fulfillment(of: [delivered], timeout: 2) + } + func testHasOpenIsTrueForLiveOpOrRecoveredRow() async throws { let invoiceHash = try Self.invoiceHash let coordinator = QuickPayPaymentCoordinator( diff --git a/BitkitTests/ScannerManagerTests.swift b/BitkitTests/ScannerManagerTests.swift index d92c96251..94fa92dcc 100644 --- a/BitkitTests/ScannerManagerTests.swift +++ b/BitkitTests/ScannerManagerTests.swift @@ -1,4 +1,5 @@ @testable import Bitkit +import BitkitCore import XCTest @MainActor @@ -53,6 +54,12 @@ final class ScannerManagerTests: XCTestCase { var hapticCount = 0 let scanner = ScannerManager { hapticCount += 1 } let app = configure(scanner) + let sheets = SheetViewModel() + sheets.showSheet(.send, data: SendConfig(view: .options)) + scanner.configure( + app: app, currency: CurrencyViewModel(currencyService: OfflineCurrencyService()), + settings: SettingsViewModel.shared, sheets: sheets + ) var completionCount = 0 await scanner.handleSendScan(paymentURI) { route in @@ -63,6 +70,7 @@ final class ScannerManagerTests: XCTestCase { XCTAssertEqual(completionCount, 1) XCTAssertEqual(hapticCount, 1) XCTAssertNotNil(app.scannedOnchainInvoice) + XCTAssertEqual(app.sendStateOwnerID, sheets.activeSheetConfiguration?.presentationID) } func testGenericSendPaymentScanPlaysOneSuccessHaptic() async { @@ -97,9 +105,242 @@ final class ScannerManagerTests: XCTestCase { XCTAssertTrue(navigation.path.isEmpty) } - private func configure(_ scanner: ScannerManager) -> AppViewModel { + func testDelayedImageCannotOverwriteReplacementPayment() async throws { + let scanner = ScannerManager { XCTFail("Stale image must not play a success haptic") } + let app = configure(scanner) + let sheets = SheetViewModel() + scanner.configure(app: app, currency: CurrencyViewModel(currencyService: OfflineCurrencyService()), + settings: SettingsViewModel.shared, sheets: sheets) + sheets.showSheet(.send, data: SendConfig(view: .options)) + let started = expectation(description: "Image decoding started") + var continuation: CheckedContinuation? + var routes: [SendRoute?] = [] + let task = Task { + await scanner.handleImageScan(context: .send, decode: { + try await withCheckedThrowingContinuation { + continuation = $0 + started.fulfill() + } + }) { routes.append($0) } + } + await fulfillment(of: [started], timeout: 2) + sheets.hideSheet(reason: "Replace image origin") + sheets.showSheet(.send, data: SendConfig(view: .lnurlPayConfirm)) + let replacementID = sheets.activeSheetConfiguration?.presentationID + app.resetSendState(ownerID: replacementID) + app.lnurlPayData = LnurlPayData( + uri: "https://example.com/new", callback: "https://example.com/callback", + minSendable: 222_222, maxSendable: 222_222, + metadataStr: "[]", commentAllowed: nil, allowsNostr: false, nostrPubkey: nil + ) + let replacementGeneration = app.sendStateGeneration + try XCTUnwrap(continuation).resume(returning: QRCodePayload(string: paymentURI, data: nil)) + await task.value + XCTAssertEqual(sheets.activeSheetConfiguration?.presentationID, replacementID) + XCTAssertEqual(app.sendStateGeneration, replacementGeneration) + XCTAssertEqual(app.lnurlPayData?.minSendable, 222_222) + XCTAssertNil(app.scannedOnchainInvoice) + XCTAssertTrue(routes.isEmpty, "The retiring image's completion must not navigate") + } + + func testDelayedImageCannotOverwriteNewerScanInSameSend() async throws { + snapshotAppDefaultsDomain() + UserDefaults.standard.set(false, forKey: ToastWindowManager.disableAllToastsKey) + let previousToast = ToastWindowManager.shared.currentToast + defer { ToastWindowManager.shared.currentToast = previousToast } + for fails in [false, true] { + ToastWindowManager.shared.currentToast = nil + var haptics = 0 + let scanner = ScannerManager { haptics += 1 } + let sheets = SheetViewModel() + let app = configure(scanner, sheets: sheets) + scanner.configure(app: app, currency: CurrencyViewModel(currencyService: OfflineCurrencyService()), + settings: SettingsViewModel.shared, sheets: sheets) + sheets.showSheet(.send, data: SendConfig(view: .options)) + let presentation = try XCTUnwrap(sheets.activeSheetConfiguration) + let flow = try XCTUnwrap(presentation.sendState) + let started = expectation(description: "Older photo decoding suspended") + var continuation: CheckedContinuation? + var staleRoutes: [SendRoute?] = [] + let task = Task { + await scanner.handleImageScan(context: .send, decode: { + try await withCheckedThrowingContinuation { + continuation = $0 + started.fulfill() + } + }) { staleRoutes.append($0) } + } + await fulfillment(of: [started], timeout: 2) + await scanner.handleSendScan(paymentURI + "?amount=0.000002") { route in + if let route { + flow.navigationPath.append(route) + } + } + XCTAssertEqual(app.scannedOnchainInvoice?.amountSatoshis, 200) + let generation = app.sendStateGeneration + let path = flow.navigationPath + XCTAssertFalse(path.isEmpty) + if fails { + try XCTUnwrap(continuation).resume(throwing: QRCodeImageDecoderError.invalidImage) + } else { + try XCTUnwrap(continuation).resume(returning: QRCodePayload(string: paymentURI, data: nil)) + } + await task.value + XCTAssertEqual(sheets.activeSheetConfiguration?.presentationID, presentation.presentationID) + XCTAssertEqual(app.sendStateGeneration, generation) + XCTAssertEqual(app.scannedOnchainInvoice?.amountSatoshis, 200) + XCTAssertEqual(flow.navigationPath, path) + XCTAssertTrue(staleRoutes.isEmpty) + XCTAssertEqual(haptics, 1) + XCTAssertNil(ToastWindowManager.shared.currentToast) + } + } + + func testDelayedImageIsIgnoredAfterDismissalOrTaskCancellation() async throws { + for cancelTask in [false, true] { + let scanner = ScannerManager { XCTFail("Dismissed/cancelled image must not play a success haptic") } + let app = configure(scanner) + let sheets = SheetViewModel() + scanner.configure(app: app, currency: CurrencyViewModel(currencyService: OfflineCurrencyService()), + settings: SettingsViewModel.shared, sheets: sheets) + sheets.showSheet(.send, data: SendConfig(view: .options)) + let started = expectation(description: "Image decoding suspended") + var continuation: CheckedContinuation? + var didComplete = false + let task = Task { + await scanner.handleImageScan(context: .send, decode: { + try await withCheckedThrowingContinuation { + continuation = $0 + started.fulfill() + } + }) { _ in didComplete = true } + } + await fulfillment(of: [started], timeout: 2) + let generation = app.sendStateGeneration + if cancelTask { + task.cancel() + } else { + sheets.hideSheet(reason: "Close image origin") + } + try XCTUnwrap(continuation).resume(returning: QRCodePayload(string: paymentURI, data: nil)) + await task.value + XCTAssertEqual(app.sendStateGeneration, generation) + XCTAssertNil(app.scannedOnchainInvoice) + XCTAssertFalse(didComplete) + } + } + + func testImageInCurrentPresentationStillProcessesNormally() async { + var haptics = 0 + let scanner = ScannerManager { haptics += 1 } + let app = configure(scanner) + let sheets = SheetViewModel() + scanner.configure(app: app, currency: CurrencyViewModel(currencyService: OfflineCurrencyService()), + settings: SettingsViewModel.shared, sheets: sheets) + sheets.showSheet(.send, data: SendConfig(view: .options)) + var routes: [SendRoute?] = [] + await scanner.handleImageScan(context: .send, decode: { QRCodePayload(string: self.paymentURI, data: nil) }) { + routes.append($0) + } + XCTAssertEqual(routes, [.amount]) + XCTAssertEqual(haptics, 1) + XCTAssertNotNil(app.scannedOnchainInvoice) + XCTAssertEqual(app.sendStateOwnerID, sheets.activeSheetConfiguration?.presentationID) + } + + func testStaleImageFailureDoesNotShowToastInReplacement() async throws { + snapshotAppDefaultsDomain() + UserDefaults.standard.set(false, forKey: ToastWindowManager.disableAllToastsKey) + let previousToast = ToastWindowManager.shared.currentToast + ToastWindowManager.shared.currentToast = nil + defer { ToastWindowManager.shared.currentToast = previousToast } + let scanner = ScannerManager {} + let app = configure(scanner) + let sheets = SheetViewModel() + scanner.configure(app: app, sheets: sheets) + sheets.showSheet(.send, data: SendConfig(view: .options)) + let started = expectation(description: "Image decoding suspended before error") + var continuation: CheckedContinuation? + let task = Task { + await scanner.handleImageScan(context: .send, decode: { + try await withCheckedThrowingContinuation { + continuation = $0 + started.fulfill() + } + }) + } + await fulfillment(of: [started], timeout: 2) + sheets.hideSheet(reason: "Dismiss image origin") + sheets.showSheet(.send, data: SendConfig(view: .lnurlPayConfirm)) + try XCTUnwrap(continuation).resume(throwing: QRCodeImageDecoderError.invalidImage) + await task.value + XCTAssertNil(ToastWindowManager.shared.currentToast) + } + + func testImageResolutionCannotCompleteAfterOriginDismissalOrReplacement() async throws { + let oldOnchainResult = try await decode(invoice: paymentURI) + snapshotAppDefaultsDomain() + UserDefaults.standard.set(false, forKey: ToastWindowManager.disableAllToastsKey) + let previousToast = ToastWindowManager.shared.currentToast + defer { ToastWindowManager.shared.currentToast = previousToast } + for replace in [false, true] { + for failResolution in [false, true] { + ToastWindowManager.shared.currentToast = nil + let sheets = SheetViewModel() + let started = expectation(description: "Payment target resolution suspended") + var continuation: CheckedContinuation? + let scanner = ScannerManager {} + let app = configure(scanner, sheets: sheets, decodeInvoice: { _ in + try await withCheckedThrowingContinuation { + continuation = $0 + started.fulfill() + } + }) + scanner.configure(app: app, currency: CurrencyViewModel(currencyService: OfflineCurrencyService()), + settings: SettingsViewModel.shared, sheets: sheets) + sheets.showSheet(.send, data: SendConfig(view: .options)) + var routes: [SendRoute?] = [] + let task = Task { + await scanner.handleImageScan(context: .send, decode: { + QRCodePayload(string: "https://example.com/old-image", data: nil) + }) { routes.append($0) } + } + await fulfillment(of: [started], timeout: 2) + sheets.hideSheet(reason: "Retire image during payment resolution") + app.resetSendState() + if replace { + sheets.showSheet(.send, data: SendConfig(view: .lnurlPayConfirm)) + app.lnurlPayData = imageTestLnurl(amount: 222_222) + } + let generation = app.sendStateGeneration + if failResolution { + try XCTUnwrap(continuation).resume(throwing: QRCodeImageDecoderError.invalidImage) + } else { + try XCTUnwrap(continuation).resume(returning: replace ? oldOnchainResult : .lnurlPay(data: imageTestLnurl(amount: 999_000))) + } + await task.value + XCTAssertEqual(app.sendStateGeneration, generation) + XCTAssertEqual(app.lnurlPayData?.minSendable, replace ? 222_222 : nil) + XCTAssertNil(app.scannedOnchainInvoice) + XCTAssertTrue(routes.isEmpty) + XCTAssertNil(ToastWindowManager.shared.currentToast) + } + } + } + + private func imageTestLnurl(amount: UInt64) -> LnurlPayData { + LnurlPayData(uri: "https://example.com/pay", callback: "https://example.com/callback", + minSendable: amount, maxSendable: amount, + metadataStr: "[]", commentAllowed: nil, allowsNostr: false, nostrPubkey: nil) + } + + private func configure( + _ scanner: ScannerManager, + sheets: SheetViewModel = SheetViewModel(), + decodeInvoice: @escaping (String) async throws -> BitkitCore.Scanner = { try await decode(invoice: $0) } + ) -> AppViewModel { let app = AppViewModel( - sheetViewModel: SheetViewModel(), + sheetViewModel: sheets, navigationViewModel: NavigationViewModel(), scanPaymentOperations: ScanPaymentOperations( state: { @@ -111,7 +352,8 @@ final class ScannerManagerTests: XCTestCase { hasUsableChannels: false ) }, - canSendLightning: { _ in false } + canSendLightning: { _ in false }, + decodeInvoice: decodeInvoice ) ) scanner.configure( diff --git a/BitkitTests/SendConfirmationLifecycleTests.swift b/BitkitTests/SendConfirmationLifecycleTests.swift index 3f833f335..d36bb0f22 100644 --- a/BitkitTests/SendConfirmationLifecycleTests.swift +++ b/BitkitTests/SendConfirmationLifecycleTests.swift @@ -512,6 +512,10 @@ final class SendConfirmationLifecycleTests: XCTestCase { } } } + .transaction { transaction in + // Avoid an automatic push racing an animated pop in this test host. + transaction.disablesAnimations = true + } } else { confirmation } diff --git a/BitkitTests/SendQuickpayLnurlRemountTests.swift b/BitkitTests/SendQuickpayLnurlRemountTests.swift new file mode 100644 index 000000000..672a26ebf --- /dev/null +++ b/BitkitTests/SendQuickpayLnurlRemountTests.swift @@ -0,0 +1,343 @@ +@testable import Bitkit +import BitkitCore +import Network +import SwiftUI +import XCTest + +/// F1 (PR #1099): a QuickPay-routed fixed LNURL is re-requested and re-paid when Send content is +/// recreated while the presentation stays current. +@MainActor +final class SendQuickpayLnurlRemountTests: XCTestCase { + private static let metadata = #"[["text/plain","test"]]"# + /// Two distinct, validly signed regtest invoices for 500500 msat whose `h` tag is sha256(metadata). + private static let invoices = [ + "lnbcrt5005000p1p4d5kcvpp5ldcnltt7re50tv7a7jjxv0nzh9xl3e3ktuxqzf09nh2tgxdqdnmssp5xzj6kuaug34a3aqgdvnqge85vdhy9rmsvxnq8s5wpct0u2fxe49shp5plegz60wa3rk9cxru5wtzfdxq32hh50rga6aa6eeh2gl6q0z9g8qxqrrsscqpj9qyysgqt6m4mumhw8emmnw9rcrfhazxk9pewm30yyhhf9ynzl039jwt5xfn4u3n79m6z77pteygwqs57qljk26ztlm27jr5644zfm8spp3yvvqqhv2z5w", + "lnbcrt5005000p1p4d5kcvpp5xqwmxyea8wll3nc6sw7ur7xvzcqnczvq4uwxekwldsv25zpr94tqsp5v8jde8cev4p5agl3kskk2fne2mtk505m56naa48ducg5yt2fp8qshp5plegz60wa3rk9cxru5wtzfdxq32hh50rga6aa6eeh2gl6q0z9g8qxqrrsscqpj9qyysgqz7dwdadwpxm36j4c4w6d4ga9jtqa4e4lkaj99r5fdejwr943rfqz5hr57xcx8tfd3arfzerzs66apanuntv7f9de6c55d7y5teav2xgqcrch44", + ] + + override func setUp() { + super.setUp() + snapshotAppGroupDefaults("home_screen_display_currency_code_v1", "home_screen_display_currency_symbol_v1") + snapshotAppDefaults("primaryDisplay", "cached_fx_rates", "enableQuickpay", "quickpayAmount", "quickpayDailyLimitMultiplier") + SettingsViewModel.shared.enableQuickpay = true + SettingsViewModel.shared.quickpayAmount = 5 + SettingsViewModel.shared.quickpayDailyLimitMultiplier = 5 + let rate = FxRate(symbol: "BTCUSD", lastPrice: "100000", base: "BTC", baseName: "Bitcoin", quote: "USD", + quoteName: "US Dollar", currencySymbol: "$", currencyFlag: "", lastUpdatedAt: 0) + UserDefaults.standard.set(try? JSONEncoder().encode([rate]), forKey: "cached_fx_rates") + } + + private func lnurl(callback: String) -> LnurlPayData { + LnurlPayData(uri: "https://example.com/quickpay", callback: callback, + minSendable: 500_500, maxSendable: 500_500, metadataStr: Self.metadata, + commentAllowed: nil, allowsNostr: false, nostrPubkey: nil) + } + + /// Production path: Send opened at `.quickpay` (as `PaymentNavigationHelper` does for a fixed LNURL under + /// the threshold), then the Send content is recreated while the presentation is current (the same event + /// `testTransientSendUnmountKeepsCurrentLnurlState` models). The recreated `SendQuickpay` must not ask the + /// LNURL service for another invoice for the same presentation. + func testRecreatedQuickpayDoesNotRequestSecondLnurlInvoice() async throws { + let server = try LnurlCallbackServer(hold: true, invoices: Self.invoices) + defer { server.stop() } + try await server.waitReady() + let (app, sheets, mount, window) = makeHost() + defer { sheets.hideSheet(reason: "Test cleanup"); window.isHidden = true; window.rootViewController = nil } + app.lnurlPayData = lnurl(callback: server.callbackURL) + app.selectedWalletToPayFrom = .lightning + sheets.showSheet(.send, data: SendConfig(view: .quickpay)) + try await waitUntil(timeout: 5) { server.requestCount >= 1 } + XCTAssertEqual(server.requestCount, 1, "Opening QuickPay fetches one invoice") + let presentationID = try XCTUnwrap(sheets.activeSheetConfiguration?.presentationID) + + mount.isSendMounted = false + try await Task.sleep(for: .milliseconds(500)) + mount.isSendMounted = true + try await Task.sleep(for: .seconds(2)) + + XCTAssertEqual(sheets.activeSheetConfiguration?.presentationID, presentationID, "Presentation stayed current") + XCTAssertNotNil(app.lnurlPayData, "Head retains the LNURL target across recreation") + XCTAssertEqual(server.requestCount, 1, + "Recreated SendQuickpay requested another LNURL invoice for the same presentation (F1)") + } + + /// Same as above, with the trigger a user performs: PIN enabled, the phone is locked or the app backgrounded + /// mid-payment, so AppScene swaps MainNavView (the `.sheet` presenter) for AuthCheck, then back after unlock. + func testPinLockDuringQuickpayDoesNotRequestSecondLnurlInvoice() async throws { + let server = try LnurlCallbackServer(hold: true, invoices: Self.invoices) + defer { server.stop() } + try await server.waitReady() + let (app, sheets, mount, window) = makeHost() + defer { sheets.hideSheet(reason: "Test cleanup"); window.isHidden = true; window.rootViewController = nil } + app.lnurlPayData = lnurl(callback: server.callbackURL) + app.selectedWalletToPayFrom = .lightning + sheets.showSheet(.send, data: SendConfig(view: .quickpay)) + try await waitUntil(timeout: 5) { server.requestCount >= 1 } + XCTAssertEqual(server.requestCount, 1) + let presentationID = try XCTUnwrap(sheets.activeSheetConfiguration?.presentationID) + + mount.isPresenterMounted = false + try await Task.sleep(for: .seconds(1)) + mount.isPresenterMounted = true + try await Task.sleep(for: .seconds(2)) + + XCTAssertEqual(sheets.activeSheetConfiguration?.presentationID, presentationID, "Send is re-presented after unlock") + XCTAssertEqual(server.requestCount, 1, + "Unlock re-presented QuickPay, which requested another LNURL invoice for the same payment (F1)") + } + + func testRemountedQuickpayReceivesInvoiceFailure() async throws { + let server = try LnurlCallbackServer(hold: true, invoices: Self.invoices) + defer { server.stop() } + try await server.waitReady() + let (app, sheets, mount, window) = makeHost() + defer { sheets.hideSheet(reason: "Test cleanup"); window.isHidden = true; window.rootViewController = nil } + app.lnurlPayData = lnurl(callback: server.callbackURL) + app.selectedWalletToPayFrom = .lightning + sheets.showSheet(.send, data: SendConfig(view: .quickpay)) + try await waitUntil(timeout: 5) { server.requestCount >= 1 } + let flow = try XCTUnwrap(sheets.activeSheetConfiguration?.sendState) + mount.isSendMounted = false + try await Task.sleep(for: .milliseconds(500)) + mount.isSendMounted = true + try await Task.sleep(for: .milliseconds(500)) + server.failHeldRequests() + try await waitUntil(timeout: 5) { !flow.navigationPath.isEmpty } + guard case .failure = flow.navigationPath.last else { return XCTFail("Recreated Send must receive the failure route") } + XCTAssertEqual(server.requestCount, 1) + } + + func testNewPresentationAndExplicitRetryStartNewQuickpaySessions() async throws { + for replace in [false, true] { + let server = try LnurlCallbackServer(hold: true, invoices: Self.invoices) + defer { server.stop() } + try await server.waitReady() + let (app, sheets, _, window) = makeHost() + defer { sheets.hideSheet(reason: "Test cleanup"); window.isHidden = true; window.rootViewController = nil } + app.lnurlPayData = lnurl(callback: server.callbackURL) + app.selectedWalletToPayFrom = .lightning + sheets.showSheet(.send, data: SendConfig(view: .quickpay)) + try await waitUntil(timeout: 5) { server.requestCount >= 1 } + XCTAssertEqual(server.requestCount, 1) + if replace { + sheets.hideSheet(reason: "Fresh QuickPay presentation") + try await Task.sleep(for: .seconds(1)) + app.lnurlPayData = lnurl(callback: server.callbackURL) + app.selectedWalletToPayFrom = .lightning + sheets.showSheet(.send, data: SendConfig(view: .quickpay)) + } else { + try XCTUnwrap(sheets.activeSheetConfiguration?.sendState).quickPaySession += 1 + } + try await waitUntil(timeout: 5) { server.requestCount >= 2 } + XCTAssertEqual(server.requestCount, 2, "replace=\(replace)") + } + } + + /// Grounds the PIN-lock trigger: removing the `.sheet` presenter (AppScene swaps MainNavView for AuthCheck) + /// does not end the Send presentation, and restoring it re-presents the same Send with its LNURL retained. + func testPresenterRemovalKeepsAndRepresentsSendPresentation() async throws { + let (app, sheets, mount, window) = makeHost() + defer { sheets.hideSheet(reason: "Test cleanup"); window.isHidden = true; window.rootViewController = nil } + app.lnurlPayData = lnurl(callback: "http://127.0.0.1:9/never") + app.selectedWalletToPayFrom = .lightning + SettingsViewModel.shared.enableQuickpay = false + sheets.showSheet(.send, data: SendConfig(view: .lnurlPayConfirm)) + try await Task.sleep(for: .seconds(1)) + let presentationID = try XCTUnwrap(sheets.activeSheetConfiguration?.presentationID) + mount.isPresenterMounted = false + try await Task.sleep(for: .seconds(1)) + XCTAssertEqual(sheets.activeSheetConfiguration?.presentationID, presentationID) + XCTAssertNotNil(app.lnurlPayData) + mount.isPresenterMounted = true + try await Task.sleep(for: .seconds(1)) + XCTAssertEqual(sheets.activeSheetConfiguration?.presentationID, presentationID, "Unlock re-presents the same Send") + } + + /// F2: a home-scanner LNURL whose decode finishes after the user opened Send manually. + func testLateHomeScanDoesNotCloseSendOpenedDuringDecode() async throws { + SettingsViewModel.shared.enableQuickpay = false + let sheets = SheetViewModel() + let started = expectation(description: "decode suspended") + var continuation: CheckedContinuation? + let app = AppViewModel( + sheetViewModel: sheets, + navigationViewModel: NavigationViewModel(), + scanPaymentOperations: ScanPaymentOperations( + state: { + ScanPaymentState(isNodeRunning: true, spendableOnchainBalanceSats: 0, totalLightningBalanceSats: 1_000_000, + hasChannels: true, hasUsableChannels: true) + }, + canSendLightning: { _ in true }, + decodeInvoice: { _ in + try await withCheckedThrowingContinuation { + continuation = $0 + started.fulfill() + } + } + ) + ) + let (_, _, _, window) = makeHost(app: app, sheets: sheets) + defer { sheets.hideSheet(reason: "Test cleanup"); window.isHidden = true; window.rootViewController = nil } + let scanner = ScannerManager {} + scanner.configure(app: app, currency: CurrencyViewModel(currencyService: OfflineCurrencyService()), + settings: SettingsViewModel.shared, sheets: sheets) + + let scan = Task { await scanner.handleScan("lnurl1latehomescan", context: .main) } + await fulfillment(of: [started], timeout: 2) + // User dismisses the scanner and taps Send while the LNURL pay params are still being fetched. + sheets.showSheet(.send, data: SendConfig(view: .options)) + try await Task.sleep(for: .seconds(1)) + let manualSend = try XCTUnwrap(sheets.activeSheetConfiguration?.presentationID) + try XCTUnwrap(continuation).resume(returning: .lnurlPay(data: lnurl(callback: "https://example.com/callback"))) + await scan.value + try await Task.sleep(for: .seconds(3)) + + XCTAssertEqual(sheets.activeSheetConfiguration?.presentationID, manualSend, + "Late scan replaced the user's Send, then the replacement closed itself with no LNURL data (F2)") + } + + private func waitUntil(timeout: TimeInterval, _ condition: () -> Bool) async throws { + let deadline = Date().addingTimeInterval(timeout) + while !condition(), Date() < deadline { + try await Task.sleep(for: .milliseconds(50)) + } + } + + private func makeHost(app: AppViewModel? = nil, + sheets: SheetViewModel? = nil) -> (AppViewModel, SheetViewModel, MountState, UIWindow) + { + snapshotAppDefaultsDomain() + let app = app ?? AppViewModel() + let sheets = sheets ?? SheetViewModel() + let wallet = WalletViewModel() + wallet.channelCount = 0 + wallet.channels = [] + wallet.nodeLifecycleState = .running + let mount = MountState() + let root = RemountHarness(sheets: sheets, mount: mount) + .environment(PaykitPaymentRequestManager()) + .environment(HwWalletManager()) + .environment(TrezorManager()) + .environment(CameraManager.shared) + .environmentObject(app) + .environmentObject(wallet) + .environmentObject(NetworkMonitor()) + .environmentObject(SettingsViewModel.shared) + .environmentObject(TagManager()) + .environmentObject(PubkyProfileManager()) + .environmentObject(ActivityListViewModel()) + .environmentObject(ContactsManager()) + .environmentObject(CurrencyViewModel(currencyService: OfflineCurrencyService())) + .environmentObject(FeeEstimatesManager()) + .environmentObject(ScannerManager()) + .environmentObject(NavigationViewModel()) + let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 390, height: 844)) + window.rootViewController = UIHostingController(rootView: root) + window.makeKeyAndVisible() + window.rootViewController?.view.layoutIfNeeded() + return (app, sheets, mount, window) + } +} + +private final class MountState: ObservableObject { + @Published var isSendMounted = true + @Published var isPresenterMounted = true +} + +private struct RemountHarness: View { + @ObservedObject var sheets: SheetViewModel + @ObservedObject var mount: MountState + var body: some View { + if mount.isPresenterMounted { + Color.black + .sheet(item: $sheets.sendSheetItem, onDismiss: { + sheets.hideSheetIfActive(.send, reason: "Send sheet dismissed") + }) { config in + if mount.isSendMounted { + SendSheet(config: config) + .environmentObject(sheets) + } + } + } else { + Text("Auth fixture") + } + } +} + +/// Minimal LNURL-pay callback: counts requests; either holds them open or answers with the next invoice. +final class LnurlCallbackServer: @unchecked Sendable { + private let listener: NWListener + private let queue = DispatchQueue(label: "LnurlCallbackServer") + private let lock = NSLock() + private let hold: Bool + private let invoices: [String] + private var count = 0 + private var held: [NWConnection] = [] + private var ready = false + + var requestCount: Int { + lock.withLock { count } + } + + var callbackURL: String { + "http://127.0.0.1:\(listener.port?.rawValue ?? 0)/callback" + } + + init(hold: Bool, invoices: [String]) throws { + self.hold = hold + self.invoices = invoices + listener = try NWListener(using: .tcp, on: .any) + listener.stateUpdateHandler = { [weak self] state in + if case .ready = state { + self?.lock.withLock { self?.ready = true } + } + } + listener.newConnectionHandler = { [weak self] connection in self?.accept(connection) } + listener.start(queue: queue) + } + + func waitReady() async throws { + for _ in 0 ..< 100 where !lock.withLock({ ready }) { + try await Task.sleep(for: .milliseconds(20)) + } + } + + func stop() { + lock.withLock { held.forEach { $0.cancel() } } + listener.cancel() + } + + func failHeldRequests() { + let connections = lock.withLock { + let connections = held + held.removeAll() + return connections + } + let body = #"{"status":"ERROR","reason":"fixture failure"}"# + let response = "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: \(body.utf8.count)\r\nConnection: close\r\n\r\n\(body)" + for connection in connections { + connection.send(content: Data(response.utf8), completion: .contentProcessed { _ in connection.cancel() }) + } + } + + private func accept(_ connection: NWConnection) { + connection.start(queue: queue) + connection.receive(minimumIncompleteLength: 1, maximumLength: 65536) { [weak self] data, _, _, _ in + guard let self, let data, String(decoding: data, as: UTF8.self).hasPrefix("GET") else { + connection.cancel() + return + } + let index: Int = lock.withLock { + self.count += 1 + return self.count - 1 + } + if hold { + lock.withLock { self.held.append(connection) } + return + } + let body = #"{"pr":"\#(invoices[index % invoices.count])","routes":[]}"# + let response = "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: \(body.utf8.count)\r\nConnection: close\r\n\r\n\(body)" + connection.send(content: Data(response.utf8), completion: .contentProcessed { _ in connection.cancel() }) + } + } +} diff --git a/BitkitTests/SendSheetLifecycleTests.swift b/BitkitTests/SendSheetLifecycleTests.swift new file mode 100644 index 000000000..6b5b27370 --- /dev/null +++ b/BitkitTests/SendSheetLifecycleTests.swift @@ -0,0 +1,430 @@ +@testable import Bitkit +import BitkitCore +import SwiftUI +import XCTest + +@MainActor +final class SendSheetLifecycleTests: XCTestCase { + func testScannerReplacementPreservesLnurlConfirmation() async throws { + let (app, sheets, prompt, window, _) = makeHost() + defer { + sheets.hideSheet(reason: "Test cleanup") + window.isHidden = true + window.rootViewController = nil + } + sheets.showSheet(.send, data: SendConfig(view: .options)) + try await Task.sleep(for: .seconds(1)) + sheets.hideSheet(reason: "Close previous send") + // A new scan can resolve while the previous send is still animating out. + sheets.showSheet(.scanner) + app.resetSendState() + app.lnurlPayData = LnurlPayData( + uri: "https://example.com/replacement", callback: "https://example.com/callback", + minSendable: 222_538, maxSendable: 222_538, + metadataStr: "[]", commentAllowed: nil, allowsNostr: false, nostrPubkey: nil + ) + app.selectedWalletToPayFrom = .lightning + sheets.showSheet(.send, data: SendConfig(view: .lnurlPayConfirm)) + try await Task.sleep(for: .seconds(2)) + XCTAssertEqual(app.lnurlPayData?.minSendable, 222_538, "The retiring send must not clear the replacement scan") + sheets.hideSheet(reason: "Close replacement") + try await Task.sleep(for: .seconds(1)) + XCTAssertNil(app.lnurlPayData, "Closing the current send must still clean up its state") + for amount: UInt64 in [222_000, 222_538, 222_222, 500_500] { + sheets.showSheet(.scanner) + try await Task.sleep(for: .seconds(1)) + prompt.isPresented = true + try await Task.sleep(for: .milliseconds(500)) + app.lnurlPayData = LnurlPayData( + uri: "https://example.com/pay", callback: "https://example.com/callback", + minSendable: amount, maxSendable: amount, + metadataStr: "[]", commentAllowed: nil, allowsNostr: false, nostrPubkey: nil + ) + app.selectedWalletToPayFrom = .lightning + sheets.showSheet(.send, data: SendConfig(view: .lnurlPayConfirm)) + prompt.isPresented = false + try await Task.sleep(for: .seconds(2)) + XCTAssertEqual(app.lnurlPayData?.minSendable, amount) + XCTAssertEqual(sheets.sendSheetItem?.initialRoute, .lnurlPayConfirm) + // Scanning again inside the current send transfers ownership to its new state. + let current = app.lnurlPayData + app.resetSendState(ownerID: sheets.activeSheetConfiguration?.presentationID) + app.lnurlPayData = current + app.selectedWalletToPayFrom = .lightning + try await Task.sleep(for: .milliseconds(100)) + sheets.hideSheet(reason: "Close LNURL send") + try await Task.sleep(for: .seconds(1)) + XCTAssertNil(app.lnurlPayData, "Closing the current send must still clean up its state") + } + } + + func testExternalLnurlHandoffDoesNotTransferOwnershipToOpenSend() async throws { + let (app, sheets, _, window, _) = makeHost() + defer { sheets.hideSheet(reason: "Test cleanup"); window.isHidden = true; window.rootViewController = nil } + SettingsViewModel.shared.enableQuickpay = false + sheets.showSheet(.send, data: SendConfig(view: .options)) + try await Task.sleep(for: .seconds(1)) + let previousID = try XCTUnwrap(sheets.activeSheetConfiguration?.presentationID) + let previousFlow = try XCTUnwrap(sheets.sendSheetItem?.state) + let previousGeneration = app.sendStateGeneration + + // External handleScannedData resets before awaiting decode while the old Send is still current. + app.resetSendState() + try await Task.sleep(for: .milliseconds(500)) + XCTAssertEqual(previousFlow.ownedSendStateGeneration, previousGeneration, + "An unrelated scan must not transfer its state to the open Send") + app.lnurlPayData = makeLnurlData() + app.selectedWalletToPayFrom = .lightning + PaymentNavigationHelper.openPaymentSheet( + app: app, currency: CurrencyViewModel(currencyService: OfflineCurrencyService()), + settings: SettingsViewModel.shared, sheetViewModel: sheets + ) + try await Task.sleep(for: .seconds(1)) + XCTAssertNotEqual(sheets.activeSheetConfiguration?.presentationID, previousID) + XCTAssertEqual(sheets.sendSheetItem?.initialRoute, .lnurlPayConfirm) + XCTAssertEqual(app.lnurlPayData?.minSendable, 222_222, + "Retiring Send must not clear the external replacement target") + sheets.hideSheet(reason: "Close external replacement") + try await Task.sleep(for: .milliseconds(500)) + XCTAssertNil(app.lnurlPayData, "The replacement must still clean up its own state") + } + + func testImmediateOwnedResetDismissalReleasesContactContext() async throws { + let (app, sheets, _, window, _) = makeHost() + defer { sheets.hideSheet(reason: "Test cleanup"); app.resetSendState(); window.isHidden = true; window.rootViewController = nil } + sheets.showSheet(.send, data: SendConfig(view: .options)) + try await Task.sleep(for: .seconds(1)) + let presentationID = try XCTUnwrap(sheets.activeSheetConfiguration?.presentationID) + XCTAssertTrue(app.claimContactPaymentContext(ContactPaymentContext(publicKey: "first-contact"))) + app.resetSendState(preservingContactPaymentContext: true, ownerID: presentationID) + app.lnurlPayData = makeLnurlData() + app.selectedWalletToPayFrom = .lightning + // Real dismissal can happen before SwiftUI delivers the generation onChange. + sheets.hideSheet(reason: "Close immediately after own scan reset") + XCTAssertNil(app.lnurlPayData) + XCTAssertNil(app.contactPaymentContext, "Closing must release this Send's contact operation") + XCTAssertTrue(app.claimContactPaymentContext(ContactPaymentContext(publicKey: "next-contact")), + "A later contact payment must not be blocked by the dismissed Send") + } + + func testTransientSendUnmountKeepsCurrentLnurlState() async throws { + let (app, sheets, prompt, window, _) = makeHost() + defer { sheets.hideSheet(reason: "Test cleanup"); window.isHidden = true; window.rootViewController = nil } + app.lnurlPayData = makeLnurlData() + app.selectedWalletToPayFrom = .lightning + sheets.showSheet(.send, data: SendConfig(view: .lnurlPayConfirm)) + try await Task.sleep(for: .seconds(1)) + let presentationID = try XCTUnwrap(sheets.activeSheetConfiguration?.presentationID) + XCTAssertEqual(app.lnurlPayData?.minSendable, 222_222) + // Nested sheet dismissal may recreate Send content without ending its presentation. + prompt.isSendMounted = false + try await Task.sleep(for: .milliseconds(500)) + XCTAssertEqual(sheets.activeSheetConfiguration?.presentationID, presentationID) + XCTAssertEqual(app.lnurlPayData?.minSendable, 222_222, "A transient unmount must retain the active scan") + prompt.isSendMounted = true + try await Task.sleep(for: .milliseconds(500)) + XCTAssertEqual(app.lnurlPayData?.minSendable, 222_222, "Replacement content needs the active scan") + sheets.hideSheet(reason: "Close current presentation") + try await Task.sleep(for: .milliseconds(500)) + XCTAssertNil(app.lnurlPayData, "Ending the presentation must still clean up") + } + + func testRemountDoesNotRepeatOptionsSetupAfterLnurlScan() async throws { + let (app, sheets, prompt, window, wallet) = makeHost() + defer { sheets.hideSheet(reason: "Test cleanup"); window.isHidden = true; window.rootViewController = nil } + sheets.showSheet(.send, data: SendConfig(view: .options)) + try await Task.sleep(for: .seconds(1)) + app.resetSendState(ownerID: sheets.activeSheetConfiguration?.presentationID) + app.lnurlPayData = makeLnurlData() + app.selectedWalletToPayFrom = .lightning + wallet.sendAmountSats = 150 + let flow = try XCTUnwrap(sheets.sendSheetItem?.state) + flow.navigationPath = [.lnurlPayConfirm] + try await Task.sleep(for: .milliseconds(100)) + prompt.isSendMounted = false + try await Task.sleep(for: .milliseconds(500)) + prompt.isSendMounted = true + try await Task.sleep(for: .milliseconds(500)) + XCTAssertEqual(flow.rootRoute, .options) + XCTAssertEqual(flow.navigationPath, [.lnurlPayConfirm]) + XCTAssertEqual(app.lnurlPayData?.minSendable, 222_222, "Recreated options-root Send must not clear its scanned target") + XCTAssertEqual(wallet.sendAmountSats, 150, "Recreated Send must not reset the entered amount") + sheets.hideSheet(reason: "Close remounted send") + try await Task.sleep(for: .milliseconds(500)) + XCTAssertNil(app.lnurlPayData) + XCTAssertNil(wallet.sendAmountSats) + } + + func testRemountPreservesVariableLnurlEnteredAmount() async throws { + let (app, sheets, prompt, window, wallet) = makeHost() + defer { sheets.hideSheet(reason: "Test cleanup"); window.isHidden = true; window.rootViewController = nil } + app.lnurlPayData = LnurlPayData( + uri: "https://example.com/variable", callback: "https://example.com/callback", + minSendable: 149_500, maxSendable: 200_999, + metadataStr: "[]", commentAllowed: 12, allowsNostr: false, nostrPubkey: nil + ) + app.selectedWalletToPayFrom = .lightning + sheets.showSheet(.send, data: SendConfig(view: .lnurlPayAmount)) + try await Task.sleep(for: .seconds(1)) + wallet.sendAmountSats = 150 + prompt.isSendMounted = false + try await Task.sleep(for: .milliseconds(500)) + prompt.isSendMounted = true + try await Task.sleep(for: .milliseconds(500)) + XCTAssertEqual(app.lnurlPayData?.minSendable, 149_500) + XCTAssertEqual(wallet.sendAmountSats, 150, "Recreated amount-root Send must retain its confirmed amount") + sheets.hideSheet(reason: "Close remounted variable send") + try await Task.sleep(for: .milliseconds(500)) + XCTAssertNil(app.lnurlPayData) + XCTAssertNil(wallet.sendAmountSats) + } + + func testRemountRetainsLnurlNavigationAndAmountDraft() async throws { + let (app, sheets, prompt, window, wallet) = makeHost() + defer { sheets.hideSheet(reason: "Test cleanup"); window.isHidden = true; window.rootViewController = nil } + app.lnurlPayData = LnurlPayData( + uri: "https://example.com/variable", callback: "https://example.com/callback", + minSendable: 149_500, maxSendable: 200_999, + metadataStr: "[]", commentAllowed: 12, allowsNostr: false, nostrPubkey: nil + ) + app.selectedWalletToPayFrom = .lightning + sheets.showSheet(.send, data: SendConfig(view: .lnurlPayAmount)) + try await Task.sleep(for: .seconds(1)) + let flow = try XCTUnwrap(sheets.sendSheetItem?.state) + let input = try XCTUnwrap(flow.lnurlAmountInput) + input.updateFromSats(150, currency: CurrencyViewModel(currencyService: OfflineCurrencyService())) + wallet.sendAmountSats = 150 + flow.navigationPath = [.lnurlPayConfirm] + flow.routingCacheResetAttempted = true + prompt.isSendMounted = false + try await Task.sleep(for: .milliseconds(500)) + prompt.isSendMounted = true + try await Task.sleep(for: .milliseconds(500)) + XCTAssertTrue(sheets.sendSheetItem?.state === flow) + XCTAssertEqual(flow.rootRoute, .lnurlPayAmount) + XCTAssertEqual(flow.navigationPath, [.lnurlPayConfirm]) + XCTAssertEqual(flow.lnurlAmountInput?.amountSats, 150) + XCTAssertTrue(flow.lnurlAmountInput === input) + XCTAssertTrue(flow.routingCacheResetAttempted) + XCTAssertEqual(wallet.sendAmountSats, 150) + XCTAssertEqual(app.lnurlPayData?.minSendable, 149_500) + sheets.hideSheet(reason: "Close remounted review") + try await Task.sleep(for: .milliseconds(500)) + XCTAssertNil(app.lnurlPayData) + XCTAssertNil(wallet.sendAmountSats) + } + + func testNewScanStartsANewAmountDraftWithinTheSamePresentation() async throws { + let (app, sheets, _, window, _) = makeHost() + defer { sheets.hideSheet(reason: "Test cleanup"); window.isHidden = true; window.rootViewController = nil } + app.lnurlPayData = makeLnurlData() + app.selectedWalletToPayFrom = .lightning + sheets.showSheet(.send, data: SendConfig(view: .lnurlPayAmount)) + try await Task.sleep(for: .seconds(1)) + let flow = try XCTUnwrap(sheets.sendSheetItem?.state) + let first = try XCTUnwrap(flow.lnurlAmountInput) + first.updateFromSats(150, currency: CurrencyViewModel(currencyService: OfflineCurrencyService())) + app.resetSendState(ownerID: sheets.activeSheetConfiguration?.presentationID) + app.lnurlPayData = makeLnurlData() + app.selectedWalletToPayFrom = .lightning + try await Task.sleep(for: .milliseconds(500)) + let second = try XCTUnwrap(flow.lnurlAmountInput) + XCTAssertFalse(first === second, "A distinct scan must not inherit the old amount draft") + XCTAssertEqual(second.amountSats, 0) + sheets.hideSheet(reason: "Close new scan") + } + + func testClosingUnmountedSendStillCleansPresentationState() async throws { + let (app, sheets, prompt, window, wallet) = makeHost() + defer { sheets.hideSheet(reason: "Test cleanup"); window.isHidden = true; window.rootViewController = nil } + app.lnurlPayData = makeLnurlData() + app.selectedWalletToPayFrom = .lightning + sheets.showSheet(.send, data: SendConfig(view: .lnurlPayConfirm)) + try await Task.sleep(for: .seconds(1)) + wallet.sendAmountSats = 150 + prompt.isSendMounted = false + try await Task.sleep(for: .milliseconds(500)) + XCTAssertNotNil(app.lnurlPayData) + sheets.hideSheet(reason: "Close while content is absent") + try await Task.sleep(for: .milliseconds(500)) + XCTAssertNil(app.lnurlPayData) + XCTAssertNil(wallet.sendAmountSats) + } + + func testMissingLnurlDataClosesItsPresentation() async throws { + let (app, sheets, _, window, _) = makeHost() + defer { sheets.hideSheet(reason: "Test cleanup"); window.isHidden = true; window.rootViewController = nil } + app.lnurlPayData = makeLnurlData() + app.selectedWalletToPayFrom = .lightning + sheets.showSheet(.send, data: SendConfig(view: .lnurlPayConfirm)) + try await Task.sleep(for: .seconds(1)) + XCTAssertEqual(sheets.activeSheetConfiguration?.id, .send) + app.lnurlPayData = nil + try await Task.sleep(for: .milliseconds(500)) + XCTAssertNil(sheets.activeSheetConfiguration, "A confirmation without LNURL data must close instead of staying blank") + } + + func testMissingLnurlDataClosesAmountRootedPresentation() async throws { + let (app, sheets, _, window, _) = makeHost() + defer { sheets.hideSheet(reason: "Test cleanup"); window.isHidden = true; window.rootViewController = nil } + app.lnurlPayData = LnurlPayData( + uri: "https://example.com/variable", callback: "https://example.com/callback", + minSendable: 149_500, maxSendable: 200_999, + metadataStr: "[]", commentAllowed: 12, allowsNostr: false, nostrPubkey: nil + ) + app.selectedWalletToPayFrom = .lightning + sheets.showSheet(.send, data: SendConfig(view: .lnurlPayAmount)) + try await Task.sleep(for: .seconds(1)) + XCTAssertEqual(sheets.activeSheetConfiguration?.id, .send) + app.lnurlPayData = nil + try await Task.sleep(for: .milliseconds(500)) + XCTAssertNil(sheets.activeSheetConfiguration, "An amount root without LNURL data must close, not stay unusable") + } + + func testMissingDataClosesAmountRootAfterContinuingToReview() async throws { + let (app, sheets, _, window, _) = makeHost() + defer { sheets.hideSheet(reason: "Test cleanup"); window.isHidden = true; window.rootViewController = nil } + app.lnurlPayData = makeLnurlData() + app.selectedWalletToPayFrom = .lightning + sheets.showSheet(.send, data: SendConfig(view: .lnurlPayAmount)) + try await Task.sleep(for: .seconds(1)) + let flow = try XCTUnwrap(sheets.sendSheetItem?.state) + flow.navigationPath = [.lnurlPayConfirm] + try await Task.sleep(for: .milliseconds(500)) + app.lnurlPayData = nil + try await Task.sleep(for: .milliseconds(500)) + XCTAssertNil(sheets.activeSheetConfiguration, "An unavailable LNURL root must close even after continuing to review") + } + + func testMissingDataReturnsToRouteBeforeLnurlEntry() async throws { + let (app, sheets, _, window, _) = makeHost() + defer { sheets.hideSheet(reason: "Test cleanup"); window.isHidden = true; window.rootViewController = nil } + sheets.showSheet(.send, data: SendConfig(view: .options)) + try await Task.sleep(for: .seconds(1)) + let presentationID = try XCTUnwrap(sheets.activeSheetConfiguration?.presentationID) + let flow = try XCTUnwrap(sheets.sendSheetItem?.state) + app.lnurlPayData = makeLnurlData() + app.selectedWalletToPayFrom = .lightning + flow.navigationPath = [.manual, .lnurlPayAmount, .lnurlPayConfirm] + try await Task.sleep(for: .milliseconds(500)) + app.lnurlPayData = nil + try await Task.sleep(for: .milliseconds(500)) + XCTAssertEqual(sheets.activeSheetConfiguration?.presentationID, presentationID) + XCTAssertEqual(flow.navigationPath, [.manual], "Leave every unavailable LNURL route and retain the preceding recipient screen") + } + + func testMissingDataWaitsForPaymentSubmissionToFinish() async throws { + let (app, sheets, _, window, _) = makeHost() + defer { sheets.hideSheet(reason: "Test cleanup"); window.isHidden = true; window.rootViewController = nil } + app.lnurlPayData = makeLnurlData() + app.selectedWalletToPayFrom = .lightning + sheets.showSheet(.send, data: SendConfig(view: .lnurlPayConfirm)) + try await Task.sleep(for: .seconds(1)) + let flow = try XCTUnwrap(sheets.sendSheetItem?.state) + flow.isSubmittingPayment = true + app.lnurlPayData = nil + try await Task.sleep(for: .milliseconds(500)) + XCTAssertEqual(sheets.activeSheetConfiguration?.id, .send) + flow.isSubmittingPayment = false + try await Task.sleep(for: .milliseconds(500)) + XCTAssertNil(sheets.activeSheetConfiguration, "Recheck unavailable data after the active submission ends") + } + + func testEmbeddedSendExitCleansStateWhileSubscriptionRemainsActive() async throws { + let (app, sheets, prompt, window, _) = makeHost(isEmbedded: true, lnurlPayData: makeLnurlData()) + defer { sheets.hideSheet(reason: "Test cleanup"); window.isHidden = true; window.rootViewController = nil } + try await Task.sleep(for: .seconds(1)) + let presentationID = try XCTUnwrap(sheets.activeSheetConfiguration?.presentationID) + XCTAssertEqual(app.lnurlPayData?.minSendable, 222_222) + prompt.isSendMounted = false + try await Task.sleep(for: .milliseconds(500)) + XCTAssertEqual(sheets.activeSheetConfiguration?.id, .subscription) + XCTAssertEqual(sheets.activeSheetConfiguration?.presentationID, presentationID) + XCTAssertNil(app.lnurlPayData, "Leaving embedded Send must clean up even while its parent stays open") + } + + private func makeLnurlData() -> LnurlPayData { + LnurlPayData(uri: "https://example.com/current", callback: "https://example.com/callback", + minSendable: 222_222, maxSendable: 222_222, metadataStr: "[]", + commentAllowed: nil, allowsNostr: false, nostrPubkey: nil) + } + + private func makeHost(isEmbedded: Bool = false, + lnurlPayData: LnurlPayData? = nil) -> (AppViewModel, SheetViewModel, ScanPromptState, UIWindow, WalletViewModel) + { + snapshotAppDefaultsDomain() + snapshotAppGroupDefaults("home_screen_display_currency_code_v1", "home_screen_display_currency_symbol_v1") + let app = AppViewModel() + if let lnurlPayData { + app.lnurlPayData = lnurlPayData + app.selectedWalletToPayFrom = .lightning + } + let wallet = WalletViewModel() + wallet.channelCount = 0 + wallet.channels = [] + wallet.nodeLifecycleState = .running + let sheets = SheetViewModel() + if isEmbedded { + sheets.showSheet(.subscription) + } + let prompt = ScanPromptState() + let root = SendPresentationHarness(sheets: sheets, prompt: prompt, isEmbedded: isEmbedded) + .environment(PaykitPaymentRequestManager()) + .environment(HwWalletManager()) + .environment(TrezorManager()) + .environment(CameraManager.shared) + .environmentObject(app) + .environmentObject(wallet) + .environmentObject(NetworkMonitor()) + .environmentObject(SettingsViewModel.shared) + .environmentObject(TagManager()) + .environmentObject(PubkyProfileManager()) + .environmentObject(ActivityListViewModel()) + .environmentObject(ContactsManager()) + .environmentObject(CurrencyViewModel(currencyService: OfflineCurrencyService())) + .environmentObject(FeeEstimatesManager()) + .environmentObject(ScannerManager()) + .environmentObject(NavigationViewModel()) + let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 390, height: 844)) + window.rootViewController = UIHostingController(rootView: root) + window.makeKeyAndVisible() + window.rootViewController?.view.layoutIfNeeded() + return (app, sheets, prompt, window, wallet) + } +} + +private struct SendPresentationHarness: View { + @ObservedObject var sheets: SheetViewModel + @ObservedObject var prompt: ScanPromptState + var isEmbedded = false + var body: some View { + if isEmbedded { + if prompt.isSendMounted { + SendSheet(config: SendSheetItem(presentationID: sheets.activeSheetConfiguration?.presentationID, + initialRoute: .lnurlPayConfirm), isEmbedded: true) + .environmentObject(sheets) + } + } else { + Color.black + .sheet(item: $sheets.scannerSheetItem, onDismiss: { + sheets.hideSheetIfActive(.scanner, reason: "Scanner sheet dismissed") + }) { _ in + Text("Scanner fixture") + .sheet(isPresented: $prompt.isPresented) { Text("Manual entry fixture") } + } + .sheet(item: $sheets.sendSheetItem, onDismiss: { + sheets.hideSheetIfActive(.send, reason: "Send sheet dismissed") + }) { config in + if prompt.isSendMounted { + SendSheet(config: config) + .environmentObject(sheets) + } + } + } + } +} + +private final class ScanPromptState: ObservableObject { + @Published var isPresented = false + @Published var isSendMounted = true +} diff --git a/BitkitTests/SheetViewModelTests.swift b/BitkitTests/SheetViewModelTests.swift index aa3286b43..7aaa4df75 100644 --- a/BitkitTests/SheetViewModelTests.swift +++ b/BitkitTests/SheetViewModelTests.swift @@ -3,6 +3,39 @@ import XCTest @MainActor final class SheetViewModelTests: XCTestCase { + func testSendStateBelongsToThePresentationRatherThanTheConfig() throws { + let sheets = SheetViewModel() + sheets.showSheet(.send) + let ordinary = try XCTUnwrap(sheets.sendSheetItem) + XCTAssertTrue(sheets.sendSheetItem?.state === ordinary.state) + sheets.hideSheet() + let config = SendConfig(view: .lnurlPayAmount) + sheets.showSheet(.send, data: config) + let first = try XCTUnwrap(sheets.sendSheetItem) + first.state.navigationPath = [.lnurlPayConfirm] + sheets.hideSheet() + sheets.showSheet(.send, data: config) + let second = try XCTUnwrap(sheets.sendSheetItem) + XCTAssertFalse(first.state === second.state) + XCTAssertTrue(second.state.navigationPath.isEmpty) + XCTAssertFalse(second.state.didStartSetup) + } + + func testSendPresentationIdentityIsStableUntilTheNextSend() throws { + let sheets = SheetViewModel() + sheets.showSheet(.send, data: SendConfig(view: .options)) + let first = try XCTUnwrap(sheets.sendSheetItem) + XCTAssertEqual(first.id, sheets.sendSheetItem?.id) + XCTAssertEqual(first.presentationID, sheets.activeSheetConfiguration?.presentationID) + + sheets.hideSheet(reason: "Close previous send") + sheets.showSheet(.send, data: SendConfig(view: .lnurlPayConfirm)) + let replacement = try XCTUnwrap(sheets.sendSheetItem) + XCTAssertNotEqual(first.id, replacement.id) + XCTAssertEqual(replacement.id, sheets.sendSheetItem?.id) + XCTAssertEqual(replacement.initialRoute, .lnurlPayConfirm) + } + func testReceivedTransactionCanReplaceOnlyReceive() { let sheets = SheetViewModel() XCTAssertTrue(sheets.canPresentReceivedTx) diff --git a/changelog.d/next/1099.fixed.md b/changelog.d/next/1099.fixed.md new file mode 100644 index 000000000..c00104d5c --- /dev/null +++ b/changelog.d/next/1099.fixed.md @@ -0,0 +1 @@ +Keeps LNURL payment reviews stable when screens change and prevents stale image scans or repeated swipes from replacing or duplicating a payment. diff --git a/journeys/lnurl/README.md b/journeys/lnurl/README.md index a2adecaf8..a70426740 100644 --- a/journeys/lnurl/README.md +++ b/journeys/lnurl/README.md @@ -34,3 +34,19 @@ the journey environment does not provide on an isolated stack. The failing and r callback (the send failure screen with a retry that succeeds once the callback is healthy) therefore has no journey here yet. `payment-requests/definite-pre-broadcast-retry.xml` covers it for a Payment Request on a wallet that has that balance. + +## Scanner presentation lifecycle + +`scanner-pay-lifecycle.xml` covers variable and fixed LNURL payment screens opened from the home +scanner, including repeated close/reopen and fractional-satoshi amounts. It needs a usable funded +regtest channel and the regular `lnurl-server` generator on port 23000; it stops before authorizing +any payment. Its setup is separate from the unfunded capacity-warning journey above. + +Before scanning, record whether Quick Pay is enabled, disable it and verify it is off. Fixed LNURLs +can otherwise pay automatically. Leave the amount and daily limits unchanged, close every payment +sheet before cleanup, and restore the original enabled state after the journey. + +`send-scanner-pay-lifecycle.xml` uses the same funded setup to scan from inside ordinary Send, +retain the variable amount when returning from confirmation, then open a fresh fixed-amount flow. +Production-host unit tests additionally force content recreation and verify route, draft and +presentation-end cleanup, including closing while Send content is temporarily absent. diff --git a/journeys/lnurl/scanner-pay-lifecycle.xml b/journeys/lnurl/scanner-pay-lifecycle.xml new file mode 100644 index 000000000..4a6d54230 --- /dev/null +++ b/journeys/lnurl/scanner-pay-lifecycle.xml @@ -0,0 +1,30 @@ + + + Regression for iOS issue 1087: replacing the QR scanner with LNURL amount or confirmation must + keep the current payment data. Closing a payment must not clear the data for a later payment. + This is an iOS presentation lifecycle regression, not a change to Android payment behavior. + + Use an onboarded regtest E2E build, a running Lightning node, and a usable channel with more + than 1000 sats of spending balance. Resolve introductory sheets before starting. Use a local + LNURL server reachable by the simulator. The regular bitkit-docker lnurl-server on port 23000 + accepts GET /generate/pay?minSendable=149500&maxSendable=200999&commentAllowed=12 for the + variable fixture, and GET /generate/pay?minSendable=N&maxSendable=N&commentAllowed=0 for + fixed fixtures. Obtain links for N=222000, 222538, 222222 and 500500. These are millisatoshis. + Use the returned lnurl strings, not BOLT11 invoice strings. Quick Pay must be off so fixed + fixtures show confirmation instead of automatically paying. No payment is authorized in this journey. + + + Open Settings, General, Quick Pay. Continue through its introduction if shown. Record the enabled state of "QuickpayToggle", turn it off if needed, verify it is off, and return to wallet home. Leave its amount and daily limits unchanged + Open Send (id "Send"), close it, and verify the wallet home screen is visible + Open the home QR scanner, then the E2E manual scan prompt (id "ScanPrompt") + Enter the variable LNURL fixture in "QRInput" and tap "DialogConfirm" + Verify "SendNumberField" and "ContinueAmount" are visible and the app remains open + Enter 150 sats and tap "ContinueAmount" + Verify "ReviewAmount-primary" shows 150 and "CommentInput" is visible; do not swipe to pay + Dismiss the payment sheet and verify the wallet home screen is visible + For each fixed LNURL fixture in order (222000, 222538, 222222, 500500 msat), open the home QR scanner, enter its link through "ScanPrompt", "QRInput" and "DialogConfirm", and verify "ReviewAmount-primary" is visible with 222, 223, 223 and 501 sats respectively; "CommentInput" is absent and no amount-entry step is required + After each fixed confirmation, dismiss it without paying and verify the wallet home screen remains responsive before scanning the next fixture + Repeat the variable and fixed scan sequence five times, including opening and closing Send before a sequence; verify no empty payment sheet or app exit occurs + With all payment sheets closed, return to Quick Pay settings, restore "QuickpayToggle" to its recorded enabled state, verify it, and return to wallet home + + diff --git a/journeys/lnurl/send-scanner-pay-lifecycle.xml b/journeys/lnurl/send-scanner-pay-lifecycle.xml new file mode 100644 index 000000000..baf83b3cd --- /dev/null +++ b/journeys/lnurl/send-scanner-pay-lifecycle.xml @@ -0,0 +1,22 @@ + + + Use the funded regtest E2E setup and variable/fixed LNURL fixtures described in + scanner-pay-lifecycle.xml. Quick Pay must be off. This covers an ordinary Send + presentation whose initial route stays options while recipient input adds payment routes. + Use Paste Invoice, which reaches the same Send scan handler; ordinary Send has no + ScanPrompt control. The entry steps were corrected after the original J2 was blocked. + Do not authorize a payment. The production-host unit regressions force content + recreation to verify the retained navigation, amount draft and final cleanup. + + + Record the Quick Pay enabled state, disable it if needed, verify it is off, and return home without changing its limits + Open Send (id "Send") and verify recipient options are visible + Set the simulator clipboard to the variable LNURL fixture and tap Paste Invoice (id "RecipientInvoice") inside Send + Verify "SendNumberField" and "ContinueAmount" are visible, enter 150 sats and tap "ContinueAmount" + Verify "ReviewAmount-primary" shows 150 and "CommentInput" is visible; do not swipe to pay + Navigate back to the LNURL amount screen and verify "SendNumberField" still shows the entered 150 sats, then continue and verify the 150-sat confirmation again + Close the payment and open a fresh Send; verify recipient options appear without the previous LNURL amount or comment + Set the simulator clipboard to the fixed 500500-msat fixture and tap "RecipientInvoice"; verify "ReviewAmount-primary" shows 501, "CommentInput" is absent and no amount-entry step is required + Close all Send sheets, restore the recorded Quick Pay enabled state, verify it, and return home + +