diff --git a/Bitkit/AppScene.swift b/Bitkit/AppScene.swift index e211e83a0..e14a45a2b 100644 --- a/Bitkit/AppScene.swift +++ b/Bitkit/AppScene.swift @@ -120,6 +120,7 @@ final class IncomingPaykitPaymentRequestPreparation { private(set) var resolvedRoute: SendRoute? private let session: PubkyProfileManager.SignedInSession? var paymentContext: ContactPaymentContext? + var isSuspendedForAppLock = false init(request: PaykitPaymentRequest, session: PubkyProfileManager.SignedInSession?) { self.request = request @@ -147,6 +148,15 @@ final class IncomingPaykitPaymentRequestPreparation { func clear() { request = nil resolvedRoute = nil + isSuspendedForAppLock = false + } + + func dismiss(app: AppViewModel, wallet: WalletViewModel, speed: TransactionSpeed) { + if isSuspendedForAppLock, let paymentContext, app.ownsContactPaymentContext(paymentContext) { + app.resetSendState() + wallet.resetSendState(speed: speed) + } + clear() } func ownsSheet(_ sheets: SheetViewModel) -> Bool { @@ -154,6 +164,37 @@ final class IncomingPaykitPaymentRequestPreparation { (sheets.activeSheetConfiguration?.data as? SendConfig)?.preparation === self && !sheets.isReplacingSheet } + func ownsResolvedContext(session: PubkyProfileManager.SignedInSession?, app: AppViewModel, sheets: SheetViewModel) -> Bool { + guard resolvedRoute != nil, let request, let paymentContext else { return false } + return matchesSession(session) && ownsSheet(sheets) && + app.contactPaymentContext == paymentContext && + paymentContext.incomingPaymentRequest?.id == request.id + } + + func suspendForAppLock(sheets: SheetViewModel) { + guard resolvedRoute != nil, ownsSheet(sheets) else { return } + isSuspendedForAppLock = true + } + + func retainReviewOnAppLock( + root: SendRoute, + path: [SendRoute], + isSubmittingPayment: Bool, + isHardwarePayment: Bool, + session: PubkyProfileManager.SignedInSession?, + app: AppViewModel, + sheets: SheetViewModel + ) -> Bool { + let route = path.last ?? root + let reviewRoute = route == .pin ? path.dropLast().last ?? root : route + guard isSuspendedForAppLock, reviewRoute == .confirm, + !isSubmittingPayment, !isHardwarePayment, !app.paykitUsesUsdt, + ownsResolvedContext(session: session, app: app, sheets: sheets) + else { return false } + resolvedRoute = .confirm + return true + } + func complete( route: SendRoute, manager: PaykitPaymentRequestManager, @@ -1284,6 +1325,7 @@ struct AppScene: View { if newPhase == .background { if settings.pinEnabled { // If PIN is enabled, lock the app when the app goes to the background + incomingPaymentRequestPreparation?.suspendForAppLock(sheets: sheets) isPinVerified = false } hwWalletManager.onAppBackgrounded() @@ -1640,7 +1682,7 @@ struct AppScene: View { { paykitPaymentRequestManager.dismissPreparingRequest(request) } - preparation.clear() + preparation.dismiss(app: app, wallet: wallet, speed: settings.defaultTransactionSpeed) } )) } @@ -1835,7 +1877,9 @@ struct AppScene: View { private var isIncomingPaymentRequestPreparationInvalid: Bool { guard let preparation = incomingPaymentRequestPreparation else { return false } if preparation.resolvedRoute != nil { - return !preparation.ownsSheet(sheets) + return !preparation.ownsSheet(sheets) || + (preparation.isSuspendedForAppLock && + !preparation.ownsResolvedContext(session: pubkyProfile.currentSession, app: app, sheets: sheets)) } return !isCurrentIncomingPaymentRequestPreparation(preparation) } diff --git a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift index 799e2305b..564d721c6 100644 --- a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift +++ b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift @@ -382,7 +382,8 @@ struct SendConfirmationView: View { hasStartedAutomaticPayment: hasStartedAutomaticPayment, isFeeRateMissing: isFeeRateMissing, feeRateLoadFailed: wallet.feeRateLoadFailed, - isPreparingRequest: preparingRequest != nil + isPreparingRequest: preparingRequest != nil, + isSubmittingPayment: isSubmittingPayment ), isSubmitted: hasStartedAutomaticPayment, swipeProgress: $swipeProgress @@ -806,6 +807,7 @@ struct SendConfirmationView: View { } private func submitPayment(isAutomatic: Bool = false) async throws { + guard !isSubmittingPayment else { return } guard preparingRequest == nil, walletSwitchContext == nil else { throw CancellationError() } if isAutomatic, isFeeRateMissing { guard !Self.automaticPaymentAwaitsFeeRate(isFeeRateMissing: isFeeRateMissing, feeRateLoadFailed: wallet.feeRateLoadFailed) else { @@ -1012,9 +1014,10 @@ struct SendConfirmationView: View { hasStartedAutomaticPayment: Bool, isFeeRateMissing: Bool, feeRateLoadFailed: Bool, - isPreparingRequest: Bool = false + isPreparingRequest: Bool = false, + isSubmittingPayment: Bool = false ) -> Bool { - isPreparingRequest || hasStartedAutomaticPayment || (isFeeRateMissing && !feeRateLoadFailed) + isSubmittingPayment || isPreparingRequest || hasStartedAutomaticPayment || (isFeeRateMissing && !feeRateLoadFailed) } private func openManualCoinSelection() async throws { diff --git a/Bitkit/Views/Wallets/Send/SendSheet.swift b/Bitkit/Views/Wallets/Send/SendSheet.swift index c75dd95b8..dfe6a4608 100644 --- a/Bitkit/Views/Wallets/Send/SendSheet.swift +++ b/Bitkit/Views/Wallets/Send/SendSheet.swift @@ -211,7 +211,8 @@ struct SendSheet: View { } private var isReviewingPayment: Bool { - guard isCurrentPresentation, !flow.didFinish, scenePhase != .background else { return false } + guard isCurrentPresentation, !flow.didFinish, scenePhase != .background, + config.preparation?.isSuspendedForAppLock != true else { return false } switch flow.navigationPath.last ?? currentRoot { case .confirm, .pin, .hardwareSign: return true case .usdt, .usdtAddress: return flow.isReviewingUsdtPayment @@ -274,7 +275,7 @@ struct SendSheet: View { /// For onchain: only need node running. private var shouldShowSyncOverlay: Bool { if isPreparingRequest || hwSend.isActive || flow.isResumingAcceptedOrdinarySend || app.paykitUsesUsdt || - (app.contactPaymentContext?.requiresAssetSelection == true && flow.navigationPath.isEmpty) + (app.contactPaymentContext?.requiresAssetSelection == true && currentRoot == .amount && flow.navigationPath.isEmpty) { return false } @@ -368,11 +369,10 @@ struct SendSheet: View { } .task(id: config.preparation?.resolvedRoute) { if let preparation = config.preparation { - guard let route = preparation.resolvedRoute, preparation.ownsSheet(sheets), - let request = preparation.request, - app.contactPaymentContext == preparation.paymentContext, - app.contactPaymentContext?.incomingPaymentRequest?.id == request.id + guard let route = preparation.resolvedRoute, + preparation.ownsResolvedContext(session: pubkyProfile.currentSession, app: app, sheets: sheets) else { return } + preparation.isSuspendedForAppLock = false flow.rootRoute = route } setUpSend() @@ -448,12 +448,34 @@ struct SendSheet: View { private func cleanup() { // Content recreation keeps the presentation, its setup and its payment operation alive. - guard isEmbedded || !isCurrentPresentation, !flow.didFinish else { return } + guard isEmbedded || !isCurrentPresentation || config.preparation?.isSuspendedForAppLock == true, + !flow.didFinish else { return } updatePaymentReview(false) - flow.didFinish = true - flow.onDismiss = nil + let retainsReview = config.preparation?.retainReviewOnAppLock( + root: currentRoot, + path: flow.navigationPath, + isSubmittingPayment: flow.isSubmittingPayment || PaykitPaymentActivity.shared.isActive, + isHardwarePayment: hwSend.isActive, + session: pubkyProfile.currentSession, + app: app, + sheets: sheets + ) == true + if !isEmbedded, isCurrentPresentation, !retainsReview { + return + } flow.setupTask?.cancel() flow.setupTask = nil + if retainsReview { + flow.didStartSetup = false + flow.isSetupPending = true + flow.rootOverride = nil + flow.navigationPath = [] + resolvePinCheck(false) + return + } + flow.didFinish = true + flow.onDismiss = nil + config.preparation?.isSuspendedForAppLock = false hwSend.cancel() if isPreparingRequest { return diff --git a/BitkitTests/PaykitPaymentRequestServiceTests.swift b/BitkitTests/PaykitPaymentRequestServiceTests.swift index 9ba7582ee..3450bd12f 100644 --- a/BitkitTests/PaykitPaymentRequestServiceTests.swift +++ b/BitkitTests/PaykitPaymentRequestServiceTests.swift @@ -7095,6 +7095,335 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { XCTAssertEqual(try store.load(identity: "pubky\(String(repeating: "z", count: 52))"), [request.id]) } + func testAppLockRemountsReadyRequestAndDismissalClearsItInEitherOrder() async throws { + for (entryRoute, reviewPath, dismissBeforeDisappearance): (SendRoute, [SendRoute], Bool) in [ + (.confirm, [], false), (.confirm, [], true), + (.amount, [.confirm], false), (.amount, [.confirm], true), + (.amount, [.confirm, .pin], false), (.amount, [.confirm, .pin], true), + ] { + let selectsAsset = entryRoute == .amount + let record: PaymentRequestRecord = if selectsAsset { + try paymentRequestRecord( + amount: "1", asset: "usd", + conversion: .fixed(rates: [.init(asset: "btc", value: "0.00001"), .init(asset: "usdt", value: "1")]), + endpoints: [PublicPaykitService.MethodId.bitcoinLightningBolt11.rawValue, PublicPaykitService.MethodId.usdtArbitrum.rawValue] + ) + } else { + try paymentRequestRecord() + } + let sdk = PaymentRequestSdkMock(records: [record]) + let manager = paymentRequestManager(sdk: sdk) + await manager.refresh() + let request = try XCTUnwrap(manager.pendingRequests.first) + let profile = PubkyProfileManager() + profile.publicKey = "pubky\(String(repeating: "z", count: 52))" + let session = try XCTUnwrap(profile.currentSession) + let preparation = IncomingPaykitPaymentRequestPreparation(request: request, session: session) + let sheets = SheetViewModel() + let app = AppViewModel(sheetViewModel: sheets, navigationViewModel: NavigationViewModel()) + let wallet = PaymentRequestSendWallet() + let context = ContactPaymentContext(publicKey: request.counterparty, incomingPaymentRequest: request) + XCTAssertEqual(context.requiresAssetSelection, selectsAsset) + let terms = try request.payment(using: .bitcoinLightningBolt11) + XCTAssertTrue(app.claimContactPaymentContext(context)) + app.selectedWalletToPayFrom = .lightning + app.paykitPaymentTerms = terms + app.paykitReviewedAmount = terms.amount + app.scannedLightningInvoice = LightningInvoice( + bolt11: "test-invoice", paymentHash: String(repeating: "00", count: 32).hexaData, + amountSatoshis: terms.amount.atomic, timestampSeconds: 0, expirySeconds: 0, isExpired: false, + description: nil, networkType: .regtest, payeeNodeId: nil + ) + preparation.paymentContext = context + sheets.showSheet(.send, data: SendConfig(view: .confirm, preparation: preparation, onDismiss: { + preparation.dismiss(app: app, wallet: wallet, speed: .normal) + })) + await manager.presentRequests { _ in + XCTAssertTrue(preparation.complete(route: entryRoute, manager: manager, session: session, app: app, sheets: sheets)) + } + XCTAssertEqual(preparation.resolvedRoute, entryRoute) + preparation.suspendForAppLock(sheets: sheets) + XCTAssertTrue(preparation.isSuspendedForAppLock) + XCTAssertTrue(preparation.retainReviewOnAppLock( + root: entryRoute, path: reviewPath, isSubmittingPayment: false, isHardwarePayment: false, + session: session, app: app, sheets: sheets + )) + XCTAssertEqual(preparation.resolvedRoute, .confirm) + let presentationID = sheets.activeSheetConfiguration?.presentationID + let item = try XCTUnwrap(sheets.sendSheetItem) + let view = SendSheet(config: item) + .environment(\.scenePhase, .active) + .environment(manager) + .environment(HwWalletManager()) + .environment(TrezorManager()) + .environmentObject(app) + .environmentObject(wallet as WalletViewModel) + .environmentObject(sheets) + .environmentObject(NetworkMonitor()) + .environmentObject(SettingsViewModel.shared) + .environmentObject(TagManager()) + .environmentObject(profile) + .environmentObject(ActivityListViewModel()) + .environmentObject(ContactsManager()) + .environmentObject(CurrencyViewModel(currencyService: OfflineCurrencyService())) + .environmentObject(FeeEstimatesManager()) + let host = UIHostingController(rootView: AnyView(view)) + let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 390, height: 844)) + window.rootViewController = host + window.makeKeyAndVisible() + host.view.layoutIfNeeded() + defer { + window.isHidden = true + window.rootViewController = nil + ToastWindowManager.shared.hideToast() + } + try await waitUntil { wallet.feeRateLoadCount == 1 && PaykitPaymentActivity.shared.shouldDeferRefresh } + XCTAssertEqual(wallet.sendAmountSats, terms.amount.atomic) + XCTAssertFalse(manager.isApprovedForPayment(request)) + + preparation.suspendForAppLock(sheets: sheets) + host.rootView = AnyView(Color.clear) + host.view.layoutIfNeeded() + try await waitUntil { !PaykitPaymentActivity.shared.shouldDeferRefresh } + XCTAssertEqual(app.contactPaymentContext, context) + XCTAssertEqual(sheets.activeSheetConfiguration?.presentationID, presentationID) + XCTAssertEqual(preparation.resolvedRoute, .confirm) + + host.rootView = AnyView(view) + host.view.layoutIfNeeded() + try await waitUntil { wallet.feeRateLoadCount == 2 && PaykitPaymentActivity.shared.shouldDeferRefresh } + XCTAssertFalse(preparation.isSuspendedForAppLock) + XCTAssertEqual(wallet.sendAmountSats, terms.amount.atomic) + XCTAssertEqual(app.contactPaymentContext, context) + XCTAssertEqual(app.scannedLightningInvoice?.bolt11, "test-invoice") + XCTAssertEqual(app.selectedWalletToPayFrom, .lightning) + XCTAssertEqual(app.paykitPaymentTerms, terms) + XCTAssertEqual(app.paykitReviewedAmount, terms.amount) + XCTAssertEqual(wallet.selectedFeeRateSatsPerVByte, 2) + XCTAssertFalse(manager.isApprovedForPayment(request)) + let snapshot = await sdk.snapshot() + XCTAssertTrue(snapshot.acceptedRequests.isEmpty) + + preparation.suspendForAppLock(sheets: sheets) + if dismissBeforeDisappearance { + sheets.hideSheet() + } + host.rootView = AnyView(Color.clear) + host.view.layoutIfNeeded() + try await waitUntil { !PaykitPaymentActivity.shared.shouldDeferRefresh } + if !dismissBeforeDisappearance { + XCTAssertEqual(app.contactPaymentContext, context) + sheets.hideSheet() + } + XCTAssertNil(app.contactPaymentContext) + XCTAssertNil(wallet.sendAmountSats) + XCTAssertNil(preparation.request) + XCTAssertNil(sheets.activeSheetConfiguration) + XCTAssertEqual(manager.pendingRequests, [request]) + XCTAssertTrue(manager.requestsForPresentation().isEmpty) + XCTAssertFalse(manager.isApprovedForPayment(request)) + XCTAssertTrue(manager.requestPresentation(request)) + XCTAssertEqual(manager.requestsForPresentation(), [request]) + } + } + + func testAppLockKeepsOtherRequestScreensUntilDismissal() async throws { + for (entryRoute, usesUsdt): (SendRoute, Bool) in [(.amount, false), (.confirm, true)] { + let record: PaymentRequestRecord = if entryRoute == .amount { + try paymentRequestRecord( + amount: "1", asset: "usd", + conversion: .fixed(rates: [.init(asset: "btc", value: "0.00001"), .init(asset: "usdt", value: "1")]), + endpoints: [PublicPaykitService.MethodId.bitcoinLightningBolt11.rawValue, PublicPaykitService.MethodId.usdtArbitrum.rawValue] + ) + } else { + try paymentRequestRecord() + } + let sdk = PaymentRequestSdkMock(records: [record]) + let manager = paymentRequestManager(sdk: sdk) + await manager.refresh() + let request = try XCTUnwrap(manager.pendingRequests.first) + let profile = PubkyProfileManager() + profile.publicKey = "pubky\(String(repeating: "z", count: 52))" + let session = try XCTUnwrap(profile.currentSession) + let preparation = IncomingPaykitPaymentRequestPreparation(request: request, session: session) + let sheets = SheetViewModel() + let app = AppViewModel(sheetViewModel: sheets, navigationViewModel: NavigationViewModel()) + let wallet = PaymentRequestSendWallet() + let context = ContactPaymentContext(publicKey: request.counterparty, incomingPaymentRequest: request) + let terms = try request.payment(using: .bitcoinLightningBolt11) + XCTAssertTrue(app.claimContactPaymentContext(context)) + app.selectedWalletToPayFrom = .lightning + app.paykitPaymentTerms = terms + app.paykitReviewedAmount = terms.amount + app.paykitUsesUsdt = usesUsdt + app.scannedLightningInvoice = LightningInvoice( + bolt11: "test-invoice", paymentHash: String(repeating: "00", count: 32).hexaData, + amountSatoshis: terms.amount.atomic, timestampSeconds: 0, expirySeconds: 0, isExpired: false, + description: nil, networkType: .regtest, payeeNodeId: nil + ) + preparation.paymentContext = context + sheets.showSheet(.send, data: SendConfig(view: .confirm, preparation: preparation, onDismiss: { + preparation.dismiss(app: app, wallet: wallet, speed: .normal) + })) + await manager.presentRequests { _ in + XCTAssertTrue(preparation.complete(route: entryRoute, manager: manager, session: session, app: app, sheets: sheets)) + } + let presentationID = sheets.activeSheetConfiguration?.presentationID + let item = try XCTUnwrap(sheets.sendSheetItem) + var disappeared = false + let view = SendSheet(config: item) + .environment(\.scenePhase, .active) + .environment(manager) + .environment(HwWalletManager()) + .environment(TrezorManager()) + .environment(UsdtWalletManager(loadCredentials: { throw CancellationError() })) + .environmentObject(app) + .environmentObject(wallet as WalletViewModel) + .environmentObject(sheets) + .environmentObject(NetworkMonitor()) + .environmentObject(SettingsViewModel.shared) + .environmentObject(TagManager()) + .environmentObject(profile) + .environmentObject(ActivityListViewModel()) + .environmentObject(ContactsManager()) + .environmentObject(CurrencyViewModel(currencyService: OfflineCurrencyService())) + .environmentObject(FeeEstimatesManager()) + .onDisappear { disappeared = true } + let host = UIHostingController(rootView: AnyView(view)) + let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 390, height: 844)) + window.rootViewController = host + window.makeKeyAndVisible() + host.view.layoutIfNeeded() + defer { + window.isHidden = true + window.rootViewController = nil + ToastWindowManager.shared.hideToast() + } + try await waitUntil { + wallet.feeRateLoadCount == 1 && wallet.sendAmountSats == terms.amount.atomic && !item.state.isSetupPending + } + let amount = wallet.sendAmountSats + XCTAssertEqual(amount, terms.amount.atomic) + + preparation.suspendForAppLock(sheets: sheets) + host.rootView = AnyView(Color.clear) + host.view.layoutIfNeeded() + try await waitUntil { disappeared } + XCTAssertTrue(preparation.isSuspendedForAppLock) + XCTAssertEqual(sheets.activeSheetConfiguration?.presentationID, presentationID) + XCTAssertEqual(app.contactPaymentContext, context) + XCTAssertEqual(wallet.sendAmountSats, amount) + XCTAssertEqual(app.paykitPaymentTerms, terms) + XCTAssertFalse(item.state.didFinish) + + host.rootView = AnyView(view) + host.view.layoutIfNeeded() + try await waitUntil { !preparation.isSuspendedForAppLock } + XCTAssertTrue(preparation.ownsResolvedContext(session: session, app: app, sheets: sheets)) + XCTAssertEqual(app.contactPaymentContext, context) + XCTAssertEqual(wallet.sendAmountSats, amount) + XCTAssertEqual(app.paykitPaymentTerms, terms) + XCTAssertFalse(item.state.didFinish) + XCTAssertFalse(manager.isApprovedForPayment(request)) + let snapshot = await sdk.snapshot() + XCTAssertTrue(snapshot.acceptedRequests.isEmpty) + + sheets.hideSheet() + XCTAssertNil(app.contactPaymentContext) + XCTAssertNil(wallet.sendAmountSats) + XCTAssertNil(preparation.request) + XCTAssertNil(sheets.activeSheetConfiguration) + XCTAssertEqual(manager.pendingRequests, [request]) + } + } + + func testAppLockReviewRetentionRequiresCurrentUnsubmittedBitcoinContext() async throws { + let record = try paymentRequestRecord(amount: "1", asset: "usd", conversion: .fixed(rates: [.init(asset: "btc", value: "0.00001")])) + let manager = paymentRequestManager(sdk: PaymentRequestSdkMock(records: [record])) + await manager.refresh() + let request = try XCTUnwrap(manager.pendingRequests.first) + let profile = PubkyProfileManager() + profile.publicKey = "pubky\(String(repeating: "z", count: 52))" + let session = try XCTUnwrap(profile.currentSession) + let preparation = IncomingPaykitPaymentRequestPreparation(request: request, session: session) + let sheets = SheetViewModel() + let app = AppViewModel() + let wallet = PaymentRequestSendWallet() + let context = ContactPaymentContext(publicKey: request.counterparty, incomingPaymentRequest: request) + XCTAssertTrue(app.claimContactPaymentContext(context)) + preparation.paymentContext = context + sheets.showSheet(.send, data: SendConfig(view: .confirm, preparation: preparation)) + preparation.suspendForAppLock(sheets: sheets) + XCTAssertFalse(preparation.isSuspendedForAppLock) + await manager.presentRequests { _ in + XCTAssertTrue(preparation.complete(route: .amount, manager: manager, session: session, app: app, sheets: sheets)) + } + + func retains(_ root: SendRoute = .confirm, path: [SendRoute] = [], submitting: Bool = false, hardware: Bool = false) -> Bool { + preparation.retainReviewOnAppLock( + root: root, path: path, isSubmittingPayment: submitting, isHardwarePayment: hardware, + session: profile.currentSession, app: app, sheets: sheets + ) + } + + XCTAssertFalse(retains()) + preparation.suspendForAppLock(sheets: sheets) + XCTAssertTrue(preparation.isSuspendedForAppLock) + XCTAssertEqual(preparation.resolvedRoute, .amount) + XCTAssertFalse(retains(submitting: true)) + XCTAssertFalse(retains(hardware: true)) + let pending = OnchainSendPendingContext(attemptId: UUID(), walletId: "wallet", txid: nil) + for route in [SendRoute.amount, .utxoSelection, .feeRate, .lnurlPayConfirm, .usdt(recipient: "recipient", amount: "1"), + .hardwareSign, .pending(paymentHash: nil, retryRoute: .confirm, paymentRequest: nil), + .onchainOperationPending(pending, requestId: request.id), .success(paymentId: "paid")] + { + XCTAssertFalse(retains(route)) + XCTAssertFalse(retains(route, path: [.pin])) + } + XCTAssertFalse(retains(.amount, path: [.lnurlPayConfirm, .pin])) + XCTAssertFalse(retains(.confirm, path: [.lnurlPayConfirm, .pin])) + XCTAssertFalse(retains(.pin)) + app.paykitUsesUsdt = true + XCTAssertFalse(retains()) + app.paykitUsesUsdt = false + XCTAssertEqual(preparation.resolvedRoute, .amount) + XCTAssertTrue(retains(.amount, path: [.confirm, .pin])) + XCTAssertTrue(retains(.confirm, path: [.pin])) + XCTAssertEqual(preparation.resolvedRoute, .confirm) + XCTAssertTrue(retains()) + + let presentation = sheets.activeSheetConfiguration + sheets.activeSheetConfiguration = SheetConfiguration( + id: .send, data: SendConfig(view: .confirm, preparation: IncomingPaykitPaymentRequestPreparation(request: request, session: session)) + ) + XCTAssertFalse(retains()) + sheets.activeSheetConfiguration = presentation + XCTAssertTrue(retains()) + profile.publicKey = "pubky\(String(repeating: "y", count: 52))" + XCTAssertFalse(retains()) + XCTAssertFalse(preparation.ownsResolvedContext(session: profile.currentSession, app: app, sheets: sheets)) + profile.publicKey = session.publicKey + XCTAssertTrue(retains()) + app.contactPaymentContext = nil + XCTAssertFalse(retains()) + app.contactPaymentContext = ContactPaymentContext(publicKey: request.counterparty, incomingPaymentRequest: request) + XCTAssertFalse(retains()) + var changedRequestContext = context + changedRequestContext.incomingPaymentRequest = nil + app.contactPaymentContext = changedRequestContext + XCTAssertFalse(retains()) + + let replacementContext = ContactPaymentContext(publicKey: "other") + app.contactPaymentContext = replacementContext + wallet.sendAmountSats = 42 + preparation.dismiss(app: app, wallet: wallet, speed: .normal) + XCTAssertEqual(app.contactPaymentContext, replacementContext) + XCTAssertEqual(wallet.sendAmountSats, 42) + XCTAssertNil(preparation.request) + XCTAssertFalse(preparation.isSuspendedForAppLock) + XCTAssertFalse(retains()) + } + func testManualPaymentRetainsUnrelatedReminderThroughConfirmationAndRetry() async throws { defer { PaykitSubscriptionNotificationTargetStore.clear() } let now = try XCTUnwrap(ISO8601DateFormatter().date(from: "2027-01-15T08:00:00Z")) @@ -10643,7 +10972,10 @@ private actor HardwarePaymentProofMemoryStore: PaykitPaymentProofStoring { @MainActor private final class PaymentRequestSendWallet: WalletViewModel { + private(set) var feeRateLoadCount = 0 + override func setFeeRate(speed: TransactionSpeed, isCurrentSend: () -> Bool = { true }) async throws { + feeRateLoadCount += 1 selectedFeeRateSatsPerVByte = 2 } } diff --git a/BitkitTests/SendConfirmationLifecycleTests.swift b/BitkitTests/SendConfirmationLifecycleTests.swift index dc513c0a3..b2df31fa6 100644 --- a/BitkitTests/SendConfirmationLifecycleTests.swift +++ b/BitkitTests/SendConfirmationLifecycleTests.swift @@ -460,19 +460,66 @@ final class SendConfirmationLifecycleTests: XCTestCase { XCTAssertEqual(setup.pinChecks, [false], "The payment starts once and is not cancelled while it awaits the PIN") } + func testRecreatedConfirmationDoesNotAuthorizeWhilePaymentIsSubmitting() async throws { + snapshotAppDefaultsDomain() + snapshotAppGroupDefaults("home_screen_display_currency_code_v1", "home_screen_display_currency_symbol_v1") + let settings = SettingsViewModel.shared + let pinEnabled = settings.pinEnabled + addTeardownBlock { @MainActor in settings.pinEnabled = pinEnabled } + settings.pinEnabled = true + settings.requirePinForPayments = true + settings.useBiometrics = false + settings.coinSelectionMethod = .autopilot + let app = AppViewModel() + let wallet = WalletViewModel() + let sheets = SheetViewModel() + sheets.activeSheetConfiguration = SheetConfiguration(id: .send, data: nil) + app.contactPaymentContext = ContactPaymentContext(publicKey: "pubkytest", isInitialSubscriptionPayment: true) + app.scannedLightningInvoice = LightningInvoice( + bolt11: "test-invoice", paymentHash: Data(), amountSatoshis: 6007, + timestampSeconds: 0, expirySeconds: 0, isExpired: false, + description: nil, networkType: .regtest, payeeNodeId: nil + ) + app.selectedWalletToPayFrom = .lightning + wallet.sendAmountSats = 6007 + wallet.totalLightningSats = 1_000_000 + let setup = SendConfirmationTestSetup() + setup.isPending = false + var isSubmittingPayment = true + var path: [SendRoute] = [] + + for _ in 0 ..< 2 { + let window = host( + app: app, + wallet: wallet, + sheets: sheets, + path: Binding(get: { path }, set: { path = $0 }), + setup: setup, + isSubmittingPayment: Binding(get: { isSubmittingPayment }, set: { isSubmittingPayment = $0 }) + ) + try await Task.sleep(for: .milliseconds(500)) + close(window) + } + + XCTAssertTrue(setup.pinChecks.isEmpty, "Restoring an in-flight payment must not request authorization again") + XCTAssertTrue(path.isEmpty, "Ignoring the duplicate entry must preserve the original payment's navigation") + XCTAssertTrue(isSubmittingPayment, "The original payment owns the retained submission flag") + } + private func host( app: AppViewModel, wallet: WalletViewModel, sheets: SheetViewModel, path: Binding<[SendRoute]>, navigation: SendConfirmationTestNavigation? = nil, - setup: SendConfirmationTestSetup? = nil + setup: SendConfirmationTestSetup? = nil, + isSubmittingPayment: Binding = .constant(false) ) -> UIWindow { let hwSend = HwSendCoordinator() let makeConfirmation = { (isSetupPending: Bool) in SendConfirmationView( navigationPath: path, - isSubmittingPayment: .constant(false), + isSubmittingPayment: isSubmittingPayment, hwSend: hwSend, requestPinCheck: { guard let setup else { diff --git a/BitkitTests/SendConfirmationSwipeTests.swift b/BitkitTests/SendConfirmationSwipeTests.swift index 47985501b..b0e71074f 100644 --- a/BitkitTests/SendConfirmationSwipeTests.swift +++ b/BitkitTests/SendConfirmationSwipeTests.swift @@ -150,6 +150,21 @@ final class SendConfirmationSwipeTests: XCTestCase { )) } + func testManualPaymentKeepsTheSwipeBusyWhenConfirmationIsRecreated() { + XCTAssertTrue(SendConfirmationView.isSwipeLoading( + hasStartedAutomaticPayment: false, + isFeeRateMissing: false, + feeRateLoadFailed: false, + isSubmittingPayment: true + )) + XCTAssertFalse(SendConfirmationView.isSwipeLoading( + hasStartedAutomaticPayment: false, + isFeeRateMissing: false, + feeRateLoadFailed: false, + isSubmittingPayment: false + )) + } + func testSwipeStopsItsSpinnerOnceTheFeeRateLoadFailed() { XCTAssertFalse(SendConfirmationView.isSwipeLoading( hasStartedAutomaticPayment: false, diff --git a/changelog.d/next/1039.fixed.md b/changelog.d/next/1039.fixed.md new file mode 100644 index 000000000..ac1d67e07 --- /dev/null +++ b/changelog.d/next/1039.fixed.md @@ -0,0 +1 @@ +Fixed payment requests losing their amount or getting stuck on loading after unlocking the app, and kept in-flight payments busy after unlock. diff --git a/journeys/payment-requests/README.md b/journeys/payment-requests/README.md index def29ce75..33bebb780 100644 --- a/journeys/payment-requests/README.md +++ b/journeys/payment-requests/README.md @@ -130,6 +130,16 @@ correctly prevents it from entering the presentation queue. ## Request summary +`review-refresh.xml` checks that an unpaid Bitcoin review resumes after app unlock without +dismissing and reopening the sheet. iOS recreates the send view on app lock; Android keeps +its content beneath the lock overlay. The iOS journey therefore explicitly checks the +restored fee, ready swipe control and Show Details before authorizing. Its additional iOS +app-lock steps start from amount and method selection for a fixed USD request, select Bitcoin, +and lock both confirmation and payment PIN entry. Unlock must return to unpaid Bitcoin +confirmation with the reviewed conversion amount and require fresh payment authorization. +These steps need a conversion-capable linked issuer with current local endpoints; mark them +blocked when unavailable. They do not make a USDT payment. + `request-summary.xml` is ported alongside Android's matching journey. The USDT history step in `request-summary.xml` requires both instances opted in, a payer funded with local test USDT, and two completed requests (one USD-denominated and one BTC-denominated), each paid diff --git a/journeys/payment-requests/review-refresh.xml b/journeys/payment-requests/review-refresh.xml index b1333e95e..194ca9e1b 100644 --- a/journeys/payment-requests/review-refresh.xml +++ b/journeys/payment-requests/review-refresh.xml @@ -1,6 +1,6 @@ - Verifies that a Bitcoin Payment Request remains usable while reviewing and authorizing it, and that closing or backgrounding review does not prevent later requests from arriving. Requires two linked disposable regtest wallets, an existing PIN with payment-PIN enabled and biometric authorization disabled, and at least 10,000 sats in Savings. Use only authorized regtest payments. Queue-admission and expiry fault checks are covered separately by repository tests; this journey verifies the visible flow without changing payment terms. + Verifies that a Bitcoin Payment Request remains usable while reviewing and authorizing it, and that closing or backgrounding review does not prevent later requests from arriving. Requires two linked disposable regtest wallets, an existing PIN with payment-PIN enabled and biometric authorization disabled, and at least 10,000 sats in Savings. Use only authorized regtest payments. On iOS, app lock removes the send view; unlocking must restore the unpaid review without dismissing and reopening it. The final iOS app-lock checks also require a linked issuer capable of fixed conversion terms and current local USDT and Bitcoin endpoints; no USDT funding or payment is required. Report those checks as blocked if that issuer is unavailable. Queue-admission and expiry fault checks are covered separately by repository tests; this journey verifies the visible flow without changing payment terms. On the requester, send the payer an on-chain-only Bitcoin Payment Request for 1,000 sats @@ -14,11 +14,22 @@ On the payer, verify the second request becomes visible without restarting the app Close the second request without paying, then reopen the first request from the pending-request list Verify the confirmation still shows the original 1,000 sats - Background the app, reopen it, and verify the original request remains available for review - Open the original request if necessary, verify 1,000 sats and the fee, then swipe to pay and complete PIN authorization + Background the app, reopen it, and complete app-unlock PIN entry + Without dismissing or reopening the send sheet, verify the original confirmation shows 1,000 sats and the fee, the swipe control with id "GRAB" is ready, and Show Details with id "SendConfirmToggleDetails" opens the details + Close details, swipe to pay, and complete PIN authorization Verify the success screen (id "SendSuccess") is visible Close success and verify Activity contains exactly one outgoing payment for the original request On the requester, verify the original request records the completed Bitcoin payment without another payment from the payer Verify the second request remains unpaid + For the iOS app-lock check, have the linked issuer send a new one-time request for amount "1", asset "usd", accepted identifiers "btc-regtest-p2wpkh" and "usdt-arbitrum-address", and fixed conversion rates "btc" equal to "0.00001" and "usdt" equal to "1" + Wait for the Amount screen (id "SendAmount"), select Savings through the asset selector if needed, and verify the Bitcoin amount is 1,000 sats + Tap Continue (id "ContinueAmount"), verify Bitcoin confirmation (id "PaymentRequestConfirm") shows 1,000 sats, and wait for fee preparation to finish + Background the app, reopen it, and complete app-unlock PIN entry; verify the same Bitcoin confirmation returns with 1,000 sats, a prepared fee, and the swipe control ready without reopening or selecting the asset again + Swipe to pay, complete any sending-warning confirmation, and stop on payment PIN entry without authorizing; background the app, reopen it, and complete only app-unlock PIN entry + Verify the unpaid Bitcoin confirmation returns with the same amount and requires a new swipe and payment authorization; close it without paying and verify Activity contains no outgoing payment for this request + Reopen the unpaid fixed-conversion request from the pending-request list, stop on Amount (id "SendAmount"), select Savings, and verify the Bitcoin amount is 1,000 sats + Background the app while Amount is visible, reopen it, and complete only app-unlock PIN entry; verify Amount still shows 1,000 sats with Savings selected + Tap Continue (id "ContinueAmount") and verify the same request reaches Bitcoin confirmation (id "PaymentRequestConfirm") with 1,000 sats and a prepared fee + Close the request without paying, verify it remains pending and can be opened again, and verify Activity contains no outgoing payment for it