From 77ee092d50b943e2795d1cdfb1621f4c1749b22b Mon Sep 17 00:00:00 2001 From: Piotr Stachyra Date: Thu, 22 Oct 2026 09:40:42 +0200 Subject: [PATCH 01/25] fix: preserve LNURL state across send dismissal --- Bitkit/ViewModels/AppViewModel.swift | 2 + Bitkit/ViewModels/SheetViewModel.swift | 1 + .../Subscriptions/SubscriptionsView.swift | 5 +- .../Views/Wallets/Send/LnurlPayAmount.swift | 11 +- Bitkit/Views/Wallets/Send/SendSheet.swift | 32 ++++- BitkitTests/LnurlPayConfirmTests.swift | 37 ++++++ BitkitTests/SendSheetLifecycleTests.swift | 110 ++++++++++++++++++ BitkitTests/SheetViewModelTests.swift | 15 +++ changelog.d/next/1087.fixed.md | 1 + journeys/lnurl/scanner-pay-lifecycle.xml | 27 +++++ 10 files changed, 233 insertions(+), 8 deletions(-) create mode 100644 BitkitTests/SendSheetLifecycleTests.swift create mode 100644 changelog.d/next/1087.fixed.md create mode 100644 journeys/lnurl/scanner-pay-lifecycle.xml diff --git a/Bitkit/ViewModels/AppViewModel.swift b/Bitkit/ViewModels/AppViewModel.swift index 330a38f44..786f822d8 100644 --- a/Bitkit/ViewModels/AppViewModel.swift +++ b/Bitkit/ViewModels/AppViewModel.swift @@ -159,6 +159,7 @@ class AppViewModel: ObservableObject { // LNURL @Published var lnurlPayData: LnurlPayData? @Published var lnurlWithdrawData: LnurlWithdrawData? + @Published private(set) var sendStateGeneration = 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. @@ -1304,6 +1305,7 @@ extension AppViewModel { } func resetSendState(preservingContactPaymentContext: Bool = false) { + sendStateGeneration = UUID() paykitUsesUsdt = false paykitReviewedAmount = nil paykitPaymentTerms = nil diff --git a/Bitkit/ViewModels/SheetViewModel.swift b/Bitkit/ViewModels/SheetViewModel.swift index 98cc53de7..e90ae7178 100644 --- a/Bitkit/ViewModels/SheetViewModel.swift +++ b/Bitkit/ViewModels/SheetViewModel.swift @@ -498,6 +498,7 @@ class SheetViewModel: ObservableObject { let sendConfig = config.data as? SendConfig let initialRoute = sendConfig?.initialRoute ?? .options return SendSheetItem( + presentationID: config.presentationID, 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..5951de18f 100644 --- a/Bitkit/Views/Wallets/Send/LnurlPayAmount.swift +++ b/Bitkit/Views/Wallets/Send/LnurlPayAmount.swift @@ -11,7 +11,8 @@ struct LnurlPayAmount: View { 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 +20,7 @@ struct LnurlPayAmount: View { } var isValid: Bool { - amount >= 0 && amount <= maxAmount + app.lnurlPayData != nil && amount >= 0 && amount <= maxAmount } var body: some View { @@ -90,8 +91,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 +105,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/SendSheet.swift b/Bitkit/Views/Wallets/Send/SendSheet.swift index d52a201e4..1b7d5d16c 100644 --- a/Bitkit/Views/Wallets/Send/SendSheet.swift +++ b/Bitkit/Views/Wallets/Send/SendSheet.swift @@ -100,13 +100,21 @@ struct SendConfig { } struct SendSheetItem: SheetItem { - let id: SheetID = .send + 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, + initialRoute: SendRoute = .options, + hardwareWalletId: String? = nil, + preparation: IncomingPaykitPaymentRequestPreparation? = nil + ) { + self.presentationID = presentationID + id = presentationID ?? UUID() self.initialRoute = initialRoute self.hardwareWalletId = hardwareWalletId self.preparation = preparation @@ -147,6 +155,14 @@ struct SendSheet: View { /// 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 + @State private var ownedSendStateGeneration: UUID? + + 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 @@ -272,6 +288,11 @@ struct SendSheet: View { .onChange(of: shouldShowSyncOverlay, initial: true) { _, isShowing in Logger.debug("shouldShowSyncOverlay: \(isShowing) (node: \(wallet.nodeLifecycleState))", context: "SendSheet") } + .onChange(of: app.sendStateGeneration) { + if isCurrentPresentation { + ownedSendStateGeneration = app.sendStateGeneration + } + } .task(id: config.preparation?.resolvedRoute) { if let preparation = config.preparation { guard let route = preparation.resolvedRoute, preparation.ownsSheet(sheets), @@ -359,10 +380,13 @@ struct SendSheet: View { if isPreparingRequest { return } - if let request = incomingPaymentRequest ?? app.contactPaymentContext?.incomingPaymentRequest { + let ownsSendState = ownedSendStateGeneration == app.sendStateGeneration + if let request = incomingPaymentRequest ?? (ownsSendState ? app.contactPaymentContext?.incomingPaymentRequest : nil) { Task { await paykitPaymentRequestManager.finishPayment(request) } } incomingPaymentRequest = nil + // A scan may have replaced this send's state before its dismiss animation finishes. + guard ownsSendState else { return } app.resetSendState() wallet.resetSendState(speed: settings.defaultTransactionSpeed) app.resetQuickPay() @@ -370,6 +394,7 @@ struct SendSheet: View { } private func setUpSend() { + guard isCurrentPresentation else { return } wallet.resetSendState(speed: settings.defaultTransactionSpeed) if let walletId = config.hardwareWalletId { let balance = hwWalletManager.fundingBalance(walletId: walletId) @@ -405,6 +430,7 @@ struct SendSheet: View { if currentRoot == .options { app.resetSendState() } + ownedSendStateGeneration = app.sendStateGeneration setupTask?.cancel() isSetupPending = true setupTask = Task { diff --git a/BitkitTests/LnurlPayConfirmTests.swift b/BitkitTests/LnurlPayConfirmTests.swift index 12b689ed9..9b36f6658 100644 --- a/BitkitTests/LnurlPayConfirmTests.swift +++ b/BitkitTests/LnurlPayConfirmTests.swift @@ -5,6 +5,43 @@ import XCTest @MainActor final class LnurlPayConfirmTests: XCTestCase { + 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/SendSheetLifecycleTests.swift b/BitkitTests/SendSheetLifecycleTests.swift new file mode 100644 index 000000000..37a66eb14 --- /dev/null +++ b/BitkitTests/SendSheetLifecycleTests.swift @@ -0,0 +1,110 @@ +@testable import Bitkit +import BitkitCore +import SwiftUI +import XCTest + +@MainActor +final class SendSheetLifecycleTests: XCTestCase { + func testScannerReplacementPreservesLnurlConfirmation() async throws { + snapshotAppDefaultsDomain() + snapshotAppGroupDefaults("home_screen_display_currency_code_v1", "home_screen_display_currency_symbol_v1") + let app = AppViewModel() + let wallet = WalletViewModel() + wallet.nodeLifecycleState = .running + let sheets = SheetViewModel() + let prompt = ScanPromptState() + let root = SendPresentationHarness(sheets: sheets, prompt: prompt) + .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() + defer { + 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() + 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") + } + } +} + +private struct SendPresentationHarness: View { + @ObservedObject var sheets: SheetViewModel + @ObservedObject var prompt: ScanPromptState + var body: some View { + 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 + SendSheet(config: config) + .environmentObject(sheets) + } + } +} + +private final class ScanPromptState: ObservableObject { + @Published var isPresented = false +} diff --git a/BitkitTests/SheetViewModelTests.swift b/BitkitTests/SheetViewModelTests.swift index aa3286b43..2ba0102fd 100644 --- a/BitkitTests/SheetViewModelTests.swift +++ b/BitkitTests/SheetViewModelTests.swift @@ -3,6 +3,21 @@ import XCTest @MainActor final class SheetViewModelTests: XCTestCase { + 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/1087.fixed.md b/changelog.d/next/1087.fixed.md new file mode 100644 index 000000000..6089b1389 --- /dev/null +++ b/changelog.d/next/1087.fixed.md @@ -0,0 +1 @@ +Fixed LNURL payments sometimes showing an empty confirmation or closing unexpectedly when switching between payment screens. diff --git a/journeys/lnurl/scanner-pay-lifecycle.xml b/journeys/lnurl/scanner-pay-lifecycle.xml new file mode 100644 index 000000000..9b09fce0e --- /dev/null +++ b/journeys/lnurl/scanner-pay-lifecycle.xml @@ -0,0 +1,27 @@ + + + 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. No payment is authorized in this journey. + + + 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 + + From 732596a6ef4f0981e9d227fa77e4a31f67549bcc Mon Sep 17 00:00:00 2001 From: Piotr Stachyra Date: Thu, 22 Oct 2026 09:43:20 +0200 Subject: [PATCH 02/25] docs: describe LNURL presentation regression setup --- journeys/lnurl/README.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/journeys/lnurl/README.md b/journeys/lnurl/README.md index a2adecaf8..76e889042 100644 --- a/journeys/lnurl/README.md +++ b/journeys/lnurl/README.md @@ -34,3 +34,10 @@ 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. From 49244c921595edf3dfa447afccd1629b0bb47e40 Mon Sep 17 00:00:00 2001 From: Piotr Stachyra Date: Thu, 22 Oct 2026 10:15:34 +0200 Subject: [PATCH 03/25] test: isolate cached channels in Paykit sync fallback --- BitkitTests/PaykitPaymentRequestServiceTests.swift | 4 ++++ 1 file changed, 4 insertions(+) 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: ""), ]) From ce275e31fae735485de2954b60ae5c56c88d0df5 Mon Sep 17 00:00:00 2001 From: Piotr Stachyra Date: Thu, 22 Oct 2026 10:33:57 +0200 Subject: [PATCH 04/25] docs: require Quick Pay off for LNURL scanner validation --- journeys/lnurl/README.md | 4 ++++ journeys/lnurl/scanner-pay-lifecycle.xml | 5 ++++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/journeys/lnurl/README.md b/journeys/lnurl/README.md index 76e889042..90ce09812 100644 --- a/journeys/lnurl/README.md +++ b/journeys/lnurl/README.md @@ -41,3 +41,7 @@ Payment Request on a wallet that has that balance. 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. diff --git a/journeys/lnurl/scanner-pay-lifecycle.xml b/journeys/lnurl/scanner-pay-lifecycle.xml index 9b09fce0e..4a6d54230 100644 --- a/journeys/lnurl/scanner-pay-lifecycle.xml +++ b/journeys/lnurl/scanner-pay-lifecycle.xml @@ -10,9 +10,11 @@ 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. No payment is authorized in this journey. + 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" @@ -23,5 +25,6 @@ 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 From e5acf1369c3d9208c75300bb22390ff62909a3bb Mon Sep 17 00:00:00 2001 From: Piotr Stachyra Date: Thu, 22 Oct 2026 11:02:42 +0200 Subject: [PATCH 05/25] test: cover transient LNURL send view teardown --- BitkitTests/SendSheetLifecycleTests.swift | 142 ++++++++++++++++------ 1 file changed, 103 insertions(+), 39 deletions(-) diff --git a/BitkitTests/SendSheetLifecycleTests.swift b/BitkitTests/SendSheetLifecycleTests.swift index 37a66eb14..7e4158173 100644 --- a/BitkitTests/SendSheetLifecycleTests.swift +++ b/BitkitTests/SendSheetLifecycleTests.swift @@ -6,35 +6,9 @@ import XCTest @MainActor final class SendSheetLifecycleTests: XCTestCase { func testScannerReplacementPreservesLnurlConfirmation() async throws { - snapshotAppDefaultsDomain() - snapshotAppGroupDefaults("home_screen_display_currency_code_v1", "home_screen_display_currency_symbol_v1") - let app = AppViewModel() - let wallet = WalletViewModel() - wallet.nodeLifecycleState = .running - let sheets = SheetViewModel() - let prompt = ScanPromptState() - let root = SendPresentationHarness(sheets: sheets, prompt: prompt) - .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() + let (app, sheets, prompt, window) = makeHost() defer { + sheets.hideSheet(reason: "Test cleanup") window.isHidden = true window.rootViewController = nil } @@ -83,28 +57,118 @@ final class SendSheetLifecycleTests: XCTestCase { XCTAssertNil(app.lnurlPayData, "Closing the current send must still clean up its state") } } + + func testTransientSendUnmountKeepsCurrentLnurlState() async throws { + let (app, sheets, prompt, window) = makeHost() + defer { 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 testEmbeddedSendExitCleansStateWhileSubscriptionRemainsActive() async throws { + let (app, sheets, prompt, window) = makeHost(isEmbedded: true) + defer { window.isHidden = true; window.rootViewController = nil } + app.lnurlPayData = makeLnurlData() + app.selectedWalletToPayFrom = .lightning + 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) -> (AppViewModel, SheetViewModel, ScanPromptState, UIWindow) { + snapshotAppDefaultsDomain() + snapshotAppGroupDefaults("home_screen_display_currency_code_v1", "home_screen_display_currency_symbol_v1") + let app = AppViewModel() + let wallet = WalletViewModel() + 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) + } } private struct SendPresentationHarness: View { @ObservedObject var sheets: SheetViewModel @ObservedObject var prompt: ScanPromptState + var isEmbedded = false var body: some View { - 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 - SendSheet(config: config) + 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 } From 5e4a3c1d5967b541ce59575dce2cd891ade7a17b Mon Sep 17 00:00:00 2001 From: Piotr Stachyra Date: Thu, 22 Oct 2026 11:05:03 +0200 Subject: [PATCH 06/25] fix: retain LNURL state during active send remount --- Bitkit/Views/Wallets/Send/SendSheet.swift | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Bitkit/Views/Wallets/Send/SendSheet.swift b/Bitkit/Views/Wallets/Send/SendSheet.swift index 1b7d5d16c..4b7f88a64 100644 --- a/Bitkit/Views/Wallets/Send/SendSheet.swift +++ b/Bitkit/Views/Wallets/Send/SendSheet.swift @@ -380,6 +380,9 @@ struct SendSheet: View { if isPreparingRequest { return } + // SwiftUI may recreate Send content while the same sheet stays presented. + // Embedded Send still ends when its parent leaves the payment route. + guard isEmbedded || !isCurrentPresentation else { return } let ownsSendState = ownedSendStateGeneration == app.sendStateGeneration if let request = incomingPaymentRequest ?? (ownsSendState ? app.contactPaymentContext?.incomingPaymentRequest : nil) { Task { await paykitPaymentRequestManager.finishPayment(request) } From 36ae9bce3cb2ff9b94b51f9fd2c724c8265feab8 Mon Sep 17 00:00:00 2001 From: Piotr Stachyra Date: Thu, 22 Oct 2026 11:52:12 +0200 Subject: [PATCH 07/25] chore: rename changelog fragment --- changelog.d/next/{1087.fixed.md => 1099.fixed.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename changelog.d/next/{1087.fixed.md => 1099.fixed.md} (100%) diff --git a/changelog.d/next/1087.fixed.md b/changelog.d/next/1099.fixed.md similarity index 100% rename from changelog.d/next/1087.fixed.md rename to changelog.d/next/1099.fixed.md From 9531bd5bb9bddf225a43b006190fc1b1a096e6a7 Mon Sep 17 00:00:00 2001 From: Piotr Stachyra Date: Thu, 22 Oct 2026 12:10:03 +0200 Subject: [PATCH 08/25] test: reproduce Send remount resetting payment state --- BitkitTests/SendSheetLifecycleTests.swift | 56 +++++++++++++++++++++-- 1 file changed, 51 insertions(+), 5 deletions(-) diff --git a/BitkitTests/SendSheetLifecycleTests.swift b/BitkitTests/SendSheetLifecycleTests.swift index 7e4158173..b03cae1e2 100644 --- a/BitkitTests/SendSheetLifecycleTests.swift +++ b/BitkitTests/SendSheetLifecycleTests.swift @@ -6,7 +6,7 @@ import XCTest @MainActor final class SendSheetLifecycleTests: XCTestCase { func testScannerReplacementPreservesLnurlConfirmation() async throws { - let (app, sheets, prompt, window) = makeHost() + let (app, sheets, prompt, window, _) = makeHost() defer { sheets.hideSheet(reason: "Test cleanup") window.isHidden = true @@ -59,7 +59,7 @@ final class SendSheetLifecycleTests: XCTestCase { } func testTransientSendUnmountKeepsCurrentLnurlState() async throws { - let (app, sheets, prompt, window) = makeHost() + let (app, sheets, prompt, window, _) = makeHost() defer { window.isHidden = true; window.rootViewController = nil } app.lnurlPayData = makeLnurlData() app.selectedWalletToPayFrom = .lightning @@ -80,8 +80,54 @@ final class SendSheetLifecycleTests: XCTestCase { XCTAssertNil(app.lnurlPayData, "Ending the presentation must still clean up") } + func testRemountDoesNotRepeatOptionsSetupAfterLnurlScan() async throws { + let (app, sheets, prompt, window, wallet) = makeHost() + defer { window.isHidden = true; window.rootViewController = nil } + sheets.showSheet(.send, data: SendConfig(view: .options)) + try await Task.sleep(for: .seconds(1)) + app.resetSendState() + app.lnurlPayData = makeLnurlData() + app.selectedWalletToPayFrom = .lightning + wallet.sendAmountSats = 150 + 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(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 { 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 testEmbeddedSendExitCleansStateWhileSubscriptionRemainsActive() async throws { - let (app, sheets, prompt, window) = makeHost(isEmbedded: true) + let (app, sheets, prompt, window, _) = makeHost(isEmbedded: true) defer { window.isHidden = true; window.rootViewController = nil } app.lnurlPayData = makeLnurlData() app.selectedWalletToPayFrom = .lightning @@ -101,7 +147,7 @@ final class SendSheetLifecycleTests: XCTestCase { commentAllowed: nil, allowsNostr: false, nostrPubkey: nil) } - private func makeHost(isEmbedded: Bool = false) -> (AppViewModel, SheetViewModel, ScanPromptState, UIWindow) { + private func makeHost(isEmbedded: Bool = false) -> (AppViewModel, SheetViewModel, ScanPromptState, UIWindow, WalletViewModel) { snapshotAppDefaultsDomain() snapshotAppGroupDefaults("home_screen_display_currency_code_v1", "home_screen_display_currency_symbol_v1") let app = AppViewModel() @@ -133,7 +179,7 @@ final class SendSheetLifecycleTests: XCTestCase { window.rootViewController = UIHostingController(rootView: root) window.makeKeyAndVisible() window.rootViewController?.view.layoutIfNeeded() - return (app, sheets, prompt, window) + return (app, sheets, prompt, window, wallet) } } From 11daf5d229c0a24c5495b534062778ad6d322e77 Mon Sep 17 00:00:00 2001 From: Piotr Stachyra Date: Thu, 22 Oct 2026 12:15:28 +0200 Subject: [PATCH 09/25] fix: retain Send presentation state across content recreation --- Bitkit/ViewModels/SheetViewModel.swift | 25 +- .../Views/Wallets/Send/LnurlPayAmount.swift | 7 +- Bitkit/Views/Wallets/Send/SendSheet.swift | 317 ++++++++++-------- BitkitTests/SendSheetLifecycleTests.swift | 56 ++++ BitkitTests/SheetViewModelTests.swift | 18 + changelog.d/next/1099.fixed.md | 2 +- 6 files changed, 278 insertions(+), 147 deletions(-) diff --git a/Bitkit/ViewModels/SheetViewModel.swift b/Bitkit/ViewModels/SheetViewModel.swift index e90ae7178..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? @@ -499,6 +521,7 @@ class SheetViewModel: ObservableObject { 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/Wallets/Send/LnurlPayAmount.swift b/Bitkit/Views/Wallets/Send/LnurlPayAmount.swift index 5951de18f..07e14e413 100644 --- a/Bitkit/Views/Wallets/Send/LnurlPayAmount.swift +++ b/Bitkit/Views/Wallets/Send/LnurlPayAmount.swift @@ -7,7 +7,12 @@ 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 diff --git a/Bitkit/Views/Wallets/Send/SendSheet.swift b/Bitkit/Views/Wallets/Send/SendSheet.swift index 4b7f88a64..f94bd7879 100644 --- a/Bitkit/Views/Wallets/Send/SendSheet.swift +++ b/Bitkit/Views/Wallets/Send/SendSheet.swift @@ -80,6 +80,37 @@ 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 + 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)? + var hwSend: HwSendCoordinator? + var lnurlAmountInput: AmountInputViewModel? + + init(initialRoute: SendRoute) { + rootRoute = initialRoute + } +} + struct SendConfig { let initialRoute: SendRoute let hardwareWalletId: String? @@ -100,6 +131,7 @@ struct SendConfig { } struct SendSheetItem: SheetItem { + let state: SendPresentationState let id: UUID let presentationID: UUID? let size: SheetSize = .large @@ -109,11 +141,13 @@ struct SendSheetItem: SheetItem { 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 @@ -136,26 +170,9 @@ 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 - @State private var ownedSendStateGeneration: UUID? + @State private var lnurlAmountInput: AmountInputViewModel private var isCurrentPresentation: Bool { guard let presentationID = config.presentationID else { @@ -167,24 +184,29 @@ struct SendSheet: View { 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) + let amountInput = config.state.lnurlAmountInput ?? AmountInputViewModel() + config.state.lnurlAmountInput = amountInput + _lnurlAmountInput = State(initialValue: amountInput) } 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 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) ) @@ -208,8 +230,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 } @@ -267,19 +289,19 @@ 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 } @@ -290,7 +312,7 @@ struct SendSheet: View { } .onChange(of: app.sendStateGeneration) { if isCurrentPresentation { - ownedSendStateGeneration = app.sendStateGeneration + flow.ownedSendStateGeneration = app.sendStateGeneration } } .task(id: config.preparation?.resolvedRoute) { @@ -300,7 +322,7 @@ struct SendSheet: View { app.contactPaymentContext == preparation.paymentContext, app.contactPaymentContext?.incomingPaymentRequest?.id == request.id else { return } - rootRoute = route + flow.rootRoute = route } setUpSend() } @@ -316,7 +338,7 @@ struct SendSheet: View { || app.lnurlPayData != nil guard hasScannedInvoice else { return } - if state == .running, !hasValidatedAfterSync { + if state == .running, !flow.hasValidatedAfterSync { validatePaymentAfterSync() } } @@ -330,7 +352,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() } } @@ -341,13 +363,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() } @@ -362,32 +384,33 @@ 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 } - // SwiftUI may recreate Send content while the same sheet stays presented. - // Embedded Send still ends when its parent leaves the payment route. - guard isEmbedded || !isCurrentPresentation else { return } - let ownsSendState = ownedSendStateGeneration == app.sendStateGeneration - if let request = incomingPaymentRequest ?? (ownsSendState ? app.contactPaymentContext?.incomingPaymentRequest : nil) { + let ownsSendState = flow.ownedSendStateGeneration == app.sendStateGeneration + if let request = flow.incomingPaymentRequest ?? (ownsSendState ? app.contactPaymentContext?.incomingPaymentRequest : nil) { Task { await paykitPaymentRequestManager.finishPayment(request) } } - incomingPaymentRequest = nil + flow.incomingPaymentRequest = nil // A scan may have replaced this send's state before its dismiss animation finishes. guard ownsSendState else { return } app.resetSendState() @@ -397,7 +420,11 @@ struct SendSheet: View { } private func setUpSend() { - guard isCurrentPresentation else { return } + 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) @@ -424,25 +451,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() } - ownedSendStateGeneration = app.sendStateGeneration - 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 { @@ -451,12 +478,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 @@ -472,7 +499,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 @@ -497,7 +524,7 @@ struct SendSheet: View { if canFallBackToOnchain { validatePaymentAfterSync(ignoreChannelWait: true) } else { - syncTimedOut = true + flow.syncTimedOut = true } } @@ -570,7 +597,7 @@ struct SendSheet: View { if isEmbedded { replaceRootRoute(with: failureRoute) } else { - navigationPath.append(failureRoute) + flow.navigationPath.append(failureRoute) } return } @@ -595,23 +622,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, @@ -620,8 +647,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 @@ -642,7 +671,7 @@ struct SendSheet: View { ), accessibilityIdentifier: "LnurlPayAmountTooLowToast" ) - hasValidatedAfterSync = true + flow.hasValidatedAfterSync = true return .failed } guard requestedAmount <= lnurlPayData.maxSendableSat else { @@ -651,17 +680,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 } @@ -706,30 +735,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 } } @@ -744,49 +773,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 } } @@ -800,7 +829,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 { @@ -876,18 +905,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 } @@ -903,25 +932,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 @@ -929,7 +958,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, @@ -958,18 +987,18 @@ 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, + navigationPath: $flow.navigationPath, + routingCacheResetAttempted: flow.routingCacheResetAttempted, replaceQuickPay: replaceQuickPay(with:) ) - .id(quickPaySession) + .id(flow.quickPaySession) case .pin: SendPinScreen(onCancel: { resolvePinCheck(false) }, onPinVerified: { resolvePinCheck(true) }) case let .pending(paymentHash, retryRoute, paymentRequest, paykitPaymentRequestId): @@ -978,28 +1007,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( @@ -1012,7 +1041,7 @@ struct SendSheet: View { context: context, onRetryReady: { didResetRoutingCaches in if didResetRoutingCaches { - routingCacheResetAttempted = true + flow.routingCacheResetAttempted = true } if let requestId = context.incomingPaymentRequestId { await retryIncomingPaymentRequest( @@ -1028,22 +1057,22 @@ struct SendSheet: View { : nil ) case .lnurlPayAmount: - LnurlPayAmount(navigationPath: $navigationPath) + LnurlPayAmount(navigationPath: $flow.navigationPath, amountViewModel: lnurlAmountInput) 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) @@ -1216,9 +1245,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 { @@ -1236,24 +1265,24 @@ 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 replaceRootRoute(with route: SendRoute) { - rootOverride = nil - rootRoute = route - navigationPath = [] - rootRouteGeneration &+= 1 + flow.rootOverride = nil + flow.rootRoute = route + flow.navigationPath = [] + flow.rootRouteGeneration &+= 1 } @MainActor @@ -1370,8 +1399,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() } @@ -1402,7 +1431,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/SendSheetLifecycleTests.swift b/BitkitTests/SendSheetLifecycleTests.swift index b03cae1e2..a15320260 100644 --- a/BitkitTests/SendSheetLifecycleTests.swift +++ b/BitkitTests/SendSheetLifecycleTests.swift @@ -89,11 +89,15 @@ final class SendSheetLifecycleTests: XCTestCase { 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") @@ -126,6 +130,58 @@ final class SendSheetLifecycleTests: XCTestCase { XCTAssertNil(wallet.sendAmountSats) } + func testRemountRetainsLnurlNavigationAndAmountDraft() async throws { + let (app, sheets, prompt, window, wallet) = makeHost() + defer { 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 testClosingUnmountedSendStillCleansPresentationState() async throws { + let (app, sheets, prompt, window, wallet) = makeHost() + defer { 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 testEmbeddedSendExitCleansStateWhileSubscriptionRemainsActive() async throws { let (app, sheets, prompt, window, _) = makeHost(isEmbedded: true) defer { window.isHidden = true; window.rootViewController = nil } diff --git a/BitkitTests/SheetViewModelTests.swift b/BitkitTests/SheetViewModelTests.swift index 2ba0102fd..7aaa4df75 100644 --- a/BitkitTests/SheetViewModelTests.swift +++ b/BitkitTests/SheetViewModelTests.swift @@ -3,6 +3,24 @@ 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)) diff --git a/changelog.d/next/1099.fixed.md b/changelog.d/next/1099.fixed.md index 6089b1389..963e75e7d 100644 --- a/changelog.d/next/1099.fixed.md +++ b/changelog.d/next/1099.fixed.md @@ -1 +1 @@ -Fixed LNURL payments sometimes showing an empty confirmation or closing unexpectedly when switching between payment screens. +Prevents LNURL payments from crashing, showing empty reviews, or losing their entered amount when payment screens are recreated. From 21fb09a99c59858518176265bcb9357e2e30d210 Mon Sep 17 00:00:00 2001 From: Piotr Stachyra Date: Thu, 22 Oct 2026 12:20:00 +0200 Subject: [PATCH 10/25] fix: scope retained LNURL drafts to their scan --- Bitkit/Views/Wallets/Send/SendSheet.swift | 23 +++++++++++++------ BitkitTests/SendSheetLifecycleTests.swift | 20 ++++++++++++++++ journeys/lnurl/README.md | 5 ++++ journeys/lnurl/send-scanner-pay-lifecycle.xml | 20 ++++++++++++++++ 4 files changed, 61 insertions(+), 7 deletions(-) create mode 100644 journeys/lnurl/send-scanner-pay-lifecycle.xml diff --git a/Bitkit/Views/Wallets/Send/SendSheet.swift b/Bitkit/Views/Wallets/Send/SendSheet.swift index f94bd7879..7a2951bb4 100644 --- a/Bitkit/Views/Wallets/Send/SendSheet.swift +++ b/Bitkit/Views/Wallets/Send/SendSheet.swift @@ -103,8 +103,20 @@ final class SendPresentationState { var didStartSetup = false var didFinish = false var onDismiss: (() -> Void)? - var hwSend: HwSendCoordinator? - var lnurlAmountInput: AmountInputViewModel? + @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 @@ -172,7 +184,6 @@ struct SendSheet: View { @State private var flow: SendPresentationState @State private var hwSend: HwSendCoordinator - @State private var lnurlAmountInput: AmountInputViewModel private var isCurrentPresentation: Bool { guard let presentationID = config.presentationID else { @@ -188,9 +199,6 @@ struct SendSheet: View { let hwSend = config.state.hwSend ?? HwSendCoordinator(walletId: config.hardwareWalletId) config.state.hwSend = hwSend _hwSend = State(initialValue: hwSend) - let amountInput = config.state.lnurlAmountInput ?? AmountInputViewModel() - config.state.lnurlAmountInput = amountInput - _lnurlAmountInput = State(initialValue: amountInput) } private var currentRoot: SendRoute { @@ -1057,7 +1065,8 @@ struct SendSheet: View { : nil ) case .lnurlPayAmount: - LnurlPayAmount(navigationPath: $flow.navigationPath, amountViewModel: lnurlAmountInput) + LnurlPayAmount(navigationPath: $flow.navigationPath, amountViewModel: flow.amountInput(for: app.sendStateGeneration)) + .id(app.sendStateGeneration) case .lnurlPayConfirm: LnurlPayConfirm( navigationPath: $flow.navigationPath, diff --git a/BitkitTests/SendSheetLifecycleTests.swift b/BitkitTests/SendSheetLifecycleTests.swift index a15320260..0e9394760 100644 --- a/BitkitTests/SendSheetLifecycleTests.swift +++ b/BitkitTests/SendSheetLifecycleTests.swift @@ -165,6 +165,26 @@ final class SendSheetLifecycleTests: XCTestCase { XCTAssertNil(wallet.sendAmountSats) } + func testNewScanStartsANewAmountDraftWithinTheSamePresentation() async throws { + let (app, sheets, _, window, _) = makeHost() + defer { 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() + 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 { window.isHidden = true; window.rootViewController = nil } diff --git a/journeys/lnurl/README.md b/journeys/lnurl/README.md index 90ce09812..a70426740 100644 --- a/journeys/lnurl/README.md +++ b/journeys/lnurl/README.md @@ -45,3 +45,8 @@ any payment. Its setup is separate from the unfunded capacity-warning journey ab 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/send-scanner-pay-lifecycle.xml b/journeys/lnurl/send-scanner-pay-lifecycle.xml new file mode 100644 index 000000000..e4f27443f --- /dev/null +++ b/journeys/lnurl/send-scanner-pay-lifecycle.xml @@ -0,0 +1,20 @@ + + + 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 the scanner adds payment routes. + 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 + Use the in-Send scanner E2E prompt (id "ScanPrompt"), enter the variable LNURL in "QRInput" and tap "DialogConfirm" + 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 + Use its in-Send scan prompt with the fixed 500500-msat fixture; 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 + + From cdfc5b47b0ad89c7b51d3e73a345e467bf635fb2 Mon Sep 17 00:00:00 2001 From: Piotr Stachyra Date: Thu, 22 Oct 2026 12:23:08 +0200 Subject: [PATCH 11/25] test: isolate Send lifecycle host from persisted channels --- BitkitTests/SendSheetLifecycleTests.swift | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/BitkitTests/SendSheetLifecycleTests.swift b/BitkitTests/SendSheetLifecycleTests.swift index 0e9394760..c361d5cd6 100644 --- a/BitkitTests/SendSheetLifecycleTests.swift +++ b/BitkitTests/SendSheetLifecycleTests.swift @@ -60,7 +60,7 @@ final class SendSheetLifecycleTests: XCTestCase { func testTransientSendUnmountKeepsCurrentLnurlState() async throws { let (app, sheets, prompt, window, _) = makeHost() - defer { window.isHidden = true; window.rootViewController = nil } + 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)) @@ -82,7 +82,7 @@ final class SendSheetLifecycleTests: XCTestCase { func testRemountDoesNotRepeatOptionsSetupAfterLnurlScan() async throws { let (app, sheets, prompt, window, wallet) = makeHost() - defer { window.isHidden = true; window.rootViewController = nil } + 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() @@ -108,7 +108,7 @@ final class SendSheetLifecycleTests: XCTestCase { func testRemountPreservesVariableLnurlEnteredAmount() async throws { let (app, sheets, prompt, window, wallet) = makeHost() - defer { window.isHidden = true; window.rootViewController = nil } + 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, @@ -132,7 +132,7 @@ final class SendSheetLifecycleTests: XCTestCase { func testRemountRetainsLnurlNavigationAndAmountDraft() async throws { let (app, sheets, prompt, window, wallet) = makeHost() - defer { window.isHidden = true; window.rootViewController = nil } + 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, @@ -167,7 +167,7 @@ final class SendSheetLifecycleTests: XCTestCase { func testNewScanStartsANewAmountDraftWithinTheSamePresentation() async throws { let (app, sheets, _, window, _) = makeHost() - defer { window.isHidden = true; window.rootViewController = nil } + 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)) @@ -187,7 +187,7 @@ final class SendSheetLifecycleTests: XCTestCase { func testClosingUnmountedSendStillCleansPresentationState() async throws { let (app, sheets, prompt, window, wallet) = makeHost() - defer { window.isHidden = true; window.rootViewController = nil } + 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)) @@ -204,7 +204,7 @@ final class SendSheetLifecycleTests: XCTestCase { func testEmbeddedSendExitCleansStateWhileSubscriptionRemainsActive() async throws { let (app, sheets, prompt, window, _) = makeHost(isEmbedded: true) - defer { window.isHidden = true; window.rootViewController = nil } + defer { sheets.hideSheet(reason: "Test cleanup"); window.isHidden = true; window.rootViewController = nil } app.lnurlPayData = makeLnurlData() app.selectedWalletToPayFrom = .lightning try await Task.sleep(for: .seconds(1)) @@ -228,6 +228,8 @@ final class SendSheetLifecycleTests: XCTestCase { snapshotAppGroupDefaults("home_screen_display_currency_code_v1", "home_screen_display_currency_symbol_v1") let app = AppViewModel() let wallet = WalletViewModel() + wallet.channelCount = 0 + wallet.channels = [] wallet.nodeLifecycleState = .running let sheets = SheetViewModel() if isEmbedded { From fc2fad49264a8efcb853f4082040f3edf1fece46 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 22 Oct 2026 12:35:23 +0200 Subject: [PATCH 12/25] fix: exit lnurl screens whose payment data is gone --- Bitkit/ViewModels/AppViewModel.swift | 4 ++++ Bitkit/Views/Wallets/Send/SendSheet.swift | 21 +++++++++++++++++++ BitkitTests/SendSheetLifecycleTests.swift | 25 ++++++++++++++++++++--- 3 files changed, 47 insertions(+), 3 deletions(-) diff --git a/Bitkit/ViewModels/AppViewModel.swift b/Bitkit/ViewModels/AppViewModel.swift index 786f822d8..042f0e8b8 100644 --- a/Bitkit/ViewModels/AppViewModel.swift +++ b/Bitkit/ViewModels/AppViewModel.swift @@ -360,6 +360,10 @@ class AppViewModel: ObservableObject { private let scanPaymentOperations: ScanPaymentOperations private let usdtBalance: () -> UInt64? private var scannedDataHandlingId: UUID? + var isHandlingScannedData: Bool { + scannedDataHandlingId != nil + } + private var manualEntryValidationSequence: UInt64 = 0 // Combine infrastructure for debounced validation diff --git a/Bitkit/Views/Wallets/Send/SendSheet.swift b/Bitkit/Views/Wallets/Send/SendSheet.swift index 4b7f88a64..760bd9802 100644 --- a/Bitkit/Views/Wallets/Send/SendSheet.swift +++ b/Bitkit/Views/Wallets/Send/SendSheet.swift @@ -179,6 +179,15 @@ struct SendSheet: View { config.preparation != nil && incomingPaymentRequest == nil } + private var isMissingLnurlPayData: Bool { + guard app.lnurlPayData == nil, !app.isHandlingScannedData, !isSubmittingPayment else { return false } + return Self.isLnurlPayRoute(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 } return Self.shouldDismissUnavailableRequest( @@ -285,6 +294,10 @@ struct SendSheet: View { 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") } @@ -1249,6 +1262,14 @@ struct SendSheet: View { navigationPath = [route] } + private func exitMissingLnurlPayment() { + if let index = navigationPath.firstIndex(where: Self.isLnurlPayRoute) { + 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 diff --git a/BitkitTests/SendSheetLifecycleTests.swift b/BitkitTests/SendSheetLifecycleTests.swift index 7e4158173..87fb0ea88 100644 --- a/BitkitTests/SendSheetLifecycleTests.swift +++ b/BitkitTests/SendSheetLifecycleTests.swift @@ -80,11 +80,23 @@ final class SendSheetLifecycleTests: XCTestCase { XCTAssertNil(app.lnurlPayData, "Ending the presentation must still clean up") } - func testEmbeddedSendExitCleansStateWhileSubscriptionRemainsActive() async throws { - let (app, sheets, prompt, window) = makeHost(isEmbedded: true) + func testMissingLnurlDataClosesItsPresentation() async throws { + let (app, sheets, _, window) = makeHost() defer { 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 testEmbeddedSendExitCleansStateWhileSubscriptionRemainsActive() async throws { + // Subscriptions route embedded Send to LNURL confirmation only once its data is set. + let (app, sheets, prompt, window) = makeHost(isEmbedded: true, lnurlPayData: makeLnurlData()) + defer { 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) @@ -101,10 +113,17 @@ final class SendSheetLifecycleTests: XCTestCase { commentAllowed: nil, allowsNostr: false, nostrPubkey: nil) } - private func makeHost(isEmbedded: Bool = false) -> (AppViewModel, SheetViewModel, ScanPromptState, UIWindow) { + private func makeHost( + isEmbedded: Bool = false, + lnurlPayData: LnurlPayData? = nil + ) -> (AppViewModel, SheetViewModel, ScanPromptState, UIWindow) { 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.nodeLifecycleState = .running let sheets = SheetViewModel() From f4e3c2ab3075fd469f9c2175c4cf6ae873e5672a Mon Sep 17 00:00:00 2001 From: Piotr Stachyra Date: Thu, 22 Oct 2026 12:35:41 +0200 Subject: [PATCH 13/25] test: reproduce external LNURL replacement clearing its target --- BitkitTests/SendSheetLifecycleTests.swift | 31 +++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/BitkitTests/SendSheetLifecycleTests.swift b/BitkitTests/SendSheetLifecycleTests.swift index c361d5cd6..17f817166 100644 --- a/BitkitTests/SendSheetLifecycleTests.swift +++ b/BitkitTests/SendSheetLifecycleTests.swift @@ -58,6 +58,37 @@ final class SendSheetLifecycleTests: XCTestCase { } } + 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 testTransientSendUnmountKeepsCurrentLnurlState() async throws { let (app, sheets, prompt, window, _) = makeHost() defer { sheets.hideSheet(reason: "Test cleanup"); window.isHidden = true; window.rootViewController = nil } From c725b107997e71f48fa538dad2f73974ea1deaaa Mon Sep 17 00:00:00 2001 From: Piotr Stachyra Date: Thu, 22 Oct 2026 12:37:57 +0200 Subject: [PATCH 14/25] fix: transfer Send state ownership only for its own scans --- Bitkit/Managers/ScannerManager.swift | 3 ++- Bitkit/ViewModels/AppViewModel.swift | 12 ++++++---- .../Wallets/Send/SendEnterManuallyView.swift | 5 ++++- Bitkit/Views/Wallets/Send/SendSheet.swift | 22 ++++++++++--------- BitkitTests/ScannerManagerTests.swift | 7 ++++++ BitkitTests/SendSheetLifecycleTests.swift | 6 ++--- 6 files changed, 36 insertions(+), 19 deletions(-) diff --git a/Bitkit/Managers/ScannerManager.swift b/Bitkit/Managers/ScannerManager.swift index a10a75f20..0e1a7aecc 100644 --- a/Bitkit/Managers/ScannerManager.swift +++ b/Bitkit/Managers/ScannerManager.swift @@ -212,7 +212,8 @@ class ScannerManager: ObservableObject { try await app.handleScannedData( uri, scope: scope, - alternativeOnchainBalanceSats: hwWalletManager?.maximumFundingBalanceSats ?? 0 + alternativeOnchainBalanceSats: hwWalletManager?.maximumFundingBalanceSats ?? 0, + sendPresentationID: sheets?.activeSheetConfiguration?.presentationID ) guard shouldOpenPaymentFlow(for: uri) else { completion(nil) diff --git a/Bitkit/ViewModels/AppViewModel.swift b/Bitkit/ViewModels/AppViewModel.swift index 786f822d8..6438cd36f 100644 --- a/Bitkit/ViewModels/AppViewModel.swift +++ b/Bitkit/ViewModels/AppViewModel.swift @@ -160,6 +160,8 @@ class AppViewModel: ObservableObject { @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. @@ -741,7 +743,8 @@ extension AppViewModel { _ uri: String, claimedContactPaymentContext: ContactPaymentContext? = nil, scope: ScanHandlingScope = .unrestricted, - alternativeOnchainBalanceSats: UInt64 = 0 + alternativeOnchainBalanceSats: UInt64 = 0, + sendPresentationID: UUID? = nil ) async throws { let handlingId = claimedContactPaymentContext?.id ?? UUID() if let claimedContactPaymentContext { @@ -758,7 +761,7 @@ extension AppViewModel { } if let context = claimedContactPaymentContext, context.requiresAssetSelection, scope != .onchainPayments { - resetSendState(preservingContactPaymentContext: true) + resetSendState(preservingContactPaymentContext: true, ownerID: sendPresentationID) for endpoint in context.endpoints where endpoint.methodId != .usdtArbitrum { switch try await decode(invoice: endpoint.paymentRequest) { case let .onChain(invoice): scannedOnchainInvoice = invoice @@ -830,7 +833,7 @@ extension AppViewModel { } // Reset send state before handling new data - resetSendState(preservingContactPaymentContext: claimedContactPaymentContext != nil) + resetSendState(preservingContactPaymentContext: claimedContactPaymentContext != nil, ownerID: sendPresentationID) if scope == .unrestricted, let samRockSetup = SamRockSetupRequest.parse(uri) { handleBTCPayConnection(samRockSetup) @@ -1304,7 +1307,8 @@ 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 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/SendSheet.swift b/Bitkit/Views/Wallets/Send/SendSheet.swift index 7a2951bb4..1f25581ea 100644 --- a/Bitkit/Views/Wallets/Send/SendSheet.swift +++ b/Bitkit/Views/Wallets/Send/SendSheet.swift @@ -319,7 +319,8 @@ struct SendSheet: View { Logger.debug("shouldShowSyncOverlay: \(isShowing) (node: \(wallet.nodeLifecycleState))", context: "SendSheet") } .onChange(of: app.sendStateGeneration) { - if isCurrentPresentation { + // 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 } } @@ -421,7 +422,7 @@ struct SendSheet: View { flow.incomingPaymentRequest = nil // A scan may have replaced this send's state before its dismiss animation finishes. guard ownsSendState else { return } - app.resetSendState() + app.resetSendState(ownerID: config.presentationID) wallet.resetSendState(speed: settings.defaultTransactionSpeed) app.resetQuickPay() QuickPayPaymentCoordinator.shared.detach() @@ -447,7 +448,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 } @@ -466,7 +467,7 @@ struct SendSheet: View { flow.hasValidatedAfterSync = false flow.syncTimedOut = false if currentRoot == .options { - app.resetSendState() + app.resetSendState(ownerID: config.presentationID) } flow.ownedSendStateGeneration = app.sendStateGeneration flow.setupTask?.cancel() @@ -1309,7 +1310,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") @@ -1319,7 +1320,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 @@ -1351,14 +1352,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 } @@ -1387,7 +1389,7 @@ struct SendSheet: View { app.ownsContactPaymentContext(context) else { if app.ownsContactPaymentContext(context) { - app.resetSendState() + app.resetSendState(ownerID: config.presentationID) } return } @@ -1422,7 +1424,7 @@ struct SendSheet: View { ) { guard sheets.activeSheetConfiguration?.id == .subscription else { if let existingContext, app.ownsContactPaymentContext(existingContext) { - app.resetSendState() + app.resetSendState(ownerID: config.presentationID) } return } diff --git a/BitkitTests/ScannerManagerTests.swift b/BitkitTests/ScannerManagerTests.swift index d92c96251..87d99a5ae 100644 --- a/BitkitTests/ScannerManagerTests.swift +++ b/BitkitTests/ScannerManagerTests.swift @@ -53,6 +53,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 +69,7 @@ final class ScannerManagerTests: XCTestCase { XCTAssertEqual(completionCount, 1) XCTAssertEqual(hapticCount, 1) XCTAssertNotNil(app.scannedOnchainInvoice) + XCTAssertEqual(app.sendStateOwnerID, sheets.activeSheetConfiguration?.presentationID) } func testGenericSendPaymentScanPlaysOneSuccessHaptic() async { diff --git a/BitkitTests/SendSheetLifecycleTests.swift b/BitkitTests/SendSheetLifecycleTests.swift index 17f817166..60f56c611 100644 --- a/BitkitTests/SendSheetLifecycleTests.swift +++ b/BitkitTests/SendSheetLifecycleTests.swift @@ -48,7 +48,7 @@ final class SendSheetLifecycleTests: XCTestCase { XCTAssertEqual(sheets.sendSheetItem?.initialRoute, .lnurlPayConfirm) // Scanning again inside the current send transfers ownership to its new state. let current = app.lnurlPayData - app.resetSendState() + app.resetSendState(ownerID: sheets.activeSheetConfiguration?.presentationID) app.lnurlPayData = current app.selectedWalletToPayFrom = .lightning try await Task.sleep(for: .milliseconds(100)) @@ -116,7 +116,7 @@ final class SendSheetLifecycleTests: XCTestCase { 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() + app.resetSendState(ownerID: sheets.activeSheetConfiguration?.presentationID) app.lnurlPayData = makeLnurlData() app.selectedWalletToPayFrom = .lightning wallet.sendAmountSats = 150 @@ -206,7 +206,7 @@ final class SendSheetLifecycleTests: XCTestCase { let flow = try XCTUnwrap(sheets.sendSheetItem?.state) let first = try XCTUnwrap(flow.lnurlAmountInput) first.updateFromSats(150, currency: CurrencyViewModel(currencyService: OfflineCurrencyService())) - app.resetSendState() + app.resetSendState(ownerID: sheets.activeSheetConfiguration?.presentationID) app.lnurlPayData = makeLnurlData() app.selectedWalletToPayFrom = .lightning try await Task.sleep(for: .milliseconds(500)) From c863c9cab7b76f4aff97f4ca485d42d403c90655 Mon Sep 17 00:00:00 2001 From: Piotr Stachyra Date: Thu, 22 Oct 2026 12:46:01 +0200 Subject: [PATCH 15/25] test: reproduce contact cleanup before SwiftUI ownership updates --- BitkitTests/SendSheetLifecycleTests.swift | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/BitkitTests/SendSheetLifecycleTests.swift b/BitkitTests/SendSheetLifecycleTests.swift index 60f56c611..7e77deb35 100644 --- a/BitkitTests/SendSheetLifecycleTests.swift +++ b/BitkitTests/SendSheetLifecycleTests.swift @@ -89,6 +89,24 @@ final class SendSheetLifecycleTests: XCTestCase { 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 } From a58030b1d6c09ab7c4b32767d9eed1bb748d23fa Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 22 Oct 2026 12:47:22 +0200 Subject: [PATCH 16/25] fix: close lnurl-rooted send and recheck after scan handling --- Bitkit/ViewModels/AppViewModel.swift | 2 +- Bitkit/Views/Wallets/Send/SendSheet.swift | 2 +- BitkitTests/SendSheetLifecycleTests.swift | 17 +++++++++++++++++ 3 files changed, 19 insertions(+), 2 deletions(-) diff --git a/Bitkit/ViewModels/AppViewModel.swift b/Bitkit/ViewModels/AppViewModel.swift index 042f0e8b8..7625b86c2 100644 --- a/Bitkit/ViewModels/AppViewModel.swift +++ b/Bitkit/ViewModels/AppViewModel.swift @@ -359,7 +359,7 @@ 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 } diff --git a/Bitkit/Views/Wallets/Send/SendSheet.swift b/Bitkit/Views/Wallets/Send/SendSheet.swift index 760bd9802..861f25afe 100644 --- a/Bitkit/Views/Wallets/Send/SendSheet.swift +++ b/Bitkit/Views/Wallets/Send/SendSheet.swift @@ -1263,7 +1263,7 @@ struct SendSheet: View { } private func exitMissingLnurlPayment() { - if let index = navigationPath.firstIndex(where: Self.isLnurlPayRoute) { + if !Self.isLnurlPayRoute(currentRoot), let index = navigationPath.firstIndex(where: Self.isLnurlPayRoute) { navigationPath.removeSubrange(index...) } else { sheets.hideSheetIfActive(isEmbedded ? .subscription : .send, reason: "LNURL payment data is no longer available") diff --git a/BitkitTests/SendSheetLifecycleTests.swift b/BitkitTests/SendSheetLifecycleTests.swift index 87fb0ea88..8bd8ad052 100644 --- a/BitkitTests/SendSheetLifecycleTests.swift +++ b/BitkitTests/SendSheetLifecycleTests.swift @@ -93,6 +93,23 @@ final class SendSheetLifecycleTests: XCTestCase { XCTAssertNil(sheets.activeSheetConfiguration, "A confirmation without LNURL data must close instead of staying blank") } + func testMissingLnurlDataClosesAmountRootedPresentation() async throws { + let (app, sheets, _, window) = makeHost() + defer { 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 testEmbeddedSendExitCleansStateWhileSubscriptionRemainsActive() async throws { // Subscriptions route embedded Send to LNURL confirmation only once its data is set. let (app, sheets, prompt, window) = makeHost(isEmbedded: true, lnurlPayData: makeLnurlData()) From bbb43af77bf6bfe816cb65834b91b2397069e669 Mon Sep 17 00:00:00 2001 From: Piotr Stachyra Date: Thu, 22 Oct 2026 12:47:28 +0200 Subject: [PATCH 17/25] fix: release owned contact state even before UI updates --- .../Wallets/Send/SendContactSelectView.swift | 20 ++++++++++++------- Bitkit/Views/Wallets/Send/SendSheet.swift | 4 +++- 2 files changed, 16 insertions(+), 8 deletions(-) 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/SendSheet.swift b/Bitkit/Views/Wallets/Send/SendSheet.swift index 1f25581ea..12bd2db43 100644 --- a/Bitkit/Views/Wallets/Send/SendSheet.swift +++ b/Bitkit/Views/Wallets/Send/SendSheet.swift @@ -415,7 +415,9 @@ struct SendSheet: View { if isPreparingRequest { return } - let ownsSendState = flow.ownedSendStateGeneration == app.sendStateGeneration + // 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) } } From 69a066ea399d63014e6cc1580d235520477d6778 Mon Sep 17 00:00:00 2001 From: Piotr Stachyra Date: Thu, 22 Oct 2026 12:51:25 +0200 Subject: [PATCH 18/25] fix: check contact decode ownership before changing payment data --- Bitkit/ViewModels/AppViewModel.swift | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/Bitkit/ViewModels/AppViewModel.swift b/Bitkit/ViewModels/AppViewModel.swift index d24b13897..73d297288 100644 --- a/Bitkit/ViewModels/AppViewModel.swift +++ b/Bitkit/ViewModels/AppViewModel.swift @@ -767,13 +767,14 @@ extension AppViewModel { if let context = claimedContactPaymentContext, context.requiresAssetSelection, scope != .onchainPayments { resetSendState(preservingContactPaymentContext: true, ownerID: sendPresentationID) for endpoint in context.endpoints where endpoint.methodId != .usdtArbitrum { - switch try await decode(invoice: endpoint.paymentRequest) { + let data = try await decode(invoice: endpoint.paymentRequest) + try ensureScannedDataHandlingOwnership(handlingId, claimedContactPaymentContext: context) + 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 From f70387de013e55278e1a5b261ca0714485655c41 Mon Sep 17 00:00:00 2001 From: Piotr Stachyra Date: Thu, 22 Oct 2026 13:59:43 +0200 Subject: [PATCH 19/25] test: reproduce stale image and overlapping lnurl attempts --- Bitkit/Managers/ScannerManager.swift | 16 ++- .../Views/Wallets/Send/LnurlPayConfirm.swift | 15 ++- BitkitTests/LnurlPayConfirmTests.swift | 62 +++++++++ BitkitTests/ScannerManagerTests.swift | 120 ++++++++++++++++++ 4 files changed, 209 insertions(+), 4 deletions(-) diff --git a/Bitkit/Managers/ScannerManager.swift b/Bitkit/Managers/ScannerManager.swift index 0e1a7aecc..f9a5dca08 100644 --- a/Bitkit/Managers/ScannerManager.swift +++ b/Bitkit/Managers/ScannerManager.swift @@ -312,10 +312,22 @@ 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 } do { - let payload = try await QRCodeImageDecoder.decode(item) + let payload = try await decode() if context == .send { await handleSendScan(payload, scope: scope, completion: completion) } else { diff --git a/Bitkit/Views/Wallets/Send/LnurlPayConfirm.swift b/Bitkit/Views/Wallets/Send/LnurlPayConfirm.swift index 8b6b2be27..9644875d2 100644 --- a/Bitkit/Views/Wallets/Send/LnurlPayConfirm.swift +++ b/Bitkit/Views/Wallets/Send/LnurlPayConfirm.swift @@ -209,7 +209,20 @@ struct LnurlPayConfirm: View { } } + @MainActor + static func withSubmissionLock(isSubmitting: Binding, operation: () async throws -> Void) async throws { + 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 +276,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/BitkitTests/LnurlPayConfirmTests.swift b/BitkitTests/LnurlPayConfirmTests.swift index 9b36f6658..2d4d6aca2 100644 --- a/BitkitTests/LnurlPayConfirmTests.swift +++ b/BitkitTests/LnurlPayConfirmTests.swift @@ -5,6 +5,68 @@ 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 testAmountScreenSurvivesContextResetDuringDismissal() async { snapshotAppDefaultsDomain() snapshotAppGroupDefaults("home_screen_display_currency_code_v1", "home_screen_display_currency_symbol_v1") diff --git a/BitkitTests/ScannerManagerTests.swift b/BitkitTests/ScannerManagerTests.swift index 87d99a5ae..ef48b57a9 100644 --- a/BitkitTests/ScannerManagerTests.swift +++ b/BitkitTests/ScannerManagerTests.swift @@ -1,4 +1,5 @@ @testable import Bitkit +import BitkitCore import XCTest @MainActor @@ -104,6 +105,125 @@ final class ScannerManagerTests: XCTestCase { XCTAssertTrue(navigation.path.isEmpty) } + 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 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) + } + private func configure(_ scanner: ScannerManager) -> AppViewModel { let app = AppViewModel( sheetViewModel: SheetViewModel(), From 0fb101d212856298a26a1fa74551520d7b41fc89 Mon Sep 17 00:00:00 2001 From: Piotr Stachyra Date: Thu, 22 Oct 2026 14:01:52 +0200 Subject: [PATCH 20/25] fix: retain lnurl input ownership and submission lock --- Bitkit/Managers/ScannerManager.swift | 47 ++++++++++++------- .../Views/Wallets/Send/LnurlPayConfirm.swift | 3 +- changelog.d/next/1099.fixed.md | 2 +- 3 files changed, 32 insertions(+), 20 deletions(-) diff --git a/Bitkit/Managers/ScannerManager.swift b/Bitkit/Managers/ScannerManager.swift index f9a5dca08..da624bf94 100644 --- a/Bitkit/Managers/ScannerManager.swift +++ b/Bitkit/Managers/ScannerManager.swift @@ -325,33 +325,44 @@ class ScannerManager: ObservableObject { completion: @escaping (SendRoute?) -> Void = { _ in } ) async { guard let app else { return } + let presentationID = sheets?.activeSheetConfiguration?.presentationID + let navigationPath = navigation?.path + func isCurrentOrigin() -> Bool { + !Task.isCancelled && sheets?.activeSheetConfiguration?.presentationID == presentationID && + navigation?.path == navigationPath + } do { let payload = try await decode() + guard isCurrentOrigin() else { return } if context == .send { await handleSendScan(payload, scope: scope, completion: completion) } else { await handleScan(payload, context: context) } - } 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(), !(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/Views/Wallets/Send/LnurlPayConfirm.swift b/Bitkit/Views/Wallets/Send/LnurlPayConfirm.swift index 9644875d2..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() @@ -211,6 +211,7 @@ 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() diff --git a/changelog.d/next/1099.fixed.md b/changelog.d/next/1099.fixed.md index 963e75e7d..c00104d5c 100644 --- a/changelog.d/next/1099.fixed.md +++ b/changelog.d/next/1099.fixed.md @@ -1 +1 @@ -Prevents LNURL payments from crashing, showing empty reviews, or losing their entered amount when payment screens are recreated. +Keeps LNURL payment reviews stable when screens change and prevents stale image scans or repeated swipes from replacing or duplicating a payment. From 8f52f28a97401ab46e37c87e2cc022e1ce9b3f10 Mon Sep 17 00:00:00 2001 From: Piotr Stachyra Date: Thu, 22 Oct 2026 14:07:43 +0200 Subject: [PATCH 21/25] test: cover image payment resolution and automatic remount --- Bitkit/ViewModels/AppViewModel.swift | 9 ++-- BitkitTests/LnurlPayConfirmTests.swift | 68 ++++++++++++++++++++++++++ BitkitTests/ScannerManagerTests.swift | 66 +++++++++++++++++++++++-- 3 files changed, 136 insertions(+), 7 deletions(-) diff --git a/Bitkit/ViewModels/AppViewModel.swift b/Bitkit/ViewModels/AppViewModel.swift index 73d297288..1b210cd38 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 { @@ -767,7 +768,7 @@ extension AppViewModel { if let context = claimedContactPaymentContext, context.requiresAssetSelection, scope != .onchainPayments { resetSendState(preservingContactPaymentContext: true, ownerID: sendPresentationID) for endpoint in context.endpoints where endpoint.methodId != .usdtArbitrum { - let data = try await decode(invoice: endpoint.paymentRequest) + let data = try await scanPaymentOperations.decodeInvoice(endpoint.paymentRequest) try ensureScannedDataHandlingOwnership(handlingId, claimedContactPaymentContext: context) switch data { case let .onChain(invoice): scannedOnchainInvoice = invoice @@ -829,7 +830,7 @@ extension AppViewModel { ) return } - let data = try await decode(invoice: uri) + let data = try await scanPaymentOperations.decodeInvoice(uri) try ensureScannedDataHandlingOwnership(handlingId, claimedContactPaymentContext: claimedContactPaymentContext) guard ShopPaymentRequest.isSupported(data) else { throw ScanHandlingError.unsupportedRequest } prevalidatedPaymentRequest = data @@ -882,7 +883,7 @@ extension AppViewModel { if let prevalidatedPaymentRequest { data = prevalidatedPaymentRequest } else { - data = try await decode(invoice: uri) + data = try await scanPaymentOperations.decodeInvoice(uri) try ensureScannedDataHandlingOwnership(handlingId, claimedContactPaymentContext: claimedContactPaymentContext) } let paymentState = scanPaymentOperations.state() @@ -912,7 +913,7 @@ 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) + let lightningData = try await scanPaymentOperations.decodeInvoice(lnInvoice) try ensureScannedDataHandlingOwnership(handlingId, claimedContactPaymentContext: claimedContactPaymentContext) if case let .lightning(lightningInvoice) = lightningData { // Check lightning invoice network diff --git a/BitkitTests/LnurlPayConfirmTests.swift b/BitkitTests/LnurlPayConfirmTests.swift index 2d4d6aca2..f878baf72 100644 --- a/BitkitTests/LnurlPayConfirmTests.swift +++ b/BitkitTests/LnurlPayConfirmTests.swift @@ -67,6 +67,74 @@ final class LnurlPayConfirmTests: XCTestCase { } } + 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") diff --git a/BitkitTests/ScannerManagerTests.swift b/BitkitTests/ScannerManagerTests.swift index ef48b57a9..7f5a6267d 100644 --- a/BitkitTests/ScannerManagerTests.swift +++ b/BitkitTests/ScannerManagerTests.swift @@ -224,9 +224,68 @@ final class ScannerManagerTests: XCTestCase { XCTAssertNil(ToastWindowManager.shared.currentToast) } - private func configure(_ scanner: ScannerManager) -> AppViewModel { + func testImageResolutionCannotCompleteAfterOriginDismissalOrReplacement() async throws { + 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: .lnurlPay(data: imageTestLnurl(amount: 999_000))) + } + await task.value + XCTAssertEqual(app.sendStateGeneration, generation) + XCTAssertEqual(app.lnurlPayData?.minSendable, replace ? 222_222 : nil) + 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: { @@ -238,7 +297,8 @@ final class ScannerManagerTests: XCTestCase { hasUsableChannels: false ) }, - canSendLightning: { _ in false } + canSendLightning: { _ in false }, + decodeInvoice: decodeInvoice ) ) scanner.configure( From 7796bd2b8a4918c954b68ba91436e4c0df64c75d Mon Sep 17 00:00:00 2001 From: Piotr Stachyra Date: Thu, 22 Oct 2026 14:10:18 +0200 Subject: [PATCH 22/25] fix: preserve scan ownership through payment resolution --- Bitkit/Managers/ScannerManager.swift | 42 ++++++++++++++++----------- Bitkit/ViewModels/AppViewModel.swift | 29 +++++++++++++----- BitkitTests/ScannerManagerTests.swift | 4 ++- 3 files changed, 50 insertions(+), 25 deletions(-) diff --git a/Bitkit/Managers/ScannerManager.swift b/Bitkit/Managers/ScannerManager.swift index da624bf94..529e4bf5a 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 { @@ -213,8 +217,10 @@ class ScannerManager: ObservableObject { uri, scope: scope, alternativeOnchainBalanceSats: hwWalletManager?.maximumFundingBalanceSats ?? 0, - sendPresentationID: sheets?.activeSheetConfiguration?.presentationID + sendPresentationID: sheets?.activeSheetConfiguration?.presentationID, + isCurrentScan: isCurrent ) + guard isCurrent() else { return } guard shouldOpenPaymentFlow(for: uri) else { completion(nil) return @@ -228,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, @@ -241,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 { @@ -248,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 { @@ -336,9 +344,9 @@ class ScannerManager: ObservableObject { let payload = try await decode() guard isCurrentOrigin() 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 { guard isCurrentOrigin(), !(error is CancellationError) else { return } diff --git a/Bitkit/ViewModels/AppViewModel.swift b/Bitkit/ViewModels/AppViewModel.swift index 1b210cd38..abbf7bef3 100644 --- a/Bitkit/ViewModels/AppViewModel.swift +++ b/Bitkit/ViewModels/AppViewModel.swift @@ -749,8 +749,10 @@ extension AppViewModel { claimedContactPaymentContext: ContactPaymentContext? = nil, scope: ScanHandlingScope = .unrestricted, alternativeOnchainBalanceSats: UInt64 = 0, - sendPresentationID: UUID? = nil + sendPresentationID: UUID? = nil, + isCurrentScan: () -> Bool = { true } ) async throws { + guard isCurrentScan(), !Task.isCancelled else { throw CancellationError() } let handlingId = claimedContactPaymentContext?.id ?? UUID() if let claimedContactPaymentContext { guard ownsContactPaymentContext(claimedContactPaymentContext), scannedDataHandlingId == nil else { @@ -769,7 +771,7 @@ extension AppViewModel { resetSendState(preservingContactPaymentContext: true, ownerID: sendPresentationID) for endpoint in context.endpoints where endpoint.methodId != .usdtArbitrum { let data = try await scanPaymentOperations.decodeInvoice(endpoint.paymentRequest) - try ensureScannedDataHandlingOwnership(handlingId, claimedContactPaymentContext: context) + try ensureScannedDataHandlingOwnership(handlingId, claimedContactPaymentContext: context, isCurrentScan: isCurrentScan) switch data { case let .onChain(invoice): scannedOnchainInvoice = invoice case let .lightning(invoice): scannedLightningInvoice = invoice @@ -831,7 +833,11 @@ extension AppViewModel { return } let data = try await scanPaymentOperations.decodeInvoice(uri) - try ensureScannedDataHandlingOwnership(handlingId, claimedContactPaymentContext: claimedContactPaymentContext) + try ensureScannedDataHandlingOwnership( + handlingId, + claimedContactPaymentContext: claimedContactPaymentContext, + isCurrentScan: isCurrentScan + ) guard ShopPaymentRequest.isSupported(data) else { throw ScanHandlingError.unsupportedRequest } prevalidatedPaymentRequest = data } else { @@ -884,7 +890,11 @@ extension AppViewModel { data = prevalidatedPaymentRequest } else { data = try await scanPaymentOperations.decodeInvoice(uri) - try ensureScannedDataHandlingOwnership(handlingId, claimedContactPaymentContext: claimedContactPaymentContext) + try ensureScannedDataHandlingOwnership( + handlingId, + claimedContactPaymentContext: claimedContactPaymentContext, + isCurrentScan: isCurrentScan + ) } let paymentState = scanPaymentOperations.state() @@ -914,7 +924,11 @@ 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 scanPaymentOperations.decodeInvoice(lnInvoice) - try ensureScannedDataHandlingOwnership(handlingId, claimedContactPaymentContext: claimedContactPaymentContext) + try ensureScannedDataHandlingOwnership( + handlingId, + claimedContactPaymentContext: claimedContactPaymentContext, + isCurrentScan: isCurrentScan + ) if case let .lightning(lightningInvoice) = lightningData { // Check lightning invoice network let lnNetwork = NetworkValidationHelper.convertNetworkType(lightningInvoice.networkType) @@ -1089,9 +1103,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() } } diff --git a/BitkitTests/ScannerManagerTests.swift b/BitkitTests/ScannerManagerTests.swift index 7f5a6267d..ed449bb8e 100644 --- a/BitkitTests/ScannerManagerTests.swift +++ b/BitkitTests/ScannerManagerTests.swift @@ -225,6 +225,7 @@ final class ScannerManagerTests: XCTestCase { } func testImageResolutionCannotCompleteAfterOriginDismissalOrReplacement() async throws { + let oldOnchainResult = try await decode(invoice: paymentURI) snapshotAppDefaultsDomain() UserDefaults.standard.set(false, forKey: ToastWindowManager.disableAllToastsKey) let previousToast = ToastWindowManager.shared.currentToast @@ -262,11 +263,12 @@ final class ScannerManagerTests: XCTestCase { if failResolution { try XCTUnwrap(continuation).resume(throwing: QRCodeImageDecoderError.invalidImage) } else { - try XCTUnwrap(continuation).resume(returning: .lnurlPay(data: imageTestLnurl(amount: 999_000))) + 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) } From c6bf7dad67035511c08a05cf80c6b3cca44e7935 Mon Sep 17 00:00:00 2001 From: Piotr Stachyra Date: Thu, 22 Oct 2026 15:34:15 +0200 Subject: [PATCH 23/25] test: stabilize send lifecycle navigation transitions --- BitkitTests/SendConfirmationLifecycleTests.swift | 4 ++++ 1 file changed, 4 insertions(+) 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 } From 0b756709939108e1971e8146200f81d9d74e957e Mon Sep 17 00:00:00 2001 From: Piotr Stachyra Date: Thu, 22 Oct 2026 19:45:12 +0200 Subject: [PATCH 24/25] fix: ignore photo imports superseded within send --- Bitkit/Managers/ScannerManager.swift | 6 ++- BitkitTests/ScannerManagerTests.swift | 53 +++++++++++++++++++ journeys/lnurl/send-scanner-pay-lifecycle.xml | 8 +-- 3 files changed, 62 insertions(+), 5 deletions(-) diff --git a/Bitkit/Managers/ScannerManager.swift b/Bitkit/Managers/ScannerManager.swift index 529e4bf5a..b8444d1b2 100644 --- a/Bitkit/Managers/ScannerManager.swift +++ b/Bitkit/Managers/ScannerManager.swift @@ -335,6 +335,7 @@ class ScannerManager: ObservableObject { 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 @@ -342,14 +343,15 @@ class ScannerManager: ObservableObject { do { let payload = try await decode() - guard isCurrentOrigin() else { return } + guard isCurrentOrigin(), context != .send || app.sendStateGeneration == inputGeneration else { return } if context == .send { await handleSendScan(payload, scope: scope, isCurrent: isCurrentOrigin, completion: completion) } else { await handleScan(payload, context: context, isCurrent: isCurrentOrigin) } } catch { - guard isCurrentOrigin(), !(error is CancellationError) else { return } + guard isCurrentOrigin(), context != .send || app.sendStateGeneration == inputGeneration, + !(error is CancellationError) else { return } switch error { case QRCodeImageDecoderError.invalidImage: app.toast( diff --git a/BitkitTests/ScannerManagerTests.swift b/BitkitTests/ScannerManagerTests.swift index ed449bb8e..94fa92dcc 100644 --- a/BitkitTests/ScannerManagerTests.swift +++ b/BitkitTests/ScannerManagerTests.swift @@ -143,6 +143,59 @@ final class ScannerManagerTests: XCTestCase { 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") } diff --git a/journeys/lnurl/send-scanner-pay-lifecycle.xml b/journeys/lnurl/send-scanner-pay-lifecycle.xml index e4f27443f..baf83b3cd 100644 --- a/journeys/lnurl/send-scanner-pay-lifecycle.xml +++ b/journeys/lnurl/send-scanner-pay-lifecycle.xml @@ -2,19 +2,21 @@ 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 the scanner adds payment routes. + 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 - Use the in-Send scanner E2E prompt (id "ScanPrompt"), enter the variable LNURL in "QRInput" and tap "DialogConfirm" + 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 - Use its in-Send scan prompt with the fixed 500500-msat fixture; verify "ReviewAmount-primary" shows 501, "CommentInput" is absent and no amount-entry step is required + 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 From e888414af5e1a75a8dfc0be21a4f401290356d53 Mon Sep 17 00:00:00 2001 From: Piotr Stachyra Date: Thu, 22 Oct 2026 21:39:20 +0200 Subject: [PATCH 25/25] fix: retain quickpay attempts and cancel superseded scans --- Bitkit/MainNavView.swift | 8 +- .../QuickPayPaymentCoordinator.swift | 46 ++- Bitkit/ViewModels/AppViewModel.swift | 14 +- Bitkit/Views/Wallets/Send/SendQuickpay.swift | 12 +- Bitkit/Views/Wallets/Send/SendSheet.swift | 9 +- .../QuickPayPaymentCoordinatorTests.swift | 67 ++++ .../SendQuickpayLnurlRemountTests.swift | 343 ++++++++++++++++++ 7 files changed, 477 insertions(+), 22 deletions(-) create mode 100644 BitkitTests/SendQuickpayLnurlRemountTests.swift 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/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 abbf7bef3..b273656da 100644 --- a/Bitkit/ViewModels/AppViewModel.swift +++ b/Bitkit/ViewModels/AppViewModel.swift @@ -753,6 +753,10 @@ extension AppViewModel { 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 { @@ -769,9 +773,10 @@ extension AppViewModel { if let context = claimedContactPaymentContext, context.requiresAssetSelection, scope != .onchainPayments { resetSendState(preservingContactPaymentContext: true, ownerID: sendPresentationID) + expectedGeneration = sendStateGeneration for endpoint in context.endpoints where endpoint.methodId != .usdtArbitrum { let data = try await scanPaymentOperations.decodeInvoice(endpoint.paymentRequest) - try ensureScannedDataHandlingOwnership(handlingId, claimedContactPaymentContext: context, isCurrentScan: isCurrentScan) + try ensureScannedDataHandlingOwnership(handlingId, claimedContactPaymentContext: context, isCurrentScan: isCurrentScanGeneration) switch data { case let .onChain(invoice): scannedOnchainInvoice = invoice case let .lightning(invoice): scannedLightningInvoice = invoice @@ -836,7 +841,7 @@ extension AppViewModel { try ensureScannedDataHandlingOwnership( handlingId, claimedContactPaymentContext: claimedContactPaymentContext, - isCurrentScan: isCurrentScan + isCurrentScan: isCurrentScanGeneration ) guard ShopPaymentRequest.isSupported(data) else { throw ScanHandlingError.unsupportedRequest } prevalidatedPaymentRequest = data @@ -846,6 +851,7 @@ extension AppViewModel { // Reset send state before handling new data resetSendState(preservingContactPaymentContext: claimedContactPaymentContext != nil, ownerID: sendPresentationID) + expectedGeneration = sendStateGeneration if scope == .unrestricted, let samRockSetup = SamRockSetupRequest.parse(uri) { handleBTCPayConnection(samRockSetup) @@ -893,7 +899,7 @@ extension AppViewModel { try ensureScannedDataHandlingOwnership( handlingId, claimedContactPaymentContext: claimedContactPaymentContext, - isCurrentScan: isCurrentScan + isCurrentScan: isCurrentScanGeneration ) } let paymentState = scanPaymentOperations.state() @@ -927,7 +933,7 @@ extension AppViewModel { try ensureScannedDataHandlingOwnership( handlingId, claimedContactPaymentContext: claimedContactPaymentContext, - isCurrentScan: isCurrentScan + isCurrentScan: isCurrentScanGeneration ) if case let .lightning(lightningInvoice) = lightningData { // Check lightning invoice network 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 22d100c58..873227c3a 100644 --- a/Bitkit/Views/Wallets/Send/SendSheet.swift +++ b/Bitkit/Views/Wallets/Send/SendSheet.swift @@ -87,6 +87,7 @@ final class SendPresentationState { var navigationPath: [SendRoute] = [] var rootOverride: SendRoute? var quickPaySession = 0 + let quickPayPresentationID = UUID() var hasValidatedAfterSync = false var incomingPaymentRequest: PaykitPaymentRequest? var isSubmittingPayment = false @@ -1020,7 +1021,13 @@ struct SendSheet: View { SendQuickpay( navigationPath: $flow.navigationPath, routingCacheResetAttempted: flow.routingCacheResetAttempted, - replaceQuickPay: replaceQuickPay(with:) + replaceQuickPay: replaceQuickPay(with:), + sessionID: QuickPayPaymentCoordinator.SessionID( + presentationID: flow.quickPayPresentationID, + sendStateGeneration: app.sendStateGeneration, + attempt: flow.quickPaySession + ), + isCurrentPresentation: { isCurrentPresentation } ) .id(flow.quickPaySession) case .pin: 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/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() }) + } + } +}