diff --git a/Bitkit/AppScene.swift b/Bitkit/AppScene.swift index 5531e8f5c..dbe2ff50d 100644 --- a/Bitkit/AppScene.swift +++ b/Bitkit/AppScene.swift @@ -371,6 +371,7 @@ struct AppScene: View { @State private var foregroundReturnTracker = ForegroundReturnTracker() @State private var paykitLifecycleTask: Task? @State private var isPaykitSceneActive = false + @State private var hasPendingExpiryToast = false @State private var isContactRefreshForeground = false @State private var shopContactImport: (id: UUID, task: Task)? @State private var foregroundWalletSync: Task? @@ -540,6 +541,7 @@ struct AppScene: View { .onChange(of: jadeManager.devicesRevision) { _, _ in pushHardwareDevices() } .onChange(of: isPinVerified) { _, verified in if verified { + showPendingExpiryToast() Task { await hwWalletManager.reconnectOnForeground() } Task { await presentNextIncomingPaykitItem() } } @@ -704,6 +706,10 @@ struct AppScene: View { .onChange(of: incomingPaykitPaymentRequestPresentationState) { previous, current in handleIncomingPaykitPaymentRequestPresentationStateChange(from: previous, to: current) } + .onChange(of: paykitPaymentRequestManager.silentExpirationTrigger) { + queueSilentExpiryToast() + } + .onChange(of: scenePhase) { showPendingExpiryToast() } .onChange(of: navigation.currentRoute) { oldRoute, newRoute in guard shouldDiscardPendingImport(currentRoute: oldRoute, destination: newRoute) else { return @@ -1824,6 +1830,30 @@ struct AppScene: View { IncomingPaykitPaymentRequestPresentationState(paykitPaymentRequestManager) } + private func queueSilentExpiryToast() { + let payingRequestId = app.contactPaymentContext?.incomingPaymentRequest?.id + guard paykitPaymentRequestManager.silentlyExpiredRequests.contains(where: { $0.id != payingRequestId }) else { return } + hasPendingExpiryToast = true + showPendingExpiryToast() + } + + /// Waits until the app is in front and unlocked, so the toast isn't shown, and lost, behind the PIN screen. + private func showPendingExpiryToast() { + guard hasPendingExpiryToast else { return } + guard PaykitFeatureFlags.isUIEnabled else { + hasPendingExpiryToast = false + return + } + guard scenePhase == .active, isPinVerified || !settings.pinEnabled else { return } + hasPendingExpiryToast = false + app.toast( + type: .error, + title: t("wallet__payment_request"), + description: t("wallet__payment_request_expired"), + accessibilityIdentifier: "PaymentRequestExpiredToast" + ) + } + private func handleIncomingPaykitPaymentRequestPresentationStateChange( from previous: IncomingPaykitPaymentRequestPresentationState, to current: IncomingPaykitPaymentRequestPresentationState diff --git a/Bitkit/Services/PaykitPaymentRequestService.swift b/Bitkit/Services/PaykitPaymentRequestService.swift index 41edccae1..05d149159 100644 --- a/Bitkit/Services/PaykitPaymentRequestService.swift +++ b/Bitkit/Services/PaykitPaymentRequestService.swift @@ -1359,6 +1359,12 @@ final class PaykitPaymentRequestManager { private static let recentEligibilityCheckInterval = TimeInterval(30) private(set) var pendingRequests: [PaykitPaymentRequest] = [] + + /// Incoming one-time requests that expired before the user accepted or paid them, without being opened. + private(set) var silentlyExpiredRequests: [PaykitPaymentRequest] = [] + /// A refresh that started before an expiry can put the request back, so report each one once. + private var reportedSilentExpirationIds: Set = [] + private(set) var silentExpirationTrigger = 0 private(set) var retainedHardwareRetries: [PaykitPaymentRequest.ID: PendingPaykitPaymentProof] = [:] private(set) var historyRequests: [PaykitPaymentRequest] = [] private(set) var subscriptions: [PaykitSubscription] = [] @@ -2168,7 +2174,7 @@ final class PaykitPaymentRequestManager { @discardableResult func requestPresentation(_ request: PaykitPaymentRequest) -> Bool { - discardExpiredRequests() + discardExpiredRequests(openedRequestId: request.id) guard pendingRequests.contains(where: { $0.id == request.id }), !processingRequestIds.contains(request.id), requestedPresentationId == nil @@ -2229,6 +2235,8 @@ final class PaykitPaymentRequestManager { presentationRetryDeadlines = [:] automaticPresentationDiagnosticReasons = [:] expiredRequestedPresentations = [] + reportedSilentExpirationIds = [] + silentlyExpiredRequests = [] unavailableRequestedPresentations = [] requestedPresentationId = nil requestedSubscriptionProposalId = nil @@ -2505,6 +2513,12 @@ final class PaykitPaymentRequestManager { let refreshDate = now() let subscriptionDate = subscriptionNow() let handledRequestedExpirationId = recordRequestedPresentationExpiration(at: refreshDate) + // The snapshot already dropped expired proposals; skip ones it still keeps, or the timer reports them again. + recordSilentExpirations( + at: refreshDate, + excluding: [handledRequestedExpirationId, excludingProtectedRequestId].compactMap { $0 } + + snapshot.incoming.map(\.id) + ) let previousPending = pendingRequests receivedPaymentContacts = snapshot.receivedPaymentContacts let satisfiedUsdtProofs = (try? PaykitUsdtPaymentService.shared.satisfiedProofs(identity: activeIdentity)) ?? [:] @@ -2685,7 +2699,7 @@ final class PaykitPaymentRequestManager { operation: (PaykitPaymentRequest) async throws -> Void ) async throws { guard !request.isExpired(at: now()) else { - discardExpiredRequests() + discardExpiredRequests(openedRequestId: request.id) throw PaykitPaymentRequestError.requestExpired } guard pendingRequests.contains(where: { $0.id == request.id }) else { @@ -2765,11 +2779,13 @@ final class PaykitPaymentRequestManager { private func discardExpiredRequests( at: Date? = nil, - handledRequestedExpirationId: PaykitPaymentRequest.ID? = nil + handledRequestedExpirationId: PaykitPaymentRequest.ID? = nil, + openedRequestId: PaykitPaymentRequest.ID? = nil ) { let date = at ?? now() let subscriptionDate = subscriptionNow() recordRequestedPresentationExpiration(at: date, excluding: handledRequestedExpirationId) + recordSilentExpirations(at: date, excluding: [handledRequestedExpirationId, openedRequestId].compactMap { $0 }) pendingRequests.removeAll { $0.isExpired(at: date) } subscriptions = subscriptions.map { $0.withExpiredLifecycle(at: subscriptionDate) } presentedSubscriptionProposalIds.formIntersection( @@ -2795,6 +2811,19 @@ final class PaykitPaymentRequestManager { schedulePresentationRetry() } + private func recordSilentExpirations(at date: Date, excluding handledIds: [PaykitPaymentRequest.ID]) { + let expired = pendingRequests.filter { + $0.id != requestedPresentationId && !handledIds.contains($0.id) && !reportedSilentExpirationIds.contains($0.id) + && !processingRequestIds.contains($0.id) + && $0.direction == .incoming && $0.billingPeriod == nil && $0.lifecycleState == .proposed + && $0.isExpired(at: date) + } + guard !expired.isEmpty else { return } + reportedSilentExpirationIds.formUnion(expired.map(\.id)) + silentlyExpiredRequests = expired + silentExpirationTrigger += 1 + } + @discardableResult private func recordRequestedPresentationExpiration( at date: Date, @@ -2808,6 +2837,7 @@ final class PaykitPaymentRequestManager { else { return nil } expiredRequestedPresentations.append(expiredRequest) + reportedSilentExpirationIds.insert(expiredRequest.id) requestedPresentationExpirationTrigger += 1 return requestedPresentationId } diff --git a/BitkitTests/PaykitPaymentRequestServiceTests.swift b/BitkitTests/PaykitPaymentRequestServiceTests.swift index 39a8772bf..6a041f0e4 100644 --- a/BitkitTests/PaykitPaymentRequestServiceTests.swift +++ b/BitkitTests/PaykitPaymentRequestServiceTests.swift @@ -5111,6 +5111,155 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { XCTAssertTrue(manager.pendingRequests.isEmpty) XCTAssertEqual(manager.requestedPresentationExpirationTrigger, 0) XCTAssertNil(manager.consumeExpiredRequestedPresentation()) + XCTAssertEqual(manager.silentExpirationTrigger, 0) + } + + func testPendingRequestExpiringUnopenedIsReportedAsSilentExpiration() async throws { + let now = Date(timeIntervalSince1970: 1_800_000_000) + let clock = PaymentRequestTestClock(now) + let sdk = try PaymentRequestSdkMock(records: [ + paymentRequestRecord(expiresAt: timestamp(now.addingTimeInterval(60))), + ]) + let manager = paymentRequestManager(sdk: sdk, clock: clock) + await manager.refresh() + let request = try XCTUnwrap(manager.pendingRequests.first) + XCTAssertEqual(manager.silentExpirationTrigger, 0) + + clock.advance(by: 60) + manager.reconcileExpiredRequests() + + XCTAssertTrue(manager.pendingRequests.isEmpty) + XCTAssertEqual(manager.silentExpirationTrigger, 1) + XCTAssertEqual(manager.silentlyExpiredRequests, [request]) + XCTAssertEqual(manager.requestedPresentationExpirationTrigger, 0) + } + + func testOpeningAnExpiredRequestIsNotReportedAsSilentExpiration() async throws { + let now = Date(timeIntervalSince1970: 1_800_000_000) + let clock = PaymentRequestTestClock(now) + let sdk = try PaymentRequestSdkMock(records: [ + paymentRequestRecord(expiresAt: timestamp(now.addingTimeInterval(60))), + ]) + let manager = paymentRequestManager(sdk: sdk, clock: clock) + await manager.refresh() + let request = try XCTUnwrap(manager.pendingRequests.first) + + clock.advance(by: 60) + + XCTAssertFalse(manager.requestPresentation(request)) + XCTAssertTrue(manager.pendingRequests.isEmpty) + XCTAssertEqual(manager.silentExpirationTrigger, 0) + } + + func testRefreshAfterExpiryReportsSilentExpiration() async throws { + let now = Date(timeIntervalSince1970: 1_800_000_000) + let clock = PaymentRequestTestClock(now) + let sdk = try PaymentRequestSdkMock(records: [ + paymentRequestRecord(expiresAt: timestamp(now.addingTimeInterval(60))), + ]) + let manager = paymentRequestManager(sdk: sdk, clock: clock) + await manager.refresh() + let request = try XCTUnwrap(manager.pendingRequests.first) + + clock.advance(by: 60) + await manager.refresh(forceFresh: true) + + XCTAssertTrue(manager.pendingRequests.isEmpty) + XCTAssertEqual(manager.silentExpirationTrigger, 1) + XCTAssertEqual(manager.silentlyExpiredRequests, [request]) + } + + func testReinstatedExpiredRequestIsReportedOnce() async throws { + let now = Date(timeIntervalSince1970: 1_800_000_000) + let clock = PaymentRequestTestClock(now) + let sdk = try PaymentRequestSdkMock(records: [ + paymentRequestRecord(expiresAt: timestamp(now.addingTimeInterval(60))), + ]) + let manager = paymentRequestManager(sdk: sdk, clock: clock) + await manager.refresh() + + clock.shiftWallClock(by: 60) + manager.reconcileExpiredRequests() + XCTAssertEqual(manager.silentExpirationTrigger, 1) + + // A refresh taken before the expiry puts the request back, then it expires again. + clock.shiftWallClock(by: -30) + _ = await manager.refresh(forceFresh: true) + XCTAssertEqual(manager.pendingRequests.count, 1) + clock.shiftWallClock(by: 30) + manager.reconcileExpiredRequests() + + XCTAssertTrue(manager.pendingRequests.isEmpty) + XCTAssertEqual(manager.silentExpirationTrigger, 1) + } + + func testRequestExpiringWhileBeingActedOnIsNotReportedAsSilentExpiration() async throws { + let now = Date(timeIntervalSince1970: 1_800_000_000) + let clock = PaymentRequestTestClock(now) + let sdk = try PaymentRequestSdkMock(records: [ + paymentRequestRecord(expiresAt: timestamp(now.addingTimeInterval(60))), + ]) + let manager = paymentRequestManager(sdk: sdk, clock: clock) + await manager.refresh() + let request = try XCTUnwrap(manager.pendingRequests.first) + await sdk.pauseNextAccept() + + let preparation = Task { try await manager.prepareForPayment(request) } + try await waitUntil { await sdk.acceptIsPaused() } + clock.shiftWallClock(by: 60) + manager.reconcileExpiredRequests() + await sdk.resumeAccept() + _ = try? await preparation.value + + XCTAssertEqual(manager.silentExpirationTrigger, 0) + } + + func testRequestedExpiryRestoredByRefreshIsNotReportedAgain() async throws { + let now = Date(timeIntervalSince1970: 1_800_000_000) + let clock = PaymentRequestTestClock(now) + let sdk = try PaymentRequestSdkMock(records: [ + paymentRequestRecord(expiresAt: timestamp(now.addingTimeInterval(60))), + ]) + let manager = paymentRequestManager(sdk: sdk, clock: clock) + await manager.refresh() + let request = try XCTUnwrap(manager.pendingRequests.first) + XCTAssertTrue(manager.requestPresentation(request)) + + clock.shiftWallClock(by: 60) + manager.reconcileExpiredRequests() + XCTAssertEqual(manager.consumeExpiredRequestedPresentation(), request) + + // A refresh taken before the expiry puts the request back, then it expires again. + clock.shiftWallClock(by: -30) + _ = await manager.refresh(forceFresh: true) + XCTAssertEqual(manager.pendingRequests.count, 1) + clock.shiftWallClock(by: 30) + manager.reconcileExpiredRequests() + + XCTAssertTrue(manager.pendingRequests.isEmpty) + XCTAssertEqual(manager.silentExpirationTrigger, 0) + } + + func testDismissingAnExpiredRequestIsNotReportedAsSilentExpiration() async throws { + let now = Date(timeIntervalSince1970: 1_800_000_000) + let clock = PaymentRequestTestClock(now) + let sdk = try PaymentRequestSdkMock(records: [ + paymentRequestRecord(expiresAt: timestamp(now.addingTimeInterval(60))), + ]) + let manager = paymentRequestManager(sdk: sdk, clock: clock) + await manager.refresh() + let request = try XCTUnwrap(manager.pendingRequests.first) + + clock.advance(by: 60) + + do { + try await manager.dismiss(request) + XCTFail("Expected requestExpired") + } catch { + XCTAssertEqual(error as? PaykitPaymentRequestError, .requestExpired) + } + XCTAssertTrue(manager.pendingRequests.isEmpty) + XCTAssertEqual(manager.silentExpirationTrigger, 0) } func testRequestedExpirationSurvivesSuspendedResolution() async throws { @@ -5143,6 +5292,7 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { XCTAssertEqual(manager.requestedPresentationExpirationTrigger, 1) XCTAssertEqual(manager.consumeExpiredRequestedPresentation(), request) XCTAssertNil(manager.consumeExpiredRequestedPresentation()) + XCTAssertEqual(manager.silentExpirationTrigger, 0) continuation?.resume() _ = await presentationTask.value diff --git a/changelog.d/next/1051.added.md b/changelog.d/next/1051.added.md new file mode 100644 index 000000000..2deca8c89 --- /dev/null +++ b/changelog.d/next/1051.added.md @@ -0,0 +1 @@ +Bitkit now shows a toast when a pending payment request expires. diff --git a/journeys/payment-requests/expiry-toast.xml b/journeys/payment-requests/expiry-toast.xml new file mode 100644 index 000000000..a1079a9cd --- /dev/null +++ b/journeys/payment-requests/expiry-toast.xml @@ -0,0 +1,16 @@ + + + Verifies that a pending incoming one-time Payment Request that expires unopened shows the expired toast once, and that a request the user opened is not reported twice. Uses the linked fixture issuer from README.md, which can send a request with a short proposal expiry. Toasts do not reach the layout dump, so capture a screenshot right after the expiry. + + + Launch the E2E Bitkit app with Paykit UI enabled and the fixture issuer saved as a contact and linked + Have the issuer send a proposed one-time request for 0.001 btc with a proposal expiry 60 seconds from now + Dismiss the automatic Payment Request confirmation with Back and stay on the home screen without opening the request + Verify the pending Payment Requests bell (id "PaymentRequestsBell") is visible + Wait until the proposal expiry passes + Verify from a screenshot that a toast titled "Payment Request" with "The payment request has expired." appears, and that the bell (id "PaymentRequestsBell") is gone + Verify no second expired toast appears within the next 10 seconds + Have the issuer send another one-time request with a proposal expiry 60 seconds from now, dismiss the automatic confirmation, then tap the bell and open the request row to its details + Wait on Payment Request details until the expiry passes and verify at most one expired toast appears + +