From bee8af892254f368e5ecb4d01f99f061ee59b892 Mon Sep 17 00:00:00 2001 From: benk10 Date: Wed, 21 Oct 2026 09:22:25 -0600 Subject: [PATCH 1/2] perf: keep request creation ahead of refreshes --- .../PaykitPaymentRequestService.swift | 2 + .../PaykitPaymentRequestServiceTests.swift | 85 +++++++++++++++++-- 2 files changed, 81 insertions(+), 6 deletions(-) diff --git a/Bitkit/Services/PaykitPaymentRequestService.swift b/Bitkit/Services/PaykitPaymentRequestService.swift index 02c390eeb..c6ae37e20 100644 --- a/Bitkit/Services/PaykitPaymentRequestService.swift +++ b/Bitkit/Services/PaykitPaymentRequestService.swift @@ -1740,7 +1740,9 @@ final class PaykitPaymentRequestManager { let actionGeneration = stateGeneration let savedPublicKeysSnapshot = savedPublicKeys isCreatingRequest = true + let review = paymentActivity.beginReview() defer { + paymentActivity.endReview(review) if actionGeneration == stateGeneration { isCreatingRequest = false } diff --git a/BitkitTests/PaykitPaymentRequestServiceTests.swift b/BitkitTests/PaykitPaymentRequestServiceTests.swift index 2a540aa94..1f79af396 100644 --- a/BitkitTests/PaykitPaymentRequestServiceTests.swift +++ b/BitkitTests/PaykitPaymentRequestServiceTests.swift @@ -8386,23 +8386,39 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { role: .payee, expiresAt: timestamp(expiresAt) )) - let manager = paymentRequestManager(sdk: sdk) + let activity = PaykitPaymentActivity() + let manager = paymentRequestManager(sdk: sdk, paymentActivity: activity) await manager.refreshEligibleTargets(savedPublicKeys: [publicKey]) let target = try XCTUnwrap(manager.eligibleTargets.first) let wasPaused = await PrivatePaykitService.shared.isBackgroundWorkPaused addTeardownBlock { await PrivatePaykitService.shared.setBackgroundWorkPaused(wasPaused) } + await sdk.pauseNextLinkedPeers() await sdk.pauseNextProposal() + await sdk.pauseNextProcess() let proposal = Task { try await manager.propose( PaykitPaymentRequestDraft(amount: PaykitAmount(asset: .btc, atomic: 1), note: "Coffee", expiresAt: expiresAt), to: target ) } + try await waitUntil { await sdk.linkedPeersIsPaused() } + XCTAssertTrue(activity.shouldDeferRefresh) + XCTAssertFalse(activity.isActive) + await sdk.resumeLinkedPeers() try await waitUntil { await sdk.proposalIsPaused() } + XCTAssertTrue(activity.shouldDeferRefresh) await PrivatePaykitService.shared.setBackgroundWorkPaused(true) await sdk.resumeProposal() + try await waitUntil { await sdk.processIsPaused() } + XCTAssertTrue(activity.shouldDeferRefresh) + let otherReview = activity.beginReview() + await sdk.resumeProcess() let request = try await proposal.value + XCTAssertTrue(activity.shouldDeferRefresh) + activity.endReview(otherReview) + XCTAssertFalse(activity.shouldDeferRefresh) + XCTAssertFalse(manager.isCreatingRequest) XCTAssertEqual(request.amount.atomic, 1) XCTAssertEqual(request.note, "Coffee") @@ -8427,6 +8443,47 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { XCTAssertEqual(priorities["delivery"], [.interactive]) } + func testCancelledProposalReleasesReviewAndAllowsAnotherCreation() async throws { + let publicKey = "pubky\(String(repeating: "y", count: 52))" + let expiresAt = Date(timeIntervalSince1970: 1_900_000_000) + let sdk = PaymentRequestSdkMock(records: []) + await sdk.configureRecipients( + peers: [linkedPeer(counterparty: publicKey, state: .linked)], + requestCapabilitiesByPublicKey: [publicKey: true] + ) + try await sdk.setProposalResult(paymentRequestRecord( + id: "outgoing", counterparty: publicKey, role: .payee, expiresAt: timestamp(expiresAt) + )) + let activity = PaykitPaymentActivity() + let manager = paymentRequestManager(sdk: sdk, paymentActivity: activity) + await manager.refreshEligibleTargets(savedPublicKeys: [publicKey]) + let target = try XCTUnwrap(manager.eligibleTargets.first) + let draft = PaykitPaymentRequestDraft(amount: PaykitAmount(asset: .btc, atomic: 1), note: "", expiresAt: expiresAt) + await sdk.pauseNextLinkedPeers() + let proposal = Task { try await manager.propose(draft, to: target) } + try await waitUntil { await sdk.linkedPeersIsPaused() } + XCTAssertTrue(activity.shouldDeferRefresh) + + proposal.cancel() + await sdk.resumeLinkedPeers() + do { + _ = try await proposal.value + XCTFail("Expected cancelled recipient discovery") + } catch is CancellationError {} + XCTAssertFalse(activity.shouldDeferRefresh) + XCTAssertFalse(manager.isCreatingRequest) + let cancelledSnapshot = await sdk.snapshot() + XCTAssertTrue(cancelledSnapshot.proposedRequests.isEmpty) + XCTAssertTrue(cancelledSnapshot.processedCounterparties.isEmpty) + + let request = try await manager.propose(draft, to: target) + XCTAssertEqual(request.paymentRequestId, "outgoing") + XCTAssertFalse(activity.shouldDeferRefresh) + let snapshot = await sdk.snapshot() + XCTAssertEqual(snapshot.proposedRequests.count, 1) + XCTAssertEqual(snapshot.processedCounterparties, [publicKey]) + } + func testProposalsOnlyInspectAndDrainSelectedSavedTarget() async throws { let publicKey = "pubky\(String(repeating: "y", count: 52))" let unrelatedKey = "pubky\(String(repeating: "a", count: 52))" @@ -8592,7 +8649,8 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { id: "outgoing", counterparty: publicKey, role: .payee, proposalOutboundMessageId: 7 )) await sdk.setPaymentRequestListError(error) - let manager = paymentRequestManager(sdk: sdk) + let activity = PaykitPaymentActivity() + let manager = paymentRequestManager(sdk: sdk, paymentActivity: activity) await manager.refreshEligibleTargets(savedPublicKeys: [publicKey]) let request = try await manager.propose( @@ -8600,6 +8658,8 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { to: XCTUnwrap(manager.eligibleTargets.first) ) + XCTAssertFalse(activity.shouldDeferRefresh) + XCTAssertFalse(manager.isCreatingRequest) XCTAssertEqual(request.deliveryStatus, .queued) XCTAssertEqual(manager.outgoingRequests.map(\.paymentRequestId), ["outgoing"]) let snapshot = await sdk.snapshot() @@ -8716,7 +8776,8 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { } else { await sdk.failNextProcess() } - let manager = paymentRequestManager(sdk: sdk) + let activity = PaykitPaymentActivity() + let manager = paymentRequestManager(sdk: sdk, paymentActivity: activity) await manager.refreshEligibleTargets(savedPublicKeys: [publicKey]) let request = try await manager.propose( @@ -8724,6 +8785,8 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { to: XCTUnwrap(manager.eligibleTargets.first) ) + XCTAssertFalse(activity.shouldDeferRefresh) + XCTAssertFalse(manager.isCreatingRequest) XCTAssertEqual(request.deliveryStatus, .queued) XCTAssertEqual(manager.outgoingRequests.map(\.paymentRequestId), ["outgoing"]) let snapshot = await sdk.snapshot() @@ -8748,7 +8811,8 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { role: .payee, expiresAt: timestamp(expiresAt) )) - let manager = paymentRequestManager(sdk: sdk) + let activity = PaykitPaymentActivity() + let manager = paymentRequestManager(sdk: sdk, paymentActivity: activity) await manager.refreshEligibleTargets(savedPublicKeys: [publicKey]) let target = try XCTUnwrap(manager.eligibleTargets.first) @@ -8763,6 +8827,8 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { } catch let error as PaykitPaymentRequestError { XCTAssertEqual(error, .requestUnavailable) } + XCTAssertFalse(activity.shouldDeferRefresh) + XCTAssertFalse(manager.isCreatingRequest) let snapshot = await sdk.snapshot() XCTAssertTrue(snapshot.proposedRequests.isEmpty) } @@ -8807,7 +8873,8 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { expiresAt: timestamp(expiresAt) )) await sdk.pauseNextProposal() - let manager = paymentRequestManager(sdk: sdk) + let activity = PaykitPaymentActivity() + let manager = paymentRequestManager(sdk: sdk, paymentActivity: activity) await manager.refreshEligibleTargets(savedPublicKeys: [publicKey]) let target = try XCTUnwrap(manager.eligibleTargets.first) @@ -8818,10 +8885,12 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { ) } try await waitUntil { await sdk.proposalIsPaused() } + XCTAssertTrue(activity.shouldDeferRefresh) manager.clear() await sdk.resumeProposal() let committedRequest = try await task.value + XCTAssertFalse(activity.shouldDeferRefresh) XCTAssertEqual(committedRequest.paymentRequestId, "outgoing") XCTAssertTrue(manager.outgoingRequests.isEmpty) XCTAssertTrue(manager.eligibleTargets.isEmpty) @@ -8842,7 +8911,8 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { expiresAt: timestamp(expiresAt) )) await sdk.pauseNextProposal() - let manager = paymentRequestManager(sdk: sdk) + let activity = PaykitPaymentActivity() + let manager = paymentRequestManager(sdk: sdk, paymentActivity: activity) await manager.refreshEligibleTargets(savedPublicKeys: [publicKey]) let target = try XCTUnwrap(manager.eligibleTargets.first) @@ -8853,6 +8923,7 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { ) } try await waitUntil { await sdk.proposalIsPaused() } + XCTAssertTrue(activity.shouldDeferRefresh) await sdk.setActiveIdentity("pubky\(String(repeating: "a", count: 52))") await sdk.resumeProposal() @@ -8862,6 +8933,8 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { } catch let error as PaykitPaymentRequestError { XCTAssertEqual(error, .requestUnavailable) } + XCTAssertFalse(activity.shouldDeferRefresh) + XCTAssertFalse(manager.isCreatingRequest) let snapshot = await sdk.snapshot() XCTAssertTrue(snapshot.proposedRequests.isEmpty) XCTAssertTrue(manager.outgoingRequests.isEmpty) From 8830af54d1aa1e9986f0974227dc0b4122e31df2 Mon Sep 17 00:00:00 2001 From: benk10 Date: Thu, 29 Oct 2026 11:36:34 +0200 Subject: [PATCH 2/2] fix: keep payment confirmation busy after unlock --- .../Wallets/Send/SendConfirmationView.swift | 9 ++-- .../SendConfirmationLifecycleTests.swift | 51 ++++++++++++++++++- BitkitTests/SendConfirmationSwipeTests.swift | 15 ++++++ changelog.d/next/1039.fixed.md | 2 +- 4 files changed, 71 insertions(+), 6 deletions(-) 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/BitkitTests/SendConfirmationLifecycleTests.swift b/BitkitTests/SendConfirmationLifecycleTests.swift index 4f23ba88f..62dafc610 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 index a46649f74..ac1d67e07 100644 --- a/changelog.d/next/1039.fixed.md +++ b/changelog.d/next/1039.fixed.md @@ -1 +1 @@ -Fixed payment requests losing their amount or getting stuck on loading after unlocking the app. +Fixed payment requests losing their amount or getting stuck on loading after unlocking the app, and kept in-flight payments busy after unlock.