From 8e624107911acd7b7aded541e1fdbfe136d857cf Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 2 Sep 2026 02:16:30 +0200 Subject: [PATCH 01/13] fix: expose payment request failures (#714) --- Bitkit/AppScene.swift | 34 ++++- .../PaykitPaymentRequestService.swift | 130 +++++++++++++----- .../PrivatePaykitService+Payments.swift | 3 +- Bitkit/Services/PublicPaykitService.swift | 31 +++++ .../PaymentRequests/PaymentRequestsView.swift | 2 + .../PaykitPaymentRequestServiceTests.swift | 92 ++++++++++++- BitkitTests/PublicPaykitServiceTests.swift | 15 ++ changelog.d/next/714.fixed.md | 1 + journeys/payment-requests/README.md | 42 ++++++ .../requested-resolution-failure.xml | 21 +++ 10 files changed, 323 insertions(+), 48 deletions(-) create mode 100644 changelog.d/next/714.fixed.md create mode 100644 journeys/payment-requests/README.md create mode 100644 journeys/payment-requests/requested-resolution-failure.xml diff --git a/Bitkit/AppScene.swift b/Bitkit/AppScene.swift index ab8f05a7f..6d74a41d4 100644 --- a/Bitkit/AppScene.swift +++ b/Bitkit/AppScene.swift @@ -837,8 +837,10 @@ struct AppScene: View { app.contactPaymentContext == nil else { return } guard case let .opened(paymentTarget, privatePaymentContext) = result else { - Logger.debug("Incoming Paykit payment request is waiting for private payment details: \(result)", context: "AppScene") - paykitPaymentRequestManager.deferPresentation(request) + deferIncomingPaykitPaymentRequestPresentation( + request, + reason: result.incomingPaymentRequestFailureReason ?? .resolutionFailed + ) continue } @@ -869,7 +871,7 @@ struct AppScene: View { guard PaymentNavigationHelper.appropriateSendRoute(app: app, currency: currency, settings: settings) != nil else { app.resetSendState() wallet.resetSendState(speed: settings.defaultTransactionSpeed) - paykitPaymentRequestManager.deferPresentation(request) + deferIncomingPaykitPaymentRequestPresentation(request, reason: .paymentTargetNotRoutable) continue } @@ -891,10 +893,9 @@ struct AppScene: View { wallet.resetSendState(speed: settings.defaultTransactionSpeed) return } - Logger.warn("Failed to present incoming Paykit payment request: \(error)", context: "AppScene") app.resetSendState() wallet.resetSendState(speed: settings.defaultTransactionSpeed) - paykitPaymentRequestManager.deferPresentation(request) + deferIncomingPaykitPaymentRequestPresentation(request, reason: .invalidPaymentTarget) continue } @@ -906,6 +907,7 @@ struct AppScene: View { else { app.resetSendState() wallet.resetSendState(speed: settings.defaultTransactionSpeed) + deferIncomingPaykitPaymentRequestPresentation(request, reason: .paymentTargetNotRoutable) return } sheets.showSheet(.send, data: SendConfig(view: route)) @@ -914,8 +916,7 @@ struct AppScene: View { return } catch { guard paykitPaymentRequestManager.isCurrentPresentation(request) else { return } - Logger.warn("Failed to present incoming Paykit payment request: \(error)", context: "AppScene") - paykitPaymentRequestManager.deferPresentation(request) + deferIncomingPaykitPaymentRequestPresentation(request, reason: .resolutionFailed) } } } @@ -930,6 +931,25 @@ struct AppScene: View { await presentNextIncomingPaykitPaymentRequest() } + private func deferIncomingPaykitPaymentRequestPresentation( + _ request: PaykitPaymentRequest, + reason: IncomingPaykitPaymentRequestFailureReason + ) { + Logger.warn( + "Rejected incoming Paykit payment request presentation: category=\(reason.category) reason=\(reason.rawValue) " + + "counterparty=\(PaykitPaymentRequestDiagnostics.redactedCounterparty(request.counterparty))", + context: "AppScene" + ) + + guard paykitPaymentRequestManager.deferPresentation(request) == .requestedPresentationEnded else { return } + app.toast( + type: .error, + title: t("wallet__payment_request"), + description: t("wallet__payment_request_unavailable"), + accessibilityIdentifier: "PaymentRequestUnavailableToast" + ) + } + private func retryPendingPaykitEndpointRemoval() async { if PublicPaykitService.isCleanupPending { do { diff --git a/Bitkit/Services/PaykitPaymentRequestService.swift b/Bitkit/Services/PaykitPaymentRequestService.swift index c492a16b0..de85583fa 100644 --- a/Bitkit/Services/PaykitPaymentRequestService.swift +++ b/Bitkit/Services/PaykitPaymentRequestService.swift @@ -2,6 +2,24 @@ import Foundation import Paykit struct PaykitPaymentRequest: Identifiable, Hashable { + enum ParseFailure: String, Error, Equatable { + case missingLocalRole = "missing_local_role" + case unsupportedLocalRole = "unsupported_local_role" + case nonActionableState = "non_actionable_state" + case missingTerms = "missing_terms" + case recurringRequest = "recurring_request" + case unsupportedAsset = "unsupported_asset" + case invalidAmount = "invalid_amount" + case amountOutOfRange = "amount_out_of_range" + case noSupportedEndpoint = "no_supported_endpoint" + case invalidExpiration = "invalid_expiration" + case expired + + var shouldLogIncomingRejection: Bool { + self != .unsupportedLocalRole && self != .nonActionableState + } + } + enum Direction: Hashable { case incoming case outgoing @@ -40,69 +58,80 @@ struct PaykitPaymentRequest: Identifiable, Hashable { } init?(record: Paykit.PaymentRequestRecord, now: Date) { - self.init(record: record, expectedRole: .payer, now: now, requiresActionableRequest: true) + guard case let .success(request) = Self.parseIncoming(record: record, now: now) else { return nil } + self = request } init?(historyRecord: Paykit.PaymentRequestRecord, now: Date) { guard let localRole = historyRecord.localRole else { return nil } switch localRole { case .payer, .payee: - self.init(record: historyRecord, expectedRole: localRole, now: now, requiresActionableRequest: false) + guard case let .success(request) = Self.parse( + record: historyRecord, + expectedRole: localRole, + now: now, + requiresActionableRequest: false + ) else { return nil } + self = request case .unknown: return nil } } - private init?( + static func parseIncoming(record: Paykit.PaymentRequestRecord, now: Date) -> Result { + parse(record: record, expectedRole: .payer, now: now, requiresActionableRequest: true) + } + + private static func parse( record: Paykit.PaymentRequestRecord, expectedRole: Paykit.PaymentRequestLocalRole, now: Date, requiresActionableRequest: Bool - ) { - guard record.localRole == expectedRole, - record.state != .activeRecurring, - let terms = record.terms, - terms.recurrence == nil, - terms.amount.asset == "btc", - let amountSats = Self.sats(fromBitcoinAmount: terms.amount.value), - amountSats <= UInt64.max / 1000 - else { return nil } + ) -> Result { + guard let localRole = record.localRole else { return .failure(.missingLocalRole) } + guard localRole == expectedRole else { return .failure(.unsupportedLocalRole) } if requiresActionableRequest, record.state != .proposed { - return nil + return .failure(.nonActionableState) } + guard record.state != .activeRecurring else { return .failure(.recurringRequest) } + guard let terms = record.terms else { return .failure(.missingTerms) } + guard terms.recurrence == nil else { return .failure(.recurringRequest) } + guard terms.amount.asset == "btc" else { return .failure(.unsupportedAsset) } + guard let amountSats = Self.sats(fromBitcoinAmount: terms.amount.value) else { return .failure(.invalidAmount) } + guard amountSats <= UInt64.max / 1000 else { return .failure(.amountOutOfRange) } + let acceptedPaymentEndpointIdentifiers = Self.supportedEndpointIdentifiers( terms.acceptedPaymentEndpointIdentifiers ) if requiresActionableRequest, acceptedPaymentEndpointIdentifiers.isEmpty { - return nil + return .failure(.noSupportedEndpoint) } let expiresAt: Date? if let proposalExpiresAt = terms.proposalExpiresAt { - guard let parsedExpiration = Self.parseDate(proposalExpiresAt), - !requiresActionableRequest || parsedExpiration > now - else { - return nil - } + guard let parsedExpiration = Self.parseDate(proposalExpiresAt) else { return .failure(.invalidExpiration) } + guard !requiresActionableRequest || parsedExpiration > now else { return .failure(.expired) } expiresAt = parsedExpiration } else { expiresAt = nil } - paymentRequestId = record.paymentRequestId - counterparty = record.counterparty - counterpartyReceiverPath = record.counterpartyReceiverPath - amountValue = terms.amount.value - self.amountSats = amountSats - note = Self.note(from: terms.metadata) - createdAt = record.lastEventAt.flatMap(Self.parseDate) - self.expiresAt = expiresAt - self.acceptedPaymentEndpointIdentifiers = acceptedPaymentEndpointIdentifiers - deliveryStatus = expectedRole == .payee ? Self.deliveryStatus(from: record.proposalOutboundStatus) : nil - direction = expectedRole == .payer ? .incoming : .outgoing - lifecycleState = record.state + return .success(PaykitPaymentRequest( + paymentRequestId: record.paymentRequestId, + counterparty: record.counterparty, + counterpartyReceiverPath: record.counterpartyReceiverPath, + amountValue: terms.amount.value, + amountSats: amountSats, + note: Self.note(from: terms.metadata), + createdAt: record.lastEventAt.flatMap(Self.parseDate), + expiresAt: expiresAt, + acceptedPaymentEndpointIdentifiers: acceptedPaymentEndpointIdentifiers, + deliveryStatus: expectedRole == .payee ? Self.deliveryStatus(from: record.proposalOutboundStatus) : nil, + direction: expectedRole == .payer ? .incoming : .outgoing, + lifecycleState: record.state + )) } init( @@ -273,6 +302,13 @@ struct PaykitPaymentRequestSnapshot: Equatable { let history: [PaykitPaymentRequest] } +enum PaykitPaymentRequestDiagnostics { + static func redactedCounterparty(_ input: String) -> String { + guard let publicKey = PubkyPublicKeyFormat.normalized(input) else { return "" } + return PubkyPublicKeyFormat.redacted(publicKey) + } +} + enum PaykitPaymentRequestError: LocalizedError, Equatable { case requestUnavailable case requestExpired @@ -349,8 +385,19 @@ struct PaykitPaymentRequestService { logIntakeFailures(intakeReports) let synchronizationDate = now() let records = try await sdk.paymentRequests() - let incoming = records.compactMap { - PaykitPaymentRequest(record: $0, now: synchronizationDate) + let incoming = records.compactMap { record in + switch PaykitPaymentRequest.parseIncoming(record: record, now: synchronizationDate) { + case let .success(request): + return request + case let .failure(reason): + if reason.shouldLogIncomingRejection { + logWarning( + "Rejected incoming Paykit payment request: category=parse reason=\(reason.rawValue) " + + "counterparty=\(PaykitPaymentRequestDiagnostics.redactedCounterparty(record.counterparty))" + ) + } + return nil + } } let history = records.compactMap { PaykitPaymentRequest(historyRecord: $0, now: synchronizationDate) @@ -542,6 +589,12 @@ protocol PaykitPaymentRequestPresentationStoring { func save(_ ids: Set, identity: String) throws } +enum PaykitPaymentRequestPresentationDeferral: Equatable { + case retryScheduled + case requestedPresentationEnded + case ignored +} + struct PaykitPaymentRequestPresentationStore: PaykitPaymentRequestPresentationStoring { private struct State: Codable { var idsByIdentity: [String: [PaykitPaymentRequest.ID]] @@ -854,9 +907,13 @@ final class PaykitPaymentRequestManager { approvedPaymentRequestIds.remove(request.id) } - func deferPresentation(_ request: PaykitPaymentRequest) { + @discardableResult + func deferPresentation(_ request: PaykitPaymentRequest) -> PaykitPaymentRequestPresentationDeferral { + let wasRequestedPresentation = requestedPresentationId == request.id discardExpiredRequests() - guard pendingRequests.contains(where: { $0.id == request.id }) else { return } + guard pendingRequests.contains(where: { $0.id == request.id }) else { + return wasRequestedPresentation ? .requestedPresentationEnded : .ignored + } let isRequestedPresentation = requestedPresentationId == request.id presentationGeneration += 1 @@ -875,12 +932,13 @@ final class PaykitPaymentRequestManager { persistPresentedRequestIds() logWarning("Stopped retrying requested incoming Paykit payment request after \(attempt + 1) presentation attempts") schedulePresentationRetry() - return + return .requestedPresentationEnded } else { delay = Self.automaticPresentationRetryDelay } presentationRetryDates[request.id] = now().addingTimeInterval(delay) schedulePresentationRetry() + return .retryScheduled } func markPresentedIfPending(_ request: PaykitPaymentRequest) -> Bool { diff --git a/Bitkit/Services/PrivatePaykitService+Payments.swift b/Bitkit/Services/PrivatePaykitService+Payments.swift index fb76259fd..a8453f833 100644 --- a/Bitkit/Services/PrivatePaykitService+Payments.swift +++ b/Bitkit/Services/PrivatePaykitService+Payments.swift @@ -123,7 +123,8 @@ extension PrivatePaykitService { } Logger.warn( - "Failed to resolve Paykit contact payment for \(PubkyPublicKeyFormat.redacted(publicKey)): \(error)", + "Failed to resolve Paykit contact payment for \(PubkyPublicKeyFormat.redacted(publicKey)): " + + "errorType=\(String(reflecting: type(of: error)))", context: "PrivatePaykit" ) diff --git a/Bitkit/Services/PublicPaykitService.swift b/Bitkit/Services/PublicPaykitService.swift index 72274ba5b..83ff2b61a 100644 --- a/Bitkit/Services/PublicPaykitService.swift +++ b/Bitkit/Services/PublicPaykitService.swift @@ -31,6 +31,24 @@ struct PrivatePaykitPaymentContext: Equatable { let paymentListVersion: UInt64 } +enum IncomingPaykitPaymentRequestFailureReason: String, Equatable { + case noSupportedEndpoint = "no_supported_endpoint" + case endpointNotPayable = "endpoint_not_payable" + case paymentDetailsPending = "payment_details_pending" + case invalidPaymentTarget = "invalid_payment_target" + case paymentTargetNotRoutable = "payment_target_not_routable" + case resolutionFailed = "resolution_failed" + + var category: String { + switch self { + case .noSupportedEndpoint, .endpointNotPayable, .paymentDetailsPending, .resolutionFailed: + "resolution" + case .invalidPaymentTarget, .paymentTargetNotRoutable: + "presentation" + } + } +} + enum PublicPaykitPaymentLaunchResult { case opened(paymentRequest: String, privatePaymentContext: PrivatePaykitPaymentContext?) case noEndpoint @@ -47,6 +65,19 @@ enum PublicPaykitPaymentLaunchResult { "slashtags__error_pay_not_opened_msg" } } + + var incomingPaymentRequestFailureReason: IncomingPaykitPaymentRequestFailureReason? { + switch self { + case .opened: + nil + case .noEndpoint: + .noSupportedEndpoint + case .notOpened: + .endpointNotPayable + case .waitingForUpdatedPaymentList: + .paymentDetailsPending + } + } } private actor PublicPaykitEndpointLock { diff --git a/Bitkit/Views/PaymentRequests/PaymentRequestsView.swift b/Bitkit/Views/PaymentRequests/PaymentRequestsView.swift index 3e42ac6da..81f3406e5 100644 --- a/Bitkit/Views/PaymentRequests/PaymentRequestsView.swift +++ b/Bitkit/Views/PaymentRequests/PaymentRequestsView.swift @@ -78,6 +78,7 @@ struct PaymentRequestCard: View { await onReject?() isRejecting = false } + .accessibilityIdentifier("PaymentRequestDismiss-\(request.paymentRequestId)") CustomButton( title: t("common__pay"), @@ -88,6 +89,7 @@ struct PaymentRequestCard: View { ) { onPay?() } + .accessibilityIdentifier("PaymentRequestPay-\(request.paymentRequestId)") } .padding(16) .background(Color.gray5) diff --git a/BitkitTests/PaykitPaymentRequestServiceTests.swift b/BitkitTests/PaykitPaymentRequestServiceTests.swift index 42d528107..b505b402b 100644 --- a/BitkitTests/PaykitPaymentRequestServiceTests.swift +++ b/BitkitTests/PaykitPaymentRequestServiceTests.swift @@ -68,6 +68,77 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { ) } + func testIncomingParseFailuresAreReasonSpecific() throws { + let now = Date(timeIntervalSince1970: 1_800_000_000) + let cases: [(PaymentRequestRecord, PaykitPaymentRequest.ParseFailure)] = try [ + (paymentRequestRecord(id: "missing-terms"), .missingTerms), + (paymentRequestRecord(id: "wrong-asset", asset: "BTC"), .unsupportedAsset), + (paymentRequestRecord(id: "invalid-amount", amount: "not-bitcoin"), .invalidAmount), + (paymentRequestRecord(id: "amount-out-of-range", amount: "184467440737.09551615"), .amountOutOfRange), + (paymentRequestRecord(id: "unsupported-endpoint", endpoints: ["btc-unsupported-method"]), .noSupportedEndpoint), + (paymentRequestRecord(id: "invalid-expiration", expiresAt: "not-a-timestamp"), .invalidExpiration), + (paymentRequestRecord(id: "expired", expiresAt: timestamp(now)), .expired), + ].map { record, failure in + if record.paymentRequestId == "missing-terms" { + var record = record + record.terms = nil + return (record, failure) + } + return (record, failure) + } + + for (record, expectedFailure) in cases { + guard case let .failure(failure) = PaykitPaymentRequest.parseIncoming(record: record, now: now) else { + XCTFail("Expected \(record.paymentRequestId) to fail parsing") + continue + } + XCTAssertEqual(failure, expectedFailure) + } + } + + func testSynchronizeLogsRedactedParseFailuresWithoutRequestData() async throws { + let counterparty = "pubky\(String(repeating: "y", count: 52))" + let secretNote = "do-not-log-this-note" + let records = try [ + paymentRequestRecord( + id: "do-not-log-this-id", + counterparty: counterparty, + asset: "BTC", + metadata: "{\"note\":\"\(secretNote)\"}" + ), + paymentRequestRecord( + id: "do-not-log-this-endpoint-id", + counterparty: counterparty, + endpoints: ["btc-private-unsupported-endpoint"] + ), + paymentRequestRecord( + id: "do-not-log-invalid-counterparty-id", + counterparty: "do-not-log-invalid-counterparty", + asset: "BTC" + ), + ] + let recorder = PaymentRequestLogRecorder() + let service = PaykitPaymentRequestService( + sdk: PaymentRequestSdkMock(records: records), + logWarning: { recorder.append($0) } + ) + + let snapshot = try await service.synchronize() + + XCTAssertTrue(snapshot.incoming.isEmpty) + let output = recorder.messages.joined(separator: "\n") + XCTAssertTrue(output.contains("category=parse reason=unsupported_asset")) + XCTAssertTrue(output.contains("category=parse reason=no_supported_endpoint")) + XCTAssertTrue(output.contains("counterparty=\(PaykitPaymentRequestDiagnostics.redactedCounterparty(counterparty))")) + XCTAssertTrue(output.contains("counterparty=")) + XCTAssertFalse(output.contains(counterparty)) + XCTAssertFalse(output.contains("do-not-log-this-id")) + XCTAssertFalse(output.contains("do-not-log-this-endpoint-id")) + XCTAssertFalse(output.contains(secretNote)) + XCTAssertFalse(output.contains("btc-private-unsupported-endpoint")) + XCTAssertFalse(output.contains("do-not-log-invalid-counterparty")) + } + func testRefreshDropsExpiredAndUnsupportedRequests() async throws { let now = Date(timeIntervalSince1970: 1_800_000_000) let recurrence = PaymentRequestRecurrence( @@ -249,13 +320,13 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { let request = try XCTUnwrap(manager.requestsForPresentation().first) for _ in 0 ..< 14 { - manager.deferPresentation(request) + XCTAssertEqual(manager.deferPresentation(request), .retryScheduled) XCTAssertTrue(manager.requestsForPresentation().isEmpty) clock.advance(by: 2) XCTAssertEqual(manager.requestsForPresentation(), [request]) } - manager.deferPresentation(request) + XCTAssertEqual(manager.deferPresentation(request), .retryScheduled) clock.advance(by: 119) XCTAssertTrue(manager.requestsForPresentation().isEmpty) @@ -276,13 +347,13 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { XCTAssertTrue(manager.requestPresentation(request)) for _ in 0 ..< 14 { - manager.deferPresentation(request) + XCTAssertEqual(manager.deferPresentation(request), .retryScheduled) XCTAssertTrue(manager.requestsForPresentation().isEmpty) clock.advance(by: 2) XCTAssertEqual(manager.requestsForPresentation(), [request]) } - manager.deferPresentation(request) + XCTAssertEqual(manager.deferPresentation(request), .requestedPresentationEnded) XCTAssertTrue(manager.requestsForPresentation().isEmpty) XCTAssertNil(manager.requestedPresentationId) @@ -1196,6 +1267,19 @@ private final class PaymentRequestPresentationMemoryStore: PaykitPaymentRequestP } } +private final class PaymentRequestLogRecorder: @unchecked Sendable { + private let lock = NSLock() + private var storage: [String] = [] + + var messages: [String] { + lock.withLock { storage } + } + + func append(_ message: String) { + lock.withLock { storage.append(message) } + } +} + private actor PaymentRequestSdkMock: PaykitPaymentRequestSdkHandling { private var activeIdentity = "pubky\(String(repeating: "z", count: 52))" private var records: [PaymentRequestRecord] diff --git a/BitkitTests/PublicPaykitServiceTests.swift b/BitkitTests/PublicPaykitServiceTests.swift index 94c525e53..d83103c46 100644 --- a/BitkitTests/PublicPaykitServiceTests.swift +++ b/BitkitTests/PublicPaykitServiceTests.swift @@ -166,6 +166,21 @@ final class PublicPaykitServiceTests: XCTestCase { ) } + func testPaymentLaunchResultHasReasonSpecificIncomingRequestFailures() { + XCTAssertNil( + PublicPaykitPaymentLaunchResult.opened( + paymentRequest: "bitcoin:bcrt1ptest", + privatePaymentContext: nil + ).incomingPaymentRequestFailureReason + ) + XCTAssertEqual(PublicPaykitPaymentLaunchResult.noEndpoint.incomingPaymentRequestFailureReason, .noSupportedEndpoint) + XCTAssertEqual(PublicPaykitPaymentLaunchResult.notOpened.incomingPaymentRequestFailureReason, .endpointNotPayable) + XCTAssertEqual( + PublicPaykitPaymentLaunchResult.waitingForUpdatedPaymentList.incomingPaymentRequestFailureReason, + .paymentDetailsPending + ) + } + func testPayableEndpointsFiltersInvalidDecodedEndpoints() async { let payable = await PublicPaykitService.payableEndpoints(from: [ endpoint(.bitcoinLightningBolt11, value: "not-a-bolt11"), diff --git a/changelog.d/next/714.fixed.md b/changelog.d/next/714.fixed.md new file mode 100644 index 000000000..6aa6afd97 --- /dev/null +++ b/changelog.d/next/714.fixed.md @@ -0,0 +1 @@ +Payment requests now provide safe failure diagnostics and show an error when requested payment details remain unavailable. diff --git a/journeys/payment-requests/README.md b/journeys/payment-requests/README.md new file mode 100644 index 000000000..ef728420f --- /dev/null +++ b/journeys/payment-requests/README.md @@ -0,0 +1,42 @@ +# Payment-request journeys + +These journeys cover incoming Paykit Payment Requests that Bitkit can receive but cannot open. + +## Failure contract + +- Parse-time rejection emits a warning with `category=parse`, a stable reason code, and only the + redacted counterparty. It does not include the request id, amount, note, endpoint identifier, or + endpoint payload. +- Open-time rejection emits a warning with `category=resolution` or `category=presentation`, a + stable reason code, and only the redacted counterparty. +- An explicit Pay action tries immediately and fourteen more times at two-second intervals. After + the fifteenth failure, Bitkit shows an error toast with localized keys `wallet__payment_request` + and `wallet__payment_request_unavailable`, then leaves the request available for another attempt. +- Automatic presentation uses the same initial retries, then continues every 120 seconds without + showing terminal feedback. + +The failure reason vocabulary is: + +- Parse: `missing_local_role`, `missing_terms`, `recurring_request`, `unsupported_asset`, + `invalid_amount`, `amount_out_of_range`, `no_supported_endpoint`, `invalid_expiration`, `expired`. +- Resolution: `no_supported_endpoint`, `endpoint_not_payable`, `payment_details_pending`, + `resolution_failed`. +- Presentation: `invalid_payment_target`, `payment_target_not_routable`. + +`unsupported_local_role` and `non_actionable_state` are expected filtering of outgoing or completed +records, so they do not emit incoming-rejection warnings. + +## Mandatory setup + +Use a controlled Paykit peer linked to a saved contact. Seed one proposed incoming request with a +known id, lowercase `btc`, a positive amount, a future expiration, and a supported accepted endpoint +identifier. Keep the peer's payment list empty or unsupported long enough for all fifteen explicit +resolution attempts. Do not use a malformed request for the UI journey because parse-time rejection +correctly prevents it from entering the presentation queue. + +## Identifiers used + +- Screen: `PaymentRequestsScreen`. +- Request row: `PaymentRequestRow-`. +- Pay action: `PaymentRequestPay-`. +- Terminal feedback: `PaymentRequestUnavailableToast`. diff --git a/journeys/payment-requests/requested-resolution-failure.xml b/journeys/payment-requests/requested-resolution-failure.xml new file mode 100644 index 000000000..ec091fb2c --- /dev/null +++ b/journeys/payment-requests/requested-resolution-failure.xml @@ -0,0 +1,21 @@ + + + Verifies a user explicitly opening an incoming Payment Request receives localized terminal + feedback after resolution retries exhaust, while the request remains available for another + attempt. + + Precondition: onboarded dev wallet with Paykit UI enabled, a profile, and one linked saved + contact. Seed exactly one proposed incoming Payment Request from that contact with a known + payment-request id and a supported accepted endpoint identifier, while the controlled Paykit + peer returns no matching endpoint for at least 35 seconds. Start on Payment Requests (id + "PaymentRequestsScreen"). + + + Verify the incoming request row (id "PaymentRequestRow-<payment-request-id>") is visible + Tap Pay (id "PaymentRequestPay-<payment-request-id>") + Wait up to 35 seconds for the terminal error toast (id "PaymentRequestUnavailableToast") + Verify the toast title is "Payment Request" and its description is "The payment request is no longer available." + Verify Payment Requests (id "PaymentRequestsScreen") remains visible + Verify the incoming request row (id "PaymentRequestRow-<payment-request-id>") remains visible for a later retry + + From eb7afd14a63449decb60cc11684497cf19ef1d8d Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 2 Sep 2026 02:17:49 +0200 Subject: [PATCH 02/13] chore: rename changelog fragment --- changelog.d/next/{714.fixed.md => 721.fixed.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename changelog.d/next/{714.fixed.md => 721.fixed.md} (100%) diff --git a/changelog.d/next/714.fixed.md b/changelog.d/next/721.fixed.md similarity index 100% rename from changelog.d/next/714.fixed.md rename to changelog.d/next/721.fixed.md From 8547b336de866e28951d146148eb243f1aa924f9 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 2 Sep 2026 03:00:33 +0200 Subject: [PATCH 03/13] fix: distinguish expired payment requests (#714) --- Bitkit/AppScene.swift | 33 ++++++++++++---- .../PaykitPaymentRequestService.swift | 15 ++++++- Bitkit/Services/PublicPaykitService.swift | 3 +- .../PaykitPaymentRequestServiceTests.swift | 39 +++++++++++++++++++ BitkitTests/PublicPaykitServiceTests.swift | 10 +++++ 5 files changed, 89 insertions(+), 11 deletions(-) diff --git a/Bitkit/AppScene.swift b/Bitkit/AppScene.swift index 6d74a41d4..5235b2b05 100644 --- a/Bitkit/AppScene.swift +++ b/Bitkit/AppScene.swift @@ -935,19 +935,36 @@ struct AppScene: View { _ request: PaykitPaymentRequest, reason: IncomingPaykitPaymentRequestFailureReason ) { + let deferral = paykitPaymentRequestManager.deferPresentation(request) + let diagnosticReason: IncomingPaykitPaymentRequestFailureReason = if case .requestExpired = deferral { + .requestExpired + } else { + reason + } Logger.warn( - "Rejected incoming Paykit payment request presentation: category=\(reason.category) reason=\(reason.rawValue) " + + "Rejected incoming Paykit payment request presentation: category=\(diagnosticReason.category) reason=\(diagnosticReason.rawValue) " + "counterparty=\(PaykitPaymentRequestDiagnostics.redactedCounterparty(request.counterparty))", context: "AppScene" ) - guard paykitPaymentRequestManager.deferPresentation(request) == .requestedPresentationEnded else { return } - app.toast( - type: .error, - title: t("wallet__payment_request"), - description: t("wallet__payment_request_unavailable"), - accessibilityIdentifier: "PaymentRequestUnavailableToast" - ) + switch deferral { + case .requestedPresentationEnded: + app.toast( + type: .error, + title: t("wallet__payment_request"), + description: t("wallet__payment_request_unavailable"), + accessibilityIdentifier: "PaymentRequestUnavailableToast" + ) + case let .requestExpired(wasRequested) where wasRequested: + app.toast( + type: .error, + title: t("wallet__payment_request"), + description: t("wallet__payment_request_expired"), + accessibilityIdentifier: "PaymentRequestExpiredToast" + ) + case .requestExpired, .retryScheduled, .ignored: + break + } } private func retryPendingPaykitEndpointRemoval() async { diff --git a/Bitkit/Services/PaykitPaymentRequestService.swift b/Bitkit/Services/PaykitPaymentRequestService.swift index de85583fa..0aee0106b 100644 --- a/Bitkit/Services/PaykitPaymentRequestService.swift +++ b/Bitkit/Services/PaykitPaymentRequestService.swift @@ -4,6 +4,7 @@ import Paykit struct PaykitPaymentRequest: Identifiable, Hashable { enum ParseFailure: String, Error, Equatable { case missingLocalRole = "missing_local_role" + case outgoingRequest = "outgoing_request" case unsupportedLocalRole = "unsupported_local_role" case nonActionableState = "non_actionable_state" case missingTerms = "missing_terms" @@ -16,7 +17,7 @@ struct PaykitPaymentRequest: Identifiable, Hashable { case expired var shouldLogIncomingRejection: Bool { - self != .unsupportedLocalRole && self != .nonActionableState + self != .outgoingRequest && self != .nonActionableState } } @@ -89,7 +90,12 @@ struct PaykitPaymentRequest: Identifiable, Hashable { requiresActionableRequest: Bool ) -> Result { guard let localRole = record.localRole else { return .failure(.missingLocalRole) } - guard localRole == expectedRole else { return .failure(.unsupportedLocalRole) } + guard localRole == expectedRole else { + if expectedRole == .payer, localRole == .payee { + return .failure(.outgoingRequest) + } + return .failure(.unsupportedLocalRole) + } if requiresActionableRequest, record.state != .proposed { return .failure(.nonActionableState) @@ -592,6 +598,7 @@ protocol PaykitPaymentRequestPresentationStoring { enum PaykitPaymentRequestPresentationDeferral: Equatable { case retryScheduled case requestedPresentationEnded + case requestExpired(wasRequested: Bool) case ignored } @@ -910,7 +917,11 @@ final class PaykitPaymentRequestManager { @discardableResult func deferPresentation(_ request: PaykitPaymentRequest) -> PaykitPaymentRequestPresentationDeferral { let wasRequestedPresentation = requestedPresentationId == request.id + let requestExpired = request.isExpired(at: now()) discardExpiredRequests() + if requestExpired { + return .requestExpired(wasRequested: wasRequestedPresentation) + } guard pendingRequests.contains(where: { $0.id == request.id }) else { return wasRequestedPresentation ? .requestedPresentationEnded : .ignored } diff --git a/Bitkit/Services/PublicPaykitService.swift b/Bitkit/Services/PublicPaykitService.swift index 83ff2b61a..ec235bf0d 100644 --- a/Bitkit/Services/PublicPaykitService.swift +++ b/Bitkit/Services/PublicPaykitService.swift @@ -37,13 +37,14 @@ enum IncomingPaykitPaymentRequestFailureReason: String, Equatable { case paymentDetailsPending = "payment_details_pending" case invalidPaymentTarget = "invalid_payment_target" case paymentTargetNotRoutable = "payment_target_not_routable" + case requestExpired = "request_expired" case resolutionFailed = "resolution_failed" var category: String { switch self { case .noSupportedEndpoint, .endpointNotPayable, .paymentDetailsPending, .resolutionFailed: "resolution" - case .invalidPaymentTarget, .paymentTargetNotRoutable: + case .invalidPaymentTarget, .paymentTargetNotRoutable, .requestExpired: "presentation" } } diff --git a/BitkitTests/PaykitPaymentRequestServiceTests.swift b/BitkitTests/PaykitPaymentRequestServiceTests.swift index b505b402b..eaa2c1b84 100644 --- a/BitkitTests/PaykitPaymentRequestServiceTests.swift +++ b/BitkitTests/PaykitPaymentRequestServiceTests.swift @@ -71,6 +71,9 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { func testIncomingParseFailuresAreReasonSpecific() throws { let now = Date(timeIntervalSince1970: 1_800_000_000) let cases: [(PaymentRequestRecord, PaykitPaymentRequest.ParseFailure)] = try [ + (paymentRequestRecord(id: "missing-role", role: nil), .missingLocalRole), + (paymentRequestRecord(id: "outgoing", role: .payee), .outgoingRequest), + (paymentRequestRecord(id: "unknown-role", role: .unknown), .unsupportedLocalRole), (paymentRequestRecord(id: "missing-terms"), .missingTerms), (paymentRequestRecord(id: "wrong-asset", asset: "BTC"), .unsupportedAsset), (paymentRequestRecord(id: "invalid-amount", amount: "not-bitcoin"), .invalidAmount), @@ -98,8 +101,20 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { func testSynchronizeLogsRedactedParseFailuresWithoutRequestData() async throws { let counterparty = "pubky\(String(repeating: "y", count: 52))" + let outgoingCounterparty = "pubky\(String(repeating: "p", count: 52))" + let unknownRoleCounterparty = "pubky\(String(repeating: "u", count: 52))" let secretNote = "do-not-log-this-note" let records = try [ + paymentRequestRecord( + id: "do-not-log-outgoing-id", + counterparty: outgoingCounterparty, + role: .payee + ), + paymentRequestRecord( + id: "do-not-log-unknown-role-id", + counterparty: unknownRoleCounterparty, + role: .unknown + ), paymentRequestRecord( id: "do-not-log-this-id", counterparty: counterparty, @@ -129,11 +144,16 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { let output = recorder.messages.joined(separator: "\n") XCTAssertTrue(output.contains("category=parse reason=unsupported_asset")) XCTAssertTrue(output.contains("category=parse reason=no_supported_endpoint")) + XCTAssertTrue(output.contains("category=parse reason=unsupported_local_role")) XCTAssertTrue(output.contains("counterparty=\(PaykitPaymentRequestDiagnostics.redactedCounterparty(counterparty))")) + XCTAssertTrue(output.contains("counterparty=\(PaykitPaymentRequestDiagnostics.redactedCounterparty(unknownRoleCounterparty))")) + XCTAssertFalse(output.contains(PaykitPaymentRequestDiagnostics.redactedCounterparty(outgoingCounterparty))) XCTAssertTrue(output.contains("counterparty=")) XCTAssertFalse(output.contains(counterparty)) XCTAssertFalse(output.contains("do-not-log-this-id")) XCTAssertFalse(output.contains("do-not-log-this-endpoint-id")) + XCTAssertFalse(output.contains("do-not-log-outgoing-id")) + XCTAssertFalse(output.contains("do-not-log-unknown-role-id")) XCTAssertFalse(output.contains(secretNote)) XCTAssertFalse(output.contains("btc-private-unsupported-endpoint")) XCTAssertFalse(output.contains("do-not-log-invalid-counterparty")) @@ -360,6 +380,25 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { XCTAssertEqual(manager.pendingRequests, [request]) } + func testRequestedDeferredRequestReportsExpirationInsteadOfRetryExhaustion() async throws { + let now = Date(timeIntervalSince1970: 1_800_000_000) + let clock = PaymentRequestTestClock(now) + let sdk = try PaymentRequestSdkMock(records: [ + paymentRequestRecord(expiresAt: timestamp(now.addingTimeInterval(1))), + ]) + let manager = paymentRequestManager(sdk: sdk, clock: clock) + await manager.refresh() + let request = try XCTUnwrap(manager.pendingRequests.first) + XCTAssertTrue(manager.requestPresentation(request)) + + clock.advance(by: 1) + + XCTAssertEqual(manager.deferPresentation(request), .requestExpired(wasRequested: true)) + XCTAssertTrue(manager.requestsForPresentation().isEmpty) + XCTAssertNil(manager.requestedPresentationId) + XCTAssertTrue(manager.pendingRequests.isEmpty) + } + func testPreparationConsumesBeforeAccepting() async throws { let sdk = try PaymentRequestSdkMock(records: [paymentRequestRecord()]) let manager = paymentRequestManager(sdk: sdk) diff --git a/BitkitTests/PublicPaykitServiceTests.swift b/BitkitTests/PublicPaykitServiceTests.swift index d83103c46..02c462be9 100644 --- a/BitkitTests/PublicPaykitServiceTests.swift +++ b/BitkitTests/PublicPaykitServiceTests.swift @@ -181,6 +181,16 @@ final class PublicPaykitServiceTests: XCTestCase { ) } + func testIncomingRequestFailureReasonsHaveStableCategories() { + XCTAssertEqual(IncomingPaykitPaymentRequestFailureReason.noSupportedEndpoint.category, "resolution") + XCTAssertEqual(IncomingPaykitPaymentRequestFailureReason.endpointNotPayable.category, "resolution") + XCTAssertEqual(IncomingPaykitPaymentRequestFailureReason.paymentDetailsPending.category, "resolution") + XCTAssertEqual(IncomingPaykitPaymentRequestFailureReason.resolutionFailed.category, "resolution") + XCTAssertEqual(IncomingPaykitPaymentRequestFailureReason.invalidPaymentTarget.category, "presentation") + XCTAssertEqual(IncomingPaykitPaymentRequestFailureReason.paymentTargetNotRoutable.category, "presentation") + XCTAssertEqual(IncomingPaykitPaymentRequestFailureReason.requestExpired.category, "presentation") + } + func testPayableEndpointsFiltersInvalidDecodedEndpoints() async { let payable = await PublicPaykitService.payableEndpoints(from: [ endpoint(.bitcoinLightningBolt11, value: "not-a-bolt11"), From a79e2226ea5f9a61b14846bfe75aef8f8fcb48be Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 2 Sep 2026 03:11:35 +0200 Subject: [PATCH 04/13] test: cover expired payment request feedback (#714) --- Bitkit/AppScene.swift | 73 +++++++++++++++------- BitkitTests/PublicPaykitServiceTests.swift | 13 ++++ journeys/payment-requests/README.md | 16 +++-- 3 files changed, 73 insertions(+), 29 deletions(-) diff --git a/Bitkit/AppScene.swift b/Bitkit/AppScene.swift index 5235b2b05..eb8104aee 100644 --- a/Bitkit/AppScene.swift +++ b/Bitkit/AppScene.swift @@ -3,6 +3,42 @@ import LDKNode import SwiftUI import UserNotifications +struct IncomingPaykitPaymentRequestPresentationFeedback: Equatable { + struct Toast: Equatable { + let titleKey: String + let descriptionKey: String + let accessibilityIdentifier: String + } + + let diagnosticReason: IncomingPaykitPaymentRequestFailureReason + let toast: Toast? + + init( + deferral: PaykitPaymentRequestPresentationDeferral, + fallbackReason: IncomingPaykitPaymentRequestFailureReason + ) { + switch deferral { + case .requestedPresentationEnded: + diagnosticReason = fallbackReason + toast = Toast( + titleKey: "wallet__payment_request", + descriptionKey: "wallet__payment_request_unavailable", + accessibilityIdentifier: "PaymentRequestUnavailableToast" + ) + case let .requestExpired(wasRequested): + diagnosticReason = .requestExpired + toast = wasRequested ? Toast( + titleKey: "wallet__payment_request", + descriptionKey: "wallet__payment_request_expired", + accessibilityIdentifier: "PaymentRequestExpiredToast" + ) : nil + case .retryScheduled, .ignored: + diagnosticReason = fallbackReason + toast = nil + } + } +} + struct AppScene: View { private static let paykitPaymentRequestRefreshIntervals: [Duration] = [.seconds(30), .seconds(60), .seconds(120)] private static let initialPaykitSyncRetryDelays = Array(repeating: Duration.seconds(2), count: 14) @@ -936,35 +972,24 @@ struct AppScene: View { reason: IncomingPaykitPaymentRequestFailureReason ) { let deferral = paykitPaymentRequestManager.deferPresentation(request) - let diagnosticReason: IncomingPaykitPaymentRequestFailureReason = if case .requestExpired = deferral { - .requestExpired - } else { - reason - } + let feedback = IncomingPaykitPaymentRequestPresentationFeedback( + deferral: deferral, + fallbackReason: reason + ) Logger.warn( - "Rejected incoming Paykit payment request presentation: category=\(diagnosticReason.category) reason=\(diagnosticReason.rawValue) " + + "Rejected incoming Paykit payment request presentation: category=\(feedback.diagnosticReason.category) " + + "reason=\(feedback.diagnosticReason.rawValue) " + "counterparty=\(PaykitPaymentRequestDiagnostics.redactedCounterparty(request.counterparty))", context: "AppScene" ) - switch deferral { - case .requestedPresentationEnded: - app.toast( - type: .error, - title: t("wallet__payment_request"), - description: t("wallet__payment_request_unavailable"), - accessibilityIdentifier: "PaymentRequestUnavailableToast" - ) - case let .requestExpired(wasRequested) where wasRequested: - app.toast( - type: .error, - title: t("wallet__payment_request"), - description: t("wallet__payment_request_expired"), - accessibilityIdentifier: "PaymentRequestExpiredToast" - ) - case .requestExpired, .retryScheduled, .ignored: - break - } + guard let toast = feedback.toast else { return } + app.toast( + type: .error, + title: t(toast.titleKey), + description: t(toast.descriptionKey), + accessibilityIdentifier: toast.accessibilityIdentifier + ) } private func retryPendingPaykitEndpointRemoval() async { diff --git a/BitkitTests/PublicPaykitServiceTests.swift b/BitkitTests/PublicPaykitServiceTests.swift index 02c462be9..54c090585 100644 --- a/BitkitTests/PublicPaykitServiceTests.swift +++ b/BitkitTests/PublicPaykitServiceTests.swift @@ -191,6 +191,19 @@ final class PublicPaykitServiceTests: XCTestCase { XCTAssertEqual(IncomingPaykitPaymentRequestFailureReason.requestExpired.category, "presentation") } + func testAppSceneFeedbackMapsRequestedExpirationToExpiredDiagnosticsAndToast() { + let feedback = IncomingPaykitPaymentRequestPresentationFeedback( + deferral: .requestExpired(wasRequested: true), + fallbackReason: .resolutionFailed + ) + + XCTAssertEqual(feedback.diagnosticReason, .requestExpired) + XCTAssertEqual(feedback.diagnosticReason.rawValue, "request_expired") + XCTAssertEqual(feedback.toast?.titleKey, "wallet__payment_request") + XCTAssertEqual(feedback.toast?.descriptionKey, "wallet__payment_request_expired") + XCTAssertEqual(feedback.toast?.accessibilityIdentifier, "PaymentRequestExpiredToast") + } + func testPayableEndpointsFiltersInvalidDecodedEndpoints() async { let payable = await PublicPaykitService.payableEndpoints(from: [ endpoint(.bitcoinLightningBolt11, value: "not-a-bolt11"), diff --git a/journeys/payment-requests/README.md b/journeys/payment-requests/README.md index ef728420f..2da91b8cb 100644 --- a/journeys/payment-requests/README.md +++ b/journeys/payment-requests/README.md @@ -12,19 +12,24 @@ These journeys cover incoming Paykit Payment Requests that Bitkit can receive bu - An explicit Pay action tries immediately and fourteen more times at two-second intervals. After the fifteenth failure, Bitkit shows an error toast with localized keys `wallet__payment_request` and `wallet__payment_request_unavailable`, then leaves the request available for another attempt. +- If the request expires during an explicit presentation attempt, Bitkit logs + `category=presentation reason=request_expired` and shows `PaymentRequestExpiredToast` with the + localized `wallet__payment_request_expired` message. - Automatic presentation uses the same initial retries, then continues every 120 seconds without showing terminal feedback. The failure reason vocabulary is: -- Parse: `missing_local_role`, `missing_terms`, `recurring_request`, `unsupported_asset`, - `invalid_amount`, `amount_out_of_range`, `no_supported_endpoint`, `invalid_expiration`, `expired`. +- Parse: `missing_local_role`, `outgoing_request`, `unsupported_local_role`, `missing_terms`, + `recurring_request`, `unsupported_asset`, `invalid_amount`, `amount_out_of_range`, + `no_supported_endpoint`, `invalid_expiration`, `expired`. - Resolution: `no_supported_endpoint`, `endpoint_not_payable`, `payment_details_pending`, `resolution_failed`. -- Presentation: `invalid_payment_target`, `payment_target_not_routable`. +- Presentation: `invalid_payment_target`, `payment_target_not_routable`, `request_expired`. -`unsupported_local_role` and `non_actionable_state` are expected filtering of outgoing or completed -records, so they do not emit incoming-rejection warnings. +`outgoing_request` and `non_actionable_state` are expected filtering of outgoing or completed +records, so they do not emit incoming-rejection warnings. `unsupported_local_role` identifies an +unknown role and emits a privacy-safe warning with only the redacted counterparty. ## Mandatory setup @@ -40,3 +45,4 @@ correctly prevents it from entering the presentation queue. - Request row: `PaymentRequestRow-`. - Pay action: `PaymentRequestPay-`. - Terminal feedback: `PaymentRequestUnavailableToast`. +- Expiration feedback: `PaymentRequestExpiredToast`. From 6235b9e37cd74caa35f3867fb51910d44207d64e Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 2 Sep 2026 03:43:10 +0200 Subject: [PATCH 05/13] fix: preserve requested expiry feedback (#714) --- Bitkit/AppScene.swift | 53 ++++++++++++++-- .../PaykitPaymentRequestService.swift | 44 ++++++++++++-- .../PaykitPaymentRequestServiceTests.swift | 60 +++++++++++++++++++ 3 files changed, 146 insertions(+), 11 deletions(-) diff --git a/Bitkit/AppScene.swift b/Bitkit/AppScene.swift index eb8104aee..8c102afc6 100644 --- a/Bitkit/AppScene.swift +++ b/Bitkit/AppScene.swift @@ -39,6 +39,12 @@ struct IncomingPaykitPaymentRequestPresentationFeedback: Equatable { } } +private struct IncomingPaykitPaymentRequestPresentationState: Equatable { + let requestedPresentationId: PaykitPaymentRequest.ID? + let retryTrigger: Int + let expirationTrigger: Int +} + struct AppScene: View { private static let paykitPaymentRequestRefreshIntervals: [Duration] = [.seconds(30), .seconds(60), .seconds(120)] private static let initialPaykitSyncRetryDelays = Array(repeating: Duration.seconds(2), count: 14) @@ -285,12 +291,8 @@ struct AppScene: View { await presentNextIncomingPaykitPaymentRequest() } } - .onChange(of: paykitPaymentRequestManager.requestedPresentationId) { _, requestId in - guard requestId != nil else { return } - Task { await presentNextIncomingPaykitPaymentRequest() } - } - .onChange(of: paykitPaymentRequestManager.presentationRetryTrigger) { - Task { await presentNextIncomingPaykitPaymentRequest() } + .onChange(of: incomingPaykitPaymentRequestPresentationState) { previous, current in + handleIncomingPaykitPaymentRequestPresentationStateChange(from: previous, to: current) } .onChange(of: paykitPaymentRequestManager.pendingRequests) { _, requests in guard let request = app.contactPaymentContext?.incomingPaymentRequest, @@ -976,6 +978,45 @@ struct AppScene: View { deferral: deferral, fallbackReason: reason ) + presentIncomingPaykitPaymentRequestFeedback(feedback, for: request) + } + + private func presentExpiredRequestedPaykitPaymentRequests() { + while let request = paykitPaymentRequestManager.consumeExpiredRequestedPresentation() { + let feedback = IncomingPaykitPaymentRequestPresentationFeedback( + deferral: .requestExpired(wasRequested: true), + fallbackReason: .resolutionFailed + ) + presentIncomingPaykitPaymentRequestFeedback(feedback, for: request) + } + } + + private var incomingPaykitPaymentRequestPresentationState: IncomingPaykitPaymentRequestPresentationState { + IncomingPaykitPaymentRequestPresentationState( + requestedPresentationId: paykitPaymentRequestManager.requestedPresentationId, + retryTrigger: paykitPaymentRequestManager.presentationRetryTrigger, + expirationTrigger: paykitPaymentRequestManager.requestedPresentationExpirationTrigger + ) + } + + private func handleIncomingPaykitPaymentRequestPresentationStateChange( + from previous: IncomingPaykitPaymentRequestPresentationState, + to current: IncomingPaykitPaymentRequestPresentationState + ) { + if current.expirationTrigger != previous.expirationTrigger { + presentExpiredRequestedPaykitPaymentRequests() + } + if current.retryTrigger != previous.retryTrigger || + previous.requestedPresentationId != current.requestedPresentationId && current.requestedPresentationId != nil + { + Task { await presentNextIncomingPaykitPaymentRequest() } + } + } + + private func presentIncomingPaykitPaymentRequestFeedback( + _ feedback: IncomingPaykitPaymentRequestPresentationFeedback, + for request: PaykitPaymentRequest + ) { Logger.warn( "Rejected incoming Paykit payment request presentation: category=\(feedback.diagnosticReason.category) " + "reason=\(feedback.diagnosticReason.rawValue) " + diff --git a/Bitkit/Services/PaykitPaymentRequestService.swift b/Bitkit/Services/PaykitPaymentRequestService.swift index 0aee0106b..d69aedcb0 100644 --- a/Bitkit/Services/PaykitPaymentRequestService.swift +++ b/Bitkit/Services/PaykitPaymentRequestService.swift @@ -638,6 +638,7 @@ final class PaykitPaymentRequestManager { private(set) var requestedPresentationId: PaykitPaymentRequest.ID? private(set) var isCreatingRequest = false private(set) var presentationRetryTrigger = 0 + private(set) var requestedPresentationExpirationTrigger = 0 private let service: PaykitPaymentRequestService private let presentationStore: any PaykitPaymentRequestPresentationStoring @@ -649,6 +650,7 @@ final class PaykitPaymentRequestManager { private var presentedRequestIds: Set = [] private var presentationRetryAttempts: [PaykitPaymentRequest.ID: Int] = [:] private var presentationRetryDates: [PaykitPaymentRequest.ID: Date] = [:] + private var expiredRequestedPresentations: [PaykitPaymentRequest] = [] private var isPresentingRequests = false private var refreshTask: Task? private var expirationTask: Task? @@ -858,6 +860,7 @@ final class PaykitPaymentRequestManager { persistedPresentedRequestIds = [] presentationRetryAttempts = [:] presentationRetryDates = [:] + expiredRequestedPresentations = [] requestedPresentationId = nil isCreatingRequest = false } @@ -914,11 +917,22 @@ final class PaykitPaymentRequestManager { approvedPaymentRequestIds.remove(request.id) } + func consumeExpiredRequestedPresentation() -> PaykitPaymentRequest? { + guard !expiredRequestedPresentations.isEmpty else { return nil } + return expiredRequestedPresentations.removeFirst() + } + + func reconcileExpiredRequests() { + discardExpiredRequests() + } + @discardableResult func deferPresentation(_ request: PaykitPaymentRequest) -> PaykitPaymentRequestPresentationDeferral { let wasRequestedPresentation = requestedPresentationId == request.id let requestExpired = request.isExpired(at: now()) - discardExpiredRequests() + discardExpiredRequests( + handledRequestedExpirationId: requestExpired && wasRequestedPresentation ? request.id : nil + ) if requestExpired { return .requestExpired(wasRequested: wasRequestedPresentation) } @@ -974,6 +988,7 @@ final class PaykitPaymentRequestManager { do { let snapshot = try await service.synchronize() guard generation == refreshGeneration else { return } + let handledRequestedExpirationId = recordRequestedPresentationExpiration(at: now()) let protectedRequests = pendingRequests.filter { processingRequestIds.contains($0.id) && $0.id != excludingProtectedRequestId } @@ -993,7 +1008,7 @@ final class PaykitPaymentRequestManager { requestedPresentationId = nil } persistPresentedRequestIds() - discardExpiredRequests() + discardExpiredRequests(handledRequestedExpirationId: handledRequestedExpirationId) schedulePresentationRetry() } catch is CancellationError { return @@ -1064,8 +1079,10 @@ final class PaykitPaymentRequestManager { refreshTask = nil } - private func discardExpiredRequests() { - pendingRequests.removeAll { $0.isExpired(at: now()) } + private func discardExpiredRequests(handledRequestedExpirationId: PaykitPaymentRequest.ID? = nil) { + let date = now() + recordRequestedPresentationExpiration(at: date, excluding: handledRequestedExpirationId) + pendingRequests.removeAll { $0.isExpired(at: date) } let requestIds = Set(pendingRequests.map(\.id)) presentedRequestIds.formIntersection(requestIds) presentationRetryAttempts = presentationRetryAttempts.filter { requestIds.contains($0.key) } @@ -1079,6 +1096,23 @@ final class PaykitPaymentRequestManager { schedulePresentationRetry() } + @discardableResult + private func recordRequestedPresentationExpiration( + at date: Date, + excluding handledRequestedExpirationId: PaykitPaymentRequest.ID? = nil + ) -> PaykitPaymentRequest.ID? { + guard let requestedPresentationId, + requestedPresentationId != handledRequestedExpirationId, + let expiredRequest = pendingRequests.first(where: { + $0.id == requestedPresentationId && $0.isExpired(at: date) + }) + else { return nil } + + expiredRequestedPresentations.append(expiredRequest) + requestedPresentationExpirationTrigger += 1 + return requestedPresentationId + } + private func schedulePresentationRetry() { presentationRetryTask?.cancel() presentationRetryTask = nil @@ -1110,7 +1144,7 @@ final class PaykitPaymentRequestManager { return } guard !Task.isCancelled else { return } - self?.discardExpiredRequests() + self?.reconcileExpiredRequests() } } diff --git a/BitkitTests/PaykitPaymentRequestServiceTests.swift b/BitkitTests/PaykitPaymentRequestServiceTests.swift index eaa2c1b84..20b4ece4f 100644 --- a/BitkitTests/PaykitPaymentRequestServiceTests.swift +++ b/BitkitTests/PaykitPaymentRequestServiceTests.swift @@ -397,6 +397,66 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { XCTAssertTrue(manager.requestsForPresentation().isEmpty) XCTAssertNil(manager.requestedPresentationId) XCTAssertTrue(manager.pendingRequests.isEmpty) + XCTAssertEqual(manager.requestedPresentationExpirationTrigger, 0) + XCTAssertNil(manager.consumeExpiredRequestedPresentation()) + } + + func testRequestedExpirationSurvivesSuspendedResolution() 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)) + var continuation: CheckedContinuation? + + let presentationTask = Task { + await manager.presentRequests { requests in + XCTAssertEqual(requests, [request]) + await withCheckedContinuation { continuation = $0 } + } + } + try await waitUntil { continuation != nil } + XCTAssertTrue(manager.isCurrentPresentation(request)) + + clock.advance(by: 60) + manager.reconcileExpiredRequests() + + XCTAssertFalse(manager.isCurrentPresentation(request)) + XCTAssertNil(manager.requestedPresentationId) + XCTAssertTrue(manager.pendingRequests.isEmpty) + XCTAssertEqual(manager.requestedPresentationExpirationTrigger, 1) + XCTAssertEqual(manager.consumeExpiredRequestedPresentation(), request) + XCTAssertNil(manager.consumeExpiredRequestedPresentation()) + + continuation?.resume() + _ = await presentationTask.value + } + + func testRequestedExpirationSurvivesPresentationRetryBackoff() async throws { + let now = Date(timeIntervalSince1970: 1_800_000_000) + let clock = PaymentRequestTestClock(now) + let sdk = try PaymentRequestSdkMock(records: [ + paymentRequestRecord(expiresAt: timestamp(now.addingTimeInterval(1))), + ]) + let manager = paymentRequestManager(sdk: sdk, clock: clock) + await manager.refresh() + let request = try XCTUnwrap(manager.pendingRequests.first) + XCTAssertTrue(manager.requestPresentation(request)) + XCTAssertEqual(manager.deferPresentation(request), .retryScheduled) + XCTAssertTrue(manager.requestsForPresentation().isEmpty) + + clock.advance(by: 1) + manager.reconcileExpiredRequests() + + XCTAssertNil(manager.requestedPresentationId) + XCTAssertTrue(manager.pendingRequests.isEmpty) + XCTAssertEqual(manager.requestedPresentationExpirationTrigger, 1) + XCTAssertEqual(manager.consumeExpiredRequestedPresentation(), request) + XCTAssertNil(manager.consumeExpiredRequestedPresentation()) } func testPreparationConsumesBeforeAccepting() async throws { From c86bfddb5ad846ea986d15de2717d91938da056b Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 2 Sep 2026 23:43:27 +0200 Subject: [PATCH 06/13] fix: surface requested payment toasts --- Bitkit/AppScene.swift | 130 ++++++++++++++---- .../PaykitPaymentRequestService.swift | 17 ++- .../PaykitPaymentRequestServiceTests.swift | 105 ++++++++++++++ journeys/README.md | 4 +- journeys/payment-requests/README.md | 1 + 5 files changed, 227 insertions(+), 30 deletions(-) diff --git a/Bitkit/AppScene.swift b/Bitkit/AppScene.swift index 8c102afc6..36264dcc8 100644 --- a/Bitkit/AppScene.swift +++ b/Bitkit/AppScene.swift @@ -39,10 +39,92 @@ struct IncomingPaykitPaymentRequestPresentationFeedback: Equatable { } } -private struct IncomingPaykitPaymentRequestPresentationState: Equatable { +struct IncomingPaykitPaymentRequestPresentationState: Equatable { let requestedPresentationId: PaykitPaymentRequest.ID? let retryTrigger: Int let expirationTrigger: Int + let unavailableTrigger: Int + + init( + requestedPresentationId: PaykitPaymentRequest.ID?, + retryTrigger: Int, + expirationTrigger: Int, + unavailableTrigger: Int + ) { + self.requestedPresentationId = requestedPresentationId + self.retryTrigger = retryTrigger + self.expirationTrigger = expirationTrigger + self.unavailableTrigger = unavailableTrigger + } + + @MainActor + init(_ manager: PaykitPaymentRequestManager) { + self.init( + requestedPresentationId: manager.requestedPresentationId, + retryTrigger: manager.presentationRetryTrigger, + expirationTrigger: manager.requestedPresentationExpirationTrigger, + unavailableTrigger: manager.requestedPresentationUnavailableTrigger + ) + } +} + +enum IncomingPaykitPaymentRequestPresentationDispatch: Equatable { + case presentFeedback(IncomingPaykitPaymentRequestPresentationFeedback, PaykitPaymentRequest) + case presentNext +} + +@MainActor +enum IncomingPaykitPaymentRequestPresentationDispatcher { + static func feedback( + deferring request: PaykitPaymentRequest, + reason: IncomingPaykitPaymentRequestFailureReason, + with manager: PaykitPaymentRequestManager + ) -> IncomingPaykitPaymentRequestPresentationFeedback { + IncomingPaykitPaymentRequestPresentationFeedback( + deferral: manager.deferPresentation(request), + fallbackReason: reason + ) + } + + static func handleStateChange( + from previous: IncomingPaykitPaymentRequestPresentationState, + to current: IncomingPaykitPaymentRequestPresentationState, + manager: PaykitPaymentRequestManager + ) -> [IncomingPaykitPaymentRequestPresentationDispatch] { + var dispatches: [IncomingPaykitPaymentRequestPresentationDispatch] = [] + if current.expirationTrigger != previous.expirationTrigger { + while let request = manager.consumeExpiredRequestedPresentation() { + dispatches.append( + .presentFeedback( + IncomingPaykitPaymentRequestPresentationFeedback( + deferral: .requestExpired(wasRequested: true), + fallbackReason: .resolutionFailed + ), + request + ) + ) + } + } + if current.unavailableTrigger != previous.unavailableTrigger { + while let request = manager.consumeUnavailableRequestedPresentation() { + dispatches.append( + .presentFeedback( + IncomingPaykitPaymentRequestPresentationFeedback( + deferral: .requestedPresentationEnded, + fallbackReason: .resolutionFailed + ), + request + ) + ) + } + } + if current.retryTrigger != previous.retryTrigger || + previous.requestedPresentationId != current.requestedPresentationId && current.requestedPresentationId != nil + { + dispatches.append(.presentNext) + } + return dispatches + } } struct AppScene: View { @@ -973,43 +1055,35 @@ struct AppScene: View { _ request: PaykitPaymentRequest, reason: IncomingPaykitPaymentRequestFailureReason ) { - let deferral = paykitPaymentRequestManager.deferPresentation(request) - let feedback = IncomingPaykitPaymentRequestPresentationFeedback( - deferral: deferral, - fallbackReason: reason + presentIncomingPaykitPaymentRequestFeedback( + IncomingPaykitPaymentRequestPresentationDispatcher.feedback( + deferring: request, + reason: reason, + with: paykitPaymentRequestManager + ), + for: request ) - presentIncomingPaykitPaymentRequestFeedback(feedback, for: request) - } - - private func presentExpiredRequestedPaykitPaymentRequests() { - while let request = paykitPaymentRequestManager.consumeExpiredRequestedPresentation() { - let feedback = IncomingPaykitPaymentRequestPresentationFeedback( - deferral: .requestExpired(wasRequested: true), - fallbackReason: .resolutionFailed - ) - presentIncomingPaykitPaymentRequestFeedback(feedback, for: request) - } } private var incomingPaykitPaymentRequestPresentationState: IncomingPaykitPaymentRequestPresentationState { - IncomingPaykitPaymentRequestPresentationState( - requestedPresentationId: paykitPaymentRequestManager.requestedPresentationId, - retryTrigger: paykitPaymentRequestManager.presentationRetryTrigger, - expirationTrigger: paykitPaymentRequestManager.requestedPresentationExpirationTrigger - ) + IncomingPaykitPaymentRequestPresentationState(paykitPaymentRequestManager) } private func handleIncomingPaykitPaymentRequestPresentationStateChange( from previous: IncomingPaykitPaymentRequestPresentationState, to current: IncomingPaykitPaymentRequestPresentationState ) { - if current.expirationTrigger != previous.expirationTrigger { - presentExpiredRequestedPaykitPaymentRequests() - } - if current.retryTrigger != previous.retryTrigger || - previous.requestedPresentationId != current.requestedPresentationId && current.requestedPresentationId != nil - { - Task { await presentNextIncomingPaykitPaymentRequest() } + for dispatch in IncomingPaykitPaymentRequestPresentationDispatcher.handleStateChange( + from: previous, + to: current, + manager: paykitPaymentRequestManager + ) { + switch dispatch { + case let .presentFeedback(feedback, request): + presentIncomingPaykitPaymentRequestFeedback(feedback, for: request) + case .presentNext: + Task { await presentNextIncomingPaykitPaymentRequest() } + } } } diff --git a/Bitkit/Services/PaykitPaymentRequestService.swift b/Bitkit/Services/PaykitPaymentRequestService.swift index d69aedcb0..810610fa6 100644 --- a/Bitkit/Services/PaykitPaymentRequestService.swift +++ b/Bitkit/Services/PaykitPaymentRequestService.swift @@ -639,6 +639,7 @@ final class PaykitPaymentRequestManager { private(set) var isCreatingRequest = false private(set) var presentationRetryTrigger = 0 private(set) var requestedPresentationExpirationTrigger = 0 + private(set) var requestedPresentationUnavailableTrigger = 0 private let service: PaykitPaymentRequestService private let presentationStore: any PaykitPaymentRequestPresentationStoring @@ -651,6 +652,7 @@ final class PaykitPaymentRequestManager { private var presentationRetryAttempts: [PaykitPaymentRequest.ID: Int] = [:] private var presentationRetryDates: [PaykitPaymentRequest.ID: Date] = [:] private var expiredRequestedPresentations: [PaykitPaymentRequest] = [] + private var unavailableRequestedPresentations: [PaykitPaymentRequest] = [] private var isPresentingRequests = false private var refreshTask: Task? private var expirationTask: Task? @@ -861,6 +863,7 @@ final class PaykitPaymentRequestManager { presentationRetryAttempts = [:] presentationRetryDates = [:] expiredRequestedPresentations = [] + unavailableRequestedPresentations = [] requestedPresentationId = nil isCreatingRequest = false } @@ -922,6 +925,11 @@ final class PaykitPaymentRequestManager { return expiredRequestedPresentations.removeFirst() } + func consumeUnavailableRequestedPresentation() -> PaykitPaymentRequest? { + guard !unavailableRequestedPresentations.isEmpty else { return nil } + return unavailableRequestedPresentations.removeFirst() + } + func reconcileExpiredRequests() { discardExpiredRequests() } @@ -989,6 +997,7 @@ final class PaykitPaymentRequestManager { let snapshot = try await service.synchronize() guard generation == refreshGeneration else { return } let handledRequestedExpirationId = recordRequestedPresentationExpiration(at: now()) + let previousPending = pendingRequests let protectedRequests = pendingRequests.filter { processingRequestIds.contains($0.id) && $0.id != excludingProtectedRequestId } @@ -1003,8 +1012,14 @@ final class PaykitPaymentRequestManager { presentedRequestIds.formIntersection(requestIds) presentationRetryAttempts = presentationRetryAttempts.filter { requestIds.contains($0.key) } presentationRetryDates = presentationRetryDates.filter { requestIds.contains($0.key) } - if requestedPresentationId.map({ !requestIds.contains($0) }) == true { + if let requestedId = requestedPresentationId, !requestIds.contains(requestedId) { presentationGeneration += 1 + if requestedId != handledRequestedExpirationId, + let request = previousPending.first(where: { $0.id == requestedId }) + { + unavailableRequestedPresentations.append(request) + requestedPresentationUnavailableTrigger += 1 + } requestedPresentationId = nil } persistPresentedRequestIds() diff --git a/BitkitTests/PaykitPaymentRequestServiceTests.swift b/BitkitTests/PaykitPaymentRequestServiceTests.swift index 20b4ece4f..384074e37 100644 --- a/BitkitTests/PaykitPaymentRequestServiceTests.swift +++ b/BitkitTests/PaykitPaymentRequestServiceTests.swift @@ -459,6 +459,111 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { XCTAssertNil(manager.consumeExpiredRequestedPresentation()) } + func testPresentationDispatcherSurfacesExpirationAndRetryExhaustionToasts() async throws { + let now = Date(timeIntervalSince1970: 1_800_000_000) + let clock = PaymentRequestTestClock(now) + let expiredSdk = try PaymentRequestSdkMock(records: [ + paymentRequestRecord(id: "expired-request", expiresAt: timestamp(now.addingTimeInterval(60))), + ]) + let expiredManager = paymentRequestManager(sdk: expiredSdk, clock: clock) + await expiredManager.refresh() + let expiredRequest = try XCTUnwrap(expiredManager.pendingRequests.first) + XCTAssertTrue(expiredManager.requestPresentation(expiredRequest)) + + let previousExpirationState = IncomingPaykitPaymentRequestPresentationState(expiredManager) + clock.advance(by: 60) + expiredManager.reconcileExpiredRequests() + let expiredDispatches = IncomingPaykitPaymentRequestPresentationDispatcher.handleStateChange( + from: previousExpirationState, + to: IncomingPaykitPaymentRequestPresentationState(expiredManager), + manager: expiredManager + ) + + XCTAssertEqual( + expiredDispatches, + [ + .presentFeedback( + IncomingPaykitPaymentRequestPresentationFeedback( + deferral: .requestExpired(wasRequested: true), + fallbackReason: .resolutionFailed + ), + expiredRequest + ), + ] + ) + XCTAssertNil(expiredManager.consumeExpiredRequestedPresentation()) + + let retrySdk = try PaymentRequestSdkMock(records: [paymentRequestRecord()]) + let retryManager = paymentRequestManager(sdk: retrySdk, clock: clock) + await retryManager.refresh() + let retryRequest = try XCTUnwrap(retryManager.pendingRequests.first) + XCTAssertTrue(retryManager.requestPresentation(retryRequest)) + + for _ in 0 ..< 14 { + XCTAssertEqual(retryManager.deferPresentation(retryRequest), .retryScheduled) + clock.advance(by: 2) + } + + let exhaustedFeedback = IncomingPaykitPaymentRequestPresentationDispatcher.feedback( + deferring: retryRequest, + reason: .resolutionFailed, + with: retryManager + ) + XCTAssertEqual(exhaustedFeedback.diagnosticReason, .resolutionFailed) + XCTAssertEqual(exhaustedFeedback.toast?.titleKey, "wallet__payment_request") + XCTAssertEqual(exhaustedFeedback.toast?.descriptionKey, "wallet__payment_request_unavailable") + XCTAssertEqual(exhaustedFeedback.toast?.accessibilityIdentifier, "PaymentRequestUnavailableToast") + } + + func testRefreshPreservesUnavailableOutcomeForRequestedPresentation() async throws { + let sdk = try PaymentRequestSdkMock(records: [paymentRequestRecord()]) + let manager = paymentRequestManager(sdk: sdk) + await manager.refresh() + let request = try XCTUnwrap(manager.pendingRequests.first) + XCTAssertTrue(manager.requestPresentation(request)) + + var continuation: CheckedContinuation? + let presentationTask = Task { + await manager.presentRequests { requests in + XCTAssertEqual(requests, [request]) + await withCheckedContinuation { continuation = $0 } + } + } + try await waitUntil { continuation != nil } + XCTAssertTrue(manager.isCurrentPresentation(request)) + + let previous = IncomingPaykitPaymentRequestPresentationState(manager) + await sdk.setRecords([]) + await manager.refresh() + + XCTAssertFalse(manager.isCurrentPresentation(request)) + XCTAssertNil(manager.requestedPresentationId) + XCTAssertTrue(manager.pendingRequests.isEmpty) + XCTAssertEqual(manager.requestedPresentationUnavailableTrigger, 1) + + let dispatches = IncomingPaykitPaymentRequestPresentationDispatcher.handleStateChange( + from: previous, + to: IncomingPaykitPaymentRequestPresentationState(manager), + manager: manager + ) + XCTAssertEqual( + dispatches, + [ + .presentFeedback( + IncomingPaykitPaymentRequestPresentationFeedback( + deferral: .requestedPresentationEnded, + fallbackReason: .resolutionFailed + ), + request + ), + ] + ) + XCTAssertNil(manager.consumeUnavailableRequestedPresentation()) + + continuation?.resume() + _ = await presentationTask.value + } + func testPreparationConsumesBeforeAccepting() async throws { let sdk = try PaymentRequestSdkMock(records: [paymentRequestRecord()]) let manager = paymentRequestManager(sdk: sdk) diff --git a/journeys/README.md b/journeys/README.md index 905af7d60..f4f18ce0f 100644 --- a/journeys/README.md +++ b/journeys/README.md @@ -4,10 +4,11 @@ A journey is an XML-specified walkthrough of app behaviour, evaluated by an agen simulator. They are developer-assistance specs: they give an agent a reliable route through a flow so it can reproduce a bug, check a change by hand, or show you what a screen does today. -These are ported from [`bitkit-android/journeys`](https://github.com/synonymdev/bitkit-android/tree/main/journeys) +Most suites are ported from [`bitkit-android/journeys`](https://github.com/synonymdev/bitkit-android/tree/main/journeys) and deliberately keep the same file names, journey names and `` prose so the two platforms stay diffable. Only the platform mechanics differ — `adb` becomes `xcodebuildmcp`, and Android `testTag`s become iOS `accessibilityIdentifier`s (the vocabulary is shared; see [Identifiers](#identifiers)). +iOS-only suites are marked in the [Suites](#suites) table. **Journeys are not a QA gate.** They are agent-evaluated and non-deterministic, nothing runs them in CI, and there is no runner wired up for them yet — `ai-device-tests.yml` runs `TrezorBridgeDashboardUITests` @@ -135,6 +136,7 @@ Everything else — `N0`–`N9`, `N000`, `NDecimal`, `NRemove`, `SpendingAmount* | [notification-permission](notification-permission) | 4 | Background-setup toggles | | [cjit-notifications](cjit-notifications) | 3 | Adapted — iOS notification copy differs from Android | | [hardware-wallet](hardware-wallet) | 15 | Trezor over Bridge; see `Docs/AI_DEVICE_TESTS.md` | +| [payment-requests](payment-requests) | 1 | iOS-only — no Android companion | ## Not ported diff --git a/journeys/payment-requests/README.md b/journeys/payment-requests/README.md index 2da91b8cb..2a98415ca 100644 --- a/journeys/payment-requests/README.md +++ b/journeys/payment-requests/README.md @@ -1,6 +1,7 @@ # Payment-request journeys These journeys cover incoming Paykit Payment Requests that Bitkit can receive but cannot open. +This suite is iOS-only; there is no Android companion under `bitkit-android/journeys`. ## Failure contract From 264beb9548586c19d1bda25c396979000e9aa920 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 3 Sep 2026 23:50:41 +0200 Subject: [PATCH 07/13] fix: share expiry clock and present next --- Bitkit/AppScene.swift | 3 +- .../PaykitPaymentRequestService.swift | 11 +++-- .../PaykitPaymentRequestServiceTests.swift | 48 +++++++++++++++++++ 3 files changed, 58 insertions(+), 4 deletions(-) diff --git a/Bitkit/AppScene.swift b/Bitkit/AppScene.swift index 36264dcc8..14b80a18a 100644 --- a/Bitkit/AppScene.swift +++ b/Bitkit/AppScene.swift @@ -119,7 +119,8 @@ enum IncomingPaykitPaymentRequestPresentationDispatcher { } } if current.retryTrigger != previous.retryTrigger || - previous.requestedPresentationId != current.requestedPresentationId && current.requestedPresentationId != nil + previous.requestedPresentationId != current.requestedPresentationId && current.requestedPresentationId != nil || + current.expirationTrigger != previous.expirationTrigger { dispatches.append(.presentNext) } diff --git a/Bitkit/Services/PaykitPaymentRequestService.swift b/Bitkit/Services/PaykitPaymentRequestService.swift index 810610fa6..3e43db4fe 100644 --- a/Bitkit/Services/PaykitPaymentRequestService.swift +++ b/Bitkit/Services/PaykitPaymentRequestService.swift @@ -937,8 +937,10 @@ final class PaykitPaymentRequestManager { @discardableResult func deferPresentation(_ request: PaykitPaymentRequest) -> PaykitPaymentRequestPresentationDeferral { let wasRequestedPresentation = requestedPresentationId == request.id - let requestExpired = request.isExpired(at: now()) + let presentationDate = now() + let requestExpired = request.isExpired(at: presentationDate) discardExpiredRequests( + at: presentationDate, handledRequestedExpirationId: requestExpired && wasRequestedPresentation ? request.id : nil ) if requestExpired { @@ -1094,8 +1096,11 @@ final class PaykitPaymentRequestManager { refreshTask = nil } - private func discardExpiredRequests(handledRequestedExpirationId: PaykitPaymentRequest.ID? = nil) { - let date = now() + private func discardExpiredRequests( + at: Date? = nil, + handledRequestedExpirationId: PaykitPaymentRequest.ID? = nil + ) { + let date = at ?? now() recordRequestedPresentationExpiration(at: date, excluding: handledRequestedExpirationId) pendingRequests.removeAll { $0.isExpired(at: date) } let requestIds = Set(pendingRequests.map(\.id)) diff --git a/BitkitTests/PaykitPaymentRequestServiceTests.swift b/BitkitTests/PaykitPaymentRequestServiceTests.swift index 384074e37..f00a91aec 100644 --- a/BitkitTests/PaykitPaymentRequestServiceTests.swift +++ b/BitkitTests/PaykitPaymentRequestServiceTests.swift @@ -489,6 +489,7 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { ), expiredRequest ), + .presentNext, ] ) XCTAssertNil(expiredManager.consumeExpiredRequestedPresentation()) @@ -515,6 +516,53 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { XCTAssertEqual(exhaustedFeedback.toast?.accessibilityIdentifier, "PaymentRequestUnavailableToast") } + func testPresentationDispatcherAdvancesQueueAfterRequestedExpiration() async throws { + let now = Date(timeIntervalSince1970: 1_800_000_000) + let clock = PaymentRequestTestClock(now) + let sdk = try PaymentRequestSdkMock(records: [ + paymentRequestRecord(id: "request-a", expiresAt: timestamp(now.addingTimeInterval(1))), + paymentRequestRecord( + id: "request-b", + counterparty: "pubkypayee-b", + expiresAt: timestamp(now.addingTimeInterval(60)) + ), + ]) + let manager = paymentRequestManager(sdk: sdk, clock: clock) + await manager.refresh() + let requestA = try XCTUnwrap(manager.pendingRequests.first { $0.paymentRequestId == "request-a" }) + let requestB = try XCTUnwrap(manager.pendingRequests.first { $0.paymentRequestId == "request-b" }) + XCTAssertTrue(manager.requestPresentation(requestA)) + XCTAssertEqual(manager.deferPresentation(requestA), .retryScheduled) + XCTAssertTrue(manager.requestsForPresentation().isEmpty) + + let previous = IncomingPaykitPaymentRequestPresentationState(manager) + clock.advance(by: 1) + manager.reconcileExpiredRequests() + + XCTAssertNil(manager.requestedPresentationId) + XCTAssertEqual(manager.requestsForPresentation(), [requestB]) + + let dispatches = IncomingPaykitPaymentRequestPresentationDispatcher.handleStateChange( + from: previous, + to: IncomingPaykitPaymentRequestPresentationState(manager), + manager: manager + ) + XCTAssertEqual( + dispatches, + [ + .presentFeedback( + IncomingPaykitPaymentRequestPresentationFeedback( + deferral: .requestExpired(wasRequested: true), + fallbackReason: .resolutionFailed + ), + requestA + ), + .presentNext, + ] + ) + XCTAssertNil(manager.consumeExpiredRequestedPresentation()) + } + func testRefreshPreservesUnavailableOutcomeForRequestedPresentation() async throws { let sdk = try PaymentRequestSdkMock(records: [paymentRequestRecord()]) let manager = paymentRequestManager(sdk: sdk) From 095b238d7c26af65fe95ca13cd13cad11c046290 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Sat, 5 Sep 2026 04:18:42 +0200 Subject: [PATCH 08/13] docs: mark payment request journey ported --- journeys/README.md | 2 +- journeys/payment-requests/README.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/journeys/README.md b/journeys/README.md index f4f18ce0f..afc397e61 100644 --- a/journeys/README.md +++ b/journeys/README.md @@ -136,7 +136,7 @@ Everything else — `N0`–`N9`, `N000`, `NDecimal`, `NRemove`, `SpendingAmount* | [notification-permission](notification-permission) | 4 | Background-setup toggles | | [cjit-notifications](cjit-notifications) | 3 | Adapted — iOS notification copy differs from Android | | [hardware-wallet](hardware-wallet) | 15 | Trezor over Bridge; see `Docs/AI_DEVICE_TESTS.md` | -| [payment-requests](payment-requests) | 1 | iOS-only — no Android companion | +| [payment-requests](payment-requests) | 1 | Ported — matches Android's resolution-failure journey | ## Not ported diff --git a/journeys/payment-requests/README.md b/journeys/payment-requests/README.md index 2a98415ca..a072ae905 100644 --- a/journeys/payment-requests/README.md +++ b/journeys/payment-requests/README.md @@ -1,7 +1,7 @@ # Payment-request journeys These journeys cover incoming Paykit Payment Requests that Bitkit can receive but cannot open. -This suite is iOS-only; there is no Android companion under `bitkit-android/journeys`. +This suite is ported alongside Android's matching `requested-resolution-failure.xml` journey. ## Failure contract From 0af609841b1a3d30d1acfc2d4f104d46ea022b76 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 7 Sep 2026 17:36:25 +0200 Subject: [PATCH 09/13] fix: improve payment request diagnostics --- .../PaykitPaymentRequestService.swift | 52 +++++++++++-- .../PrivatePaykitService+Errors.swift | 75 +++++++++++++++++++ .../PrivatePaykitService+Payments.swift | 2 +- .../PaykitPaymentRequestServiceTests.swift | 3 + BitkitTests/PrivatePaykitServiceTests.swift | 24 ++++++ 5 files changed, 150 insertions(+), 6 deletions(-) diff --git a/Bitkit/Services/PaykitPaymentRequestService.swift b/Bitkit/Services/PaykitPaymentRequestService.swift index 3e43db4fe..ccc2f08ad 100644 --- a/Bitkit/Services/PaykitPaymentRequestService.swift +++ b/Bitkit/Services/PaykitPaymentRequestService.swift @@ -2,7 +2,7 @@ import Foundation import Paykit struct PaykitPaymentRequest: Identifiable, Hashable { - enum ParseFailure: String, Error, Equatable { + enum ParseFailure: String, Error, Equatable, Sendable { case missingLocalRole = "missing_local_role" case outgoingRequest = "outgoing_request" case unsupportedLocalRole = "unsupported_local_role" @@ -308,6 +308,37 @@ struct PaykitPaymentRequestSnapshot: Equatable { let history: [PaykitPaymentRequest] } +private struct IncomingPaykitPaymentRequestRejection: Sendable { + struct ID: Hashable, Sendable { + let paymentRequestId: String + let reason: PaykitPaymentRequest.ParseFailure + let redactedCounterparty: String + } + + let id: ID + let message: String +} + +private actor IncomingPaykitPaymentRequestRejectionLog { + private var loggedIds: Set = [] + + func newlySeen( + _ rejections: [IncomingPaykitPaymentRequestRejection] + ) -> [IncomingPaykitPaymentRequestRejection] { + var currentIds: Set = [] + var newRejections: [IncomingPaykitPaymentRequestRejection] = [] + + for rejection in rejections where currentIds.insert(rejection.id).inserted { + if !loggedIds.contains(rejection.id) { + newRejections.append(rejection) + } + } + + loggedIds = currentIds + return newRejections + } +} + enum PaykitPaymentRequestDiagnostics { static func redactedCounterparty(_ input: String) -> String { guard let publicKey = PubkyPublicKeyFormat.normalized(input) else { return "" } @@ -368,6 +399,7 @@ struct PaykitPaymentRequestService { private let now: @Sendable () -> Date private let isPrivatePaymentPublishingEnabled: @Sendable () -> Bool private let logWarning: @Sendable (String) -> Void + private let incomingRejectionLog = IncomingPaykitPaymentRequestRejectionLog() init( sdk: any PaykitPaymentRequestSdkHandling = PaykitSdkService.shared, @@ -391,20 +423,30 @@ struct PaykitPaymentRequestService { logIntakeFailures(intakeReports) let synchronizationDate = now() let records = try await sdk.paymentRequests() + var rejections: [IncomingPaykitPaymentRequestRejection] = [] let incoming = records.compactMap { record in switch PaykitPaymentRequest.parseIncoming(record: record, now: synchronizationDate) { case let .success(request): return request case let .failure(reason): if reason.shouldLogIncomingRejection { - logWarning( - "Rejected incoming Paykit payment request: category=parse reason=\(reason.rawValue) " + - "counterparty=\(PaykitPaymentRequestDiagnostics.redactedCounterparty(record.counterparty))" - ) + let redactedCounterparty = PaykitPaymentRequestDiagnostics.redactedCounterparty(record.counterparty) + rejections.append(IncomingPaykitPaymentRequestRejection( + id: IncomingPaykitPaymentRequestRejection.ID( + paymentRequestId: record.paymentRequestId, + reason: reason, + redactedCounterparty: redactedCounterparty + ), + message: "Rejected incoming Paykit payment request: category=parse reason=\(reason.rawValue) " + + "counterparty=\(redactedCounterparty)" + )) } return nil } } + for rejection in await incomingRejectionLog.newlySeen(rejections) { + logWarning(rejection.message) + } let history = records.compactMap { PaykitPaymentRequest(historyRecord: $0, now: synchronizationDate) } diff --git a/Bitkit/Services/PrivatePaykitService+Errors.swift b/Bitkit/Services/PrivatePaykitService+Errors.swift index c600f9f49..b54170805 100644 --- a/Bitkit/Services/PrivatePaykitService+Errors.swift +++ b/Bitkit/Services/PrivatePaykitService+Errors.swift @@ -1,5 +1,6 @@ import Foundation import LDKNode +import Paykit enum PrivatePaykitError: LocalizedError { case invalidPublicKey @@ -21,6 +22,80 @@ enum PrivatePaykitError: LocalizedError { } } +enum PaykitResolutionFailureDiagnostics { + static func reason(for error: Error) -> String { + if let error = error as? PaykitError { + return paykitReason(error) + } + if let error = error as? PrivatePaykitError { + return privateReason(error) + } + if let error = error as? PublicPaykitError { + return publicReason(error) + } + return "unknown/\(String(reflecting: type(of: error)))" + } + + private static func paykitReason(_ error: PaykitError) -> String { + switch error { + case let .Storage(code, _): + "storage/\(safeCode(code))" + case let .Identity(code, _): + "identity/\(safeCode(code))" + case let .Transport(code, _): + "transport/\(safeCode(code))" + case let .NotFound(code, _): + "not_found/\(safeCode(code))" + case let .Protocol(code, _): + "protocol/\(safeCode(code))" + case let .Policy(code, _): + "policy/\(safeCode(code))" + case let .PaymentAdapter(code, _): + "payment_adapter/\(safeCode(code))" + case let .RecoveryRequired(code, _): + "recovery_required/\(safeCode(code))" + } + } + + private static func privateReason(_ error: PrivatePaykitError) -> String { + switch error { + case .invalidPublicKey: + "private/invalid_public_key" + case .privateUnavailable: + "private/unavailable" + case .paymentListAlreadyConsumed: + "private/payment_list_already_consumed" + case .routeHintsUnavailable: + "private/route_hints_unavailable" + } + } + + private static func publicReason(_ error: PublicPaykitError) -> String { + switch error { + case .noSupportedEndpoint: + "public/no_supported_endpoint" + case .walletNotReady: + "public/wallet_not_ready" + case .invalidPayload: + "public/invalid_payload" + case .routeHintsUnavailable: + "public/route_hints_unavailable" + case .publicationFailed: + "public/publication_failed" + } + } + + private static func safeCode(_ code: String) -> String { + guard !code.isEmpty, + code.utf8.count <= 64, + code.utf8.allSatisfy({ byte in + byte == 45 || byte == 95 || (48 ... 57).contains(byte) || (97 ... 122).contains(byte) + }) + else { return "unknown_code" } + return code + } +} + // MARK: - Error Helpers extension PrivatePaykitService { diff --git a/Bitkit/Services/PrivatePaykitService+Payments.swift b/Bitkit/Services/PrivatePaykitService+Payments.swift index a8453f833..aa95159d5 100644 --- a/Bitkit/Services/PrivatePaykitService+Payments.swift +++ b/Bitkit/Services/PrivatePaykitService+Payments.swift @@ -124,7 +124,7 @@ extension PrivatePaykitService { Logger.warn( "Failed to resolve Paykit contact payment for \(PubkyPublicKeyFormat.redacted(publicKey)): " + - "errorType=\(String(reflecting: type(of: error)))", + "reason=\(PaykitResolutionFailureDiagnostics.reason(for: error))", context: "PrivatePaykit" ) diff --git a/BitkitTests/PaykitPaymentRequestServiceTests.swift b/BitkitTests/PaykitPaymentRequestServiceTests.swift index f00a91aec..aeab330c3 100644 --- a/BitkitTests/PaykitPaymentRequestServiceTests.swift +++ b/BitkitTests/PaykitPaymentRequestServiceTests.swift @@ -139,9 +139,12 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { ) let snapshot = try await service.synchronize() + let firstMessages = recorder.messages + _ = try await service.synchronize() XCTAssertTrue(snapshot.incoming.isEmpty) let output = recorder.messages.joined(separator: "\n") + XCTAssertEqual(recorder.messages, firstMessages) XCTAssertTrue(output.contains("category=parse reason=unsupported_asset")) XCTAssertTrue(output.contains("category=parse reason=no_supported_endpoint")) XCTAssertTrue(output.contains("category=parse reason=unsupported_local_role")) diff --git a/BitkitTests/PrivatePaykitServiceTests.swift b/BitkitTests/PrivatePaykitServiceTests.swift index fb8407358..4fcb995e8 100644 --- a/BitkitTests/PrivatePaykitServiceTests.swift +++ b/BitkitTests/PrivatePaykitServiceTests.swift @@ -1,4 +1,5 @@ @testable import Bitkit +import Paykit import XCTest final class PrivatePaykitServiceTests: XCTestCase { @@ -46,6 +47,29 @@ final class PrivatePaykitServiceTests: XCTestCase { ) } + func testResolutionFailureDiagnosticsKeepRedactedPaykitReason() { + XCTAssertEqual( + PaykitResolutionFailureDiagnostics.reason( + for: PaykitError.Transport(code: "transport_error", context: "do-not-log") + ), + "transport/transport_error" + ) + XCTAssertEqual( + PaykitResolutionFailureDiagnostics.reason( + for: PaykitError.Storage(code: "do not log", context: "do-not-log") + ), + "storage/unknown_code" + ) + XCTAssertEqual( + PaykitResolutionFailureDiagnostics.reason(for: PrivatePaykitError.routeHintsUnavailable), + "private/route_hints_unavailable" + ) + XCTAssertEqual( + PaykitResolutionFailureDiagnostics.reason(for: PublicPaykitError.noSupportedEndpoint), + "public/no_supported_endpoint" + ) + } + func testReceivedPrivateInvoiceHashKeepsContactAttribution() async { let service = PrivatePaykitService() let publicKey = "pubkycontact" From ffdfd20be7aa9b18575549cdbfe5d7c4bacf8b11 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 8 Sep 2026 01:02:30 +0200 Subject: [PATCH 10/13] fix: limit payment request warnings --- Bitkit/AppScene.swift | 18 ++++++++++++------ .../PaykitPaymentRequestServiceTests.swift | 16 ++++++++++++++++ BitkitTests/PublicPaykitServiceTests.swift | 1 + 3 files changed, 29 insertions(+), 6 deletions(-) diff --git a/Bitkit/AppScene.swift b/Bitkit/AppScene.swift index 14b80a18a..15d75543b 100644 --- a/Bitkit/AppScene.swift +++ b/Bitkit/AppScene.swift @@ -11,6 +11,7 @@ struct IncomingPaykitPaymentRequestPresentationFeedback: Equatable { } let diagnosticReason: IncomingPaykitPaymentRequestFailureReason + let isTerminal: Bool let toast: Toast? init( @@ -20,6 +21,7 @@ struct IncomingPaykitPaymentRequestPresentationFeedback: Equatable { switch deferral { case .requestedPresentationEnded: diagnosticReason = fallbackReason + isTerminal = true toast = Toast( titleKey: "wallet__payment_request", descriptionKey: "wallet__payment_request_unavailable", @@ -27,6 +29,7 @@ struct IncomingPaykitPaymentRequestPresentationFeedback: Equatable { ) case let .requestExpired(wasRequested): diagnosticReason = .requestExpired + isTerminal = true toast = wasRequested ? Toast( titleKey: "wallet__payment_request", descriptionKey: "wallet__payment_request_expired", @@ -34,6 +37,7 @@ struct IncomingPaykitPaymentRequestPresentationFeedback: Equatable { ) : nil case .retryScheduled, .ignored: diagnosticReason = fallbackReason + isTerminal = false toast = nil } } @@ -1092,12 +1096,14 @@ struct AppScene: View { _ feedback: IncomingPaykitPaymentRequestPresentationFeedback, for request: PaykitPaymentRequest ) { - Logger.warn( - "Rejected incoming Paykit payment request presentation: category=\(feedback.diagnosticReason.category) " + - "reason=\(feedback.diagnosticReason.rawValue) " + - "counterparty=\(PaykitPaymentRequestDiagnostics.redactedCounterparty(request.counterparty))", - context: "AppScene" - ) + if feedback.isTerminal { + Logger.warn( + "Rejected incoming Paykit payment request presentation: category=\(feedback.diagnosticReason.category) " + + "reason=\(feedback.diagnosticReason.rawValue) " + + "counterparty=\(PaykitPaymentRequestDiagnostics.redactedCounterparty(request.counterparty))", + context: "AppScene" + ) + } guard let toast = feedback.toast else { return } app.toast( diff --git a/BitkitTests/PaykitPaymentRequestServiceTests.swift b/BitkitTests/PaykitPaymentRequestServiceTests.swift index aeab330c3..99404d317 100644 --- a/BitkitTests/PaykitPaymentRequestServiceTests.swift +++ b/BitkitTests/PaykitPaymentRequestServiceTests.swift @@ -514,11 +514,27 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { with: retryManager ) XCTAssertEqual(exhaustedFeedback.diagnosticReason, .resolutionFailed) + XCTAssertTrue(exhaustedFeedback.isTerminal) XCTAssertEqual(exhaustedFeedback.toast?.titleKey, "wallet__payment_request") XCTAssertEqual(exhaustedFeedback.toast?.descriptionKey, "wallet__payment_request_unavailable") XCTAssertEqual(exhaustedFeedback.toast?.accessibilityIdentifier, "PaymentRequestUnavailableToast") } + func testPresentationDispatcherSuppressesNonTerminalRetryDiagnostics() async throws { + let manager = try paymentRequestManager(sdk: PaymentRequestSdkMock(records: [paymentRequestRecord()])) + await manager.refresh() + let request = try XCTUnwrap(manager.pendingRequests.first) + + let feedback = IncomingPaykitPaymentRequestPresentationDispatcher.feedback( + deferring: request, + reason: .resolutionFailed, + with: manager + ) + + XCTAssertFalse(feedback.isTerminal) + XCTAssertNil(feedback.toast) + } + func testPresentationDispatcherAdvancesQueueAfterRequestedExpiration() async throws { let now = Date(timeIntervalSince1970: 1_800_000_000) let clock = PaymentRequestTestClock(now) diff --git a/BitkitTests/PublicPaykitServiceTests.swift b/BitkitTests/PublicPaykitServiceTests.swift index 54c090585..e329f2b1f 100644 --- a/BitkitTests/PublicPaykitServiceTests.swift +++ b/BitkitTests/PublicPaykitServiceTests.swift @@ -199,6 +199,7 @@ final class PublicPaykitServiceTests: XCTestCase { XCTAssertEqual(feedback.diagnosticReason, .requestExpired) XCTAssertEqual(feedback.diagnosticReason.rawValue, "request_expired") + XCTAssertTrue(feedback.isTerminal) XCTAssertEqual(feedback.toast?.titleKey, "wallet__payment_request") XCTAssertEqual(feedback.toast?.descriptionKey, "wallet__payment_request_expired") XCTAssertEqual(feedback.toast?.accessibilityIdentifier, "PaymentRequestExpiredToast") From 00f15f348d0d014577a422f302a9a3bc64a53bd7 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 8 Sep 2026 19:37:20 +0200 Subject: [PATCH 11/13] fix: log initial payment request failure --- Bitkit/AppScene.swift | 38 +++++++++---- .../PaykitPaymentRequestService.swift | 22 ++++++++ Bitkit/Services/PublicPaykitService.swift | 2 +- .../PaykitPaymentRequestServiceTests.swift | 55 +++++++++++++++++-- 4 files changed, 98 insertions(+), 19 deletions(-) diff --git a/Bitkit/AppScene.swift b/Bitkit/AppScene.swift index 15d75543b..43d6467e0 100644 --- a/Bitkit/AppScene.swift +++ b/Bitkit/AppScene.swift @@ -12,16 +12,19 @@ struct IncomingPaykitPaymentRequestPresentationFeedback: Equatable { let diagnosticReason: IncomingPaykitPaymentRequestFailureReason let isTerminal: Bool + let shouldLogDiagnostic: Bool let toast: Toast? init( deferral: PaykitPaymentRequestPresentationDeferral, - fallbackReason: IncomingPaykitPaymentRequestFailureReason + fallbackReason: IncomingPaykitPaymentRequestFailureReason, + shouldLogNonTerminalDiagnostic: Bool = false ) { switch deferral { case .requestedPresentationEnded: diagnosticReason = fallbackReason isTerminal = true + shouldLogDiagnostic = true toast = Toast( titleKey: "wallet__payment_request", descriptionKey: "wallet__payment_request_unavailable", @@ -30,17 +33,31 @@ struct IncomingPaykitPaymentRequestPresentationFeedback: Equatable { case let .requestExpired(wasRequested): diagnosticReason = .requestExpired isTerminal = true + shouldLogDiagnostic = true toast = wasRequested ? Toast( titleKey: "wallet__payment_request", descriptionKey: "wallet__payment_request_expired", accessibilityIdentifier: "PaymentRequestExpiredToast" ) : nil - case .retryScheduled, .ignored: + case .retryScheduled: diagnosticReason = fallbackReason isTerminal = false + shouldLogDiagnostic = shouldLogNonTerminalDiagnostic + toast = nil + case .ignored: + diagnosticReason = fallbackReason + isTerminal = false + shouldLogDiagnostic = false toast = nil } } + + func diagnosticMessage(for request: PaykitPaymentRequest) -> String? { + guard shouldLogDiagnostic else { return nil } + return "Rejected incoming Paykit payment request presentation: category=\(diagnosticReason.category) " + + "reason=\(diagnosticReason.rawValue) " + + "counterparty=\(PaykitPaymentRequestDiagnostics.redactedCounterparty(request.counterparty))" + } } struct IncomingPaykitPaymentRequestPresentationState: Equatable { @@ -84,9 +101,11 @@ enum IncomingPaykitPaymentRequestPresentationDispatcher { reason: IncomingPaykitPaymentRequestFailureReason, with manager: PaykitPaymentRequestManager ) -> IncomingPaykitPaymentRequestPresentationFeedback { - IncomingPaykitPaymentRequestPresentationFeedback( - deferral: manager.deferPresentation(request), - fallbackReason: reason + let result = manager.deferPresentation(request, diagnosticReason: reason) + return IncomingPaykitPaymentRequestPresentationFeedback( + deferral: result.deferral, + fallbackReason: reason, + shouldLogNonTerminalDiagnostic: result.shouldLogDiagnostic ) } @@ -1096,13 +1115,8 @@ struct AppScene: View { _ feedback: IncomingPaykitPaymentRequestPresentationFeedback, for request: PaykitPaymentRequest ) { - if feedback.isTerminal { - Logger.warn( - "Rejected incoming Paykit payment request presentation: category=\(feedback.diagnosticReason.category) " + - "reason=\(feedback.diagnosticReason.rawValue) " + - "counterparty=\(PaykitPaymentRequestDiagnostics.redactedCounterparty(request.counterparty))", - context: "AppScene" - ) + if let diagnosticMessage = feedback.diagnosticMessage(for: request) { + Logger.warn(diagnosticMessage, context: "AppScene") } guard let toast = feedback.toast else { return } diff --git a/Bitkit/Services/PaykitPaymentRequestService.swift b/Bitkit/Services/PaykitPaymentRequestService.swift index ccc2f08ad..e5d6e1b58 100644 --- a/Bitkit/Services/PaykitPaymentRequestService.swift +++ b/Bitkit/Services/PaykitPaymentRequestService.swift @@ -693,6 +693,8 @@ final class PaykitPaymentRequestManager { private var presentedRequestIds: Set = [] private var presentationRetryAttempts: [PaykitPaymentRequest.ID: Int] = [:] private var presentationRetryDates: [PaykitPaymentRequest.ID: Date] = [:] + private var automaticPresentationDiagnosticReasons: + [PaykitPaymentRequest.ID: Set] = [:] private var expiredRequestedPresentations: [PaykitPaymentRequest] = [] private var unavailableRequestedPresentations: [PaykitPaymentRequest] = [] private var isPresentingRequests = false @@ -904,6 +906,7 @@ final class PaykitPaymentRequestManager { persistedPresentedRequestIds = [] presentationRetryAttempts = [:] presentationRetryDates = [:] + automaticPresentationDiagnosticReasons = [:] expiredRequestedPresentations = [] unavailableRequestedPresentations = [] requestedPresentationId = nil @@ -1018,6 +1021,21 @@ final class PaykitPaymentRequestManager { return .retryScheduled } + func deferPresentation( + _ request: PaykitPaymentRequest, + diagnosticReason: IncomingPaykitPaymentRequestFailureReason + ) -> (deferral: PaykitPaymentRequestPresentationDeferral, shouldLogDiagnostic: Bool) { + let wasRequestedPresentation = requestedPresentationId == request.id + let deferral = deferPresentation(request) + guard !wasRequestedPresentation, deferral == .retryScheduled else { + return (deferral, false) + } + + let shouldLogDiagnostic = automaticPresentationDiagnosticReasons[request.id, default: []] + .insert(diagnosticReason).inserted + return (deferral, shouldLogDiagnostic) + } + func markPresentedIfPending(_ request: PaykitPaymentRequest) -> Bool { discardExpiredRequests() guard pendingRequests.contains(where: { $0.id == request.id }) else { return false } @@ -1028,6 +1046,7 @@ final class PaykitPaymentRequestManager { } presentationRetryAttempts.removeValue(forKey: request.id) presentationRetryDates.removeValue(forKey: request.id) + automaticPresentationDiagnosticReasons.removeValue(forKey: request.id) schedulePresentationRetry() persistPresentedRequestIds() return true @@ -1056,6 +1075,7 @@ final class PaykitPaymentRequestManager { presentedRequestIds.formIntersection(requestIds) presentationRetryAttempts = presentationRetryAttempts.filter { requestIds.contains($0.key) } presentationRetryDates = presentationRetryDates.filter { requestIds.contains($0.key) } + automaticPresentationDiagnosticReasons = automaticPresentationDiagnosticReasons.filter { requestIds.contains($0.key) } if let requestedId = requestedPresentationId, !requestIds.contains(requestedId) { presentationGeneration += 1 if requestedId != handledRequestedExpirationId, @@ -1115,6 +1135,7 @@ final class PaykitPaymentRequestManager { presentedRequestIds.remove(request.id) presentationRetryAttempts.removeValue(forKey: request.id) presentationRetryDates.removeValue(forKey: request.id) + automaticPresentationDiagnosticReasons.removeValue(forKey: request.id) schedulePresentationRetry() if requestedPresentationId == request.id { presentationGeneration += 1 @@ -1149,6 +1170,7 @@ final class PaykitPaymentRequestManager { presentedRequestIds.formIntersection(requestIds) presentationRetryAttempts = presentationRetryAttempts.filter { requestIds.contains($0.key) } presentationRetryDates = presentationRetryDates.filter { requestIds.contains($0.key) } + automaticPresentationDiagnosticReasons = automaticPresentationDiagnosticReasons.filter { requestIds.contains($0.key) } if requestedPresentationId.map({ !requestIds.contains($0) }) == true { presentationGeneration += 1 requestedPresentationId = nil diff --git a/Bitkit/Services/PublicPaykitService.swift b/Bitkit/Services/PublicPaykitService.swift index ec235bf0d..d55b02cfd 100644 --- a/Bitkit/Services/PublicPaykitService.swift +++ b/Bitkit/Services/PublicPaykitService.swift @@ -31,7 +31,7 @@ struct PrivatePaykitPaymentContext: Equatable { let paymentListVersion: UInt64 } -enum IncomingPaykitPaymentRequestFailureReason: String, Equatable { +enum IncomingPaykitPaymentRequestFailureReason: String, Hashable { case noSupportedEndpoint = "no_supported_endpoint" case endpointNotPayable = "endpoint_not_payable" case paymentDetailsPending = "payment_details_pending" diff --git a/BitkitTests/PaykitPaymentRequestServiceTests.swift b/BitkitTests/PaykitPaymentRequestServiceTests.swift index 99404d317..8025f839c 100644 --- a/BitkitTests/PaykitPaymentRequestServiceTests.swift +++ b/BitkitTests/PaykitPaymentRequestServiceTests.swift @@ -518,21 +518,64 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { XCTAssertEqual(exhaustedFeedback.toast?.titleKey, "wallet__payment_request") XCTAssertEqual(exhaustedFeedback.toast?.descriptionKey, "wallet__payment_request_unavailable") XCTAssertEqual(exhaustedFeedback.toast?.accessibilityIdentifier, "PaymentRequestUnavailableToast") + XCTAssertNotNil(exhaustedFeedback.diagnosticMessage(for: retryRequest)) } - func testPresentationDispatcherSuppressesNonTerminalRetryDiagnostics() async throws { - let manager = try paymentRequestManager(sdk: PaymentRequestSdkMock(records: [paymentRequestRecord()])) + func testPresentationDispatcherLogsFirstAutomaticFailurePerReasonAndLifecycle() async throws { + let counterparty = "pubky\(String(repeating: "y", count: 52))" + let record = try paymentRequestRecord(counterparty: counterparty) + let sdk = PaymentRequestSdkMock(records: [record]) + let clock = PaymentRequestTestClock(Date()) + let manager = paymentRequestManager(sdk: sdk, clock: clock) await manager.refresh() let request = try XCTUnwrap(manager.pendingRequests.first) - let feedback = IncomingPaykitPaymentRequestPresentationDispatcher.feedback( + let firstFeedback = IncomingPaykitPaymentRequestPresentationDispatcher.feedback( deferring: request, - reason: .resolutionFailed, + reason: .noSupportedEndpoint, + with: manager + ) + clock.advance(by: 2) + let repeatedFeedback = IncomingPaykitPaymentRequestPresentationDispatcher.feedback( + deferring: request, + reason: .noSupportedEndpoint, + with: manager + ) + clock.advance(by: 2) + let changedReasonFeedback = IncomingPaykitPaymentRequestPresentationDispatcher.feedback( + deferring: request, + reason: .invalidPaymentTarget, + with: manager + ) + + let firstMessage = try XCTUnwrap(firstFeedback.diagnosticMessage(for: request)) + XCTAssertFalse(firstFeedback.isTerminal) + XCTAssertNil(firstFeedback.toast) + XCTAssertEqual( + firstMessage, + "Rejected incoming Paykit payment request presentation: category=resolution reason=no_supported_endpoint " + + "counterparty=\(PaykitPaymentRequestDiagnostics.redactedCounterparty(counterparty))" + ) + XCTAssertFalse(firstMessage.contains(counterparty)) + XCTAssertNil(repeatedFeedback.diagnosticMessage(for: request)) + XCTAssertEqual( + changedReasonFeedback.diagnosticMessage(for: request), + "Rejected incoming Paykit payment request presentation: category=presentation reason=invalid_payment_target " + + "counterparty=\(PaykitPaymentRequestDiagnostics.redactedCounterparty(counterparty))" + ) + + await sdk.setRecords([]) + await manager.refresh() + await sdk.setRecords([record]) + await manager.refresh() + let reappearedRequest = try XCTUnwrap(manager.pendingRequests.first) + let reappearedFeedback = IncomingPaykitPaymentRequestPresentationDispatcher.feedback( + deferring: reappearedRequest, + reason: .noSupportedEndpoint, with: manager ) - XCTAssertFalse(feedback.isTerminal) - XCTAssertNil(feedback.toast) + XCTAssertNotNil(reappearedFeedback.diagnosticMessage(for: reappearedRequest)) } func testPresentationDispatcherAdvancesQueueAfterRequestedExpiration() async throws { From b9279b779e9620625dcbf2a34d3e7affd3a50d4b Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 10 Sep 2026 11:56:34 +0200 Subject: [PATCH 12/13] fix: preserve refresh feedback --- .../PaykitPaymentRequestService.swift | 22 +++--- .../PaykitPaymentRequestServiceTests.swift | 79 ++++++++++++++++++- journeys/payment-requests/README.md | 2 +- .../requested-resolution-failure.xml | 4 +- 4 files changed, 91 insertions(+), 16 deletions(-) diff --git a/Bitkit/Services/PaykitPaymentRequestService.swift b/Bitkit/Services/PaykitPaymentRequestService.swift index c1c17e9bc..d99494535 100644 --- a/Bitkit/Services/PaykitPaymentRequestService.swift +++ b/Bitkit/Services/PaykitPaymentRequestService.swift @@ -1569,6 +1569,17 @@ final class PaykitPaymentRequestManager { historyRequests = (oneTimeHistory + recurringHistory).sorted { ($0.createdAt ?? .distantPast) > ($1.createdAt ?? .distantPast) } + let requestIds = Set(pendingRequests.map(\.id)) + if let requestedId = requestedPresentationId, !requestIds.contains(requestedId) { + presentationGeneration += 1 + if requestedId != handledRequestedExpirationId, + let request = previousPending.first(where: { $0.id == requestedId }) + { + unavailableRequestedPresentations.append(request) + requestedPresentationUnavailableTrigger += 1 + } + requestedPresentationId = nil + } await subscriptionNotificationScheduler.synchronize( subscriptions, acceptedAt: subscriptionAcceptedAt, @@ -1577,21 +1588,10 @@ final class PaykitPaymentRequestManager { notificationsEnabled: SettingsViewModel.shared.enableNotifications, now: refreshDate ) - let requestIds = Set(pendingRequests.map(\.id)) presentedRequestIds.formIntersection(requestIds) presentationRetryAttempts = presentationRetryAttempts.filter { requestIds.contains($0.key) } presentationRetryDates = presentationRetryDates.filter { requestIds.contains($0.key) } automaticPresentationDiagnosticReasons = automaticPresentationDiagnosticReasons.filter { requestIds.contains($0.key) } - if let requestedId = requestedPresentationId, !requestIds.contains(requestedId) { - presentationGeneration += 1 - if requestedId != handledRequestedExpirationId, - let request = previousPending.first(where: { $0.id == requestedId }) - { - unavailableRequestedPresentations.append(request) - requestedPresentationUnavailableTrigger += 1 - } - requestedPresentationId = nil - } persistPresentedRequestIds() discardExpiredRequests(handledRequestedExpirationId: handledRequestedExpirationId) schedulePresentationRetry() diff --git a/BitkitTests/PaykitPaymentRequestServiceTests.swift b/BitkitTests/PaykitPaymentRequestServiceTests.swift index 2072f4683..a6bf78fd3 100644 --- a/BitkitTests/PaykitPaymentRequestServiceTests.swift +++ b/BitkitTests/PaykitPaymentRequestServiceTests.swift @@ -1753,6 +1753,60 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { _ = await presentationTask.value } + func testRefreshRecordsUnavailableBeforeSuspendedNotificationSynchronization() 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 notificationCenter = PaykitSubscriptionNotificationCenterMock() + let notificationScheduler = PaykitSubscriptionNotificationScheduler(center: notificationCenter) + let manager = paymentRequestManager( + sdk: sdk, + clock: clock, + subscriptionNotificationScheduler: notificationScheduler + ) + await manager.refresh() + let request = try XCTUnwrap(manager.pendingRequests.first) + XCTAssertTrue(manager.requestPresentation(request)) + + let previous = IncomingPaykitPaymentRequestPresentationState(manager) + await sdk.setRecords([]) + await notificationCenter.pauseNextPendingRequests() + let refreshTask = Task { await manager.refresh() } + try await waitUntil { await notificationCenter.isPendingRequestsPaused } + + XCTAssertTrue(manager.pendingRequests.isEmpty) + XCTAssertNil(manager.requestedPresentationId) + XCTAssertEqual(manager.requestedPresentationUnavailableTrigger, 1) + + clock.advance(by: 60) + manager.reconcileExpiredRequests() + + XCTAssertEqual(manager.requestedPresentationExpirationTrigger, 0) + await notificationCenter.resumePendingRequests() + await refreshTask.value + + let dispatches = IncomingPaykitPaymentRequestPresentationDispatcher.handleStateChange( + from: previous, + to: IncomingPaykitPaymentRequestPresentationState(manager), + manager: manager + ) + XCTAssertEqual( + dispatches, + [ + .presentFeedback( + IncomingPaykitPaymentRequestPresentationFeedback( + deferral: .requestedPresentationEnded, + fallbackReason: .resolutionFailed + ), + request + ), + ] + ) + XCTAssertNil(manager.consumeUnavailableRequestedPresentation()) + } + func testPreparationConsumesBeforeAccepting() async throws { let sdk = try PaymentRequestSdkMock(records: [paymentRequestRecord()]) let manager = paymentRequestManager(sdk: sdk) @@ -2602,6 +2656,7 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { sdk: PaymentRequestSdkMock, clock: PaymentRequestTestClock = PaymentRequestTestClock(Date()), subscriptionStateStore: PaymentRequestSubscriptionStateMemoryStore = PaymentRequestSubscriptionStateMemoryStore(), + subscriptionNotificationScheduler: PaykitSubscriptionNotificationScheduler = PaykitSubscriptionNotificationScheduler(), isPrivatePaymentPublishingEnabled: Bool = true, completedPaymentProofKinds: [PaykitPaymentRequest.ID: PaykitPaymentProofKind] = [:], inFlightPaymentRequestIds: Set = [], @@ -2617,6 +2672,7 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { ), presentationStore: PaymentRequestPresentationMemoryStore(), subscriptionStateStore: subscriptionStateStore, + subscriptionNotificationScheduler: subscriptionNotificationScheduler, completedPaymentProofKinds: { _ in completedPaymentProofKinds }, inFlightPaymentRequestIds: { _ in inFlightPaymentRequestIds }, protectedRequestIdsForSubscriptionCancellation: { _, _ in protectedRequestIdsForSubscriptionCancellation }, @@ -3190,6 +3246,8 @@ private actor PaykitSubscriptionNotificationCenterMock: PaykitSubscriptionNotifi private var requests: [String: UNNotificationRequest] = [:] private var shouldPauseNextAdd = false private var addContinuation: CheckedContinuation? + private var shouldPauseNextPendingRequests = false + private var pendingRequestsContinuation: CheckedContinuation? var isAddPaused: Bool { addContinuation != nil @@ -3199,12 +3257,24 @@ private actor PaykitSubscriptionNotificationCenterMock: PaykitSubscriptionNotifi Set(requests.keys) } + var isPendingRequestsPaused: Bool { + pendingRequestsContinuation != nil + } + func pauseNextAdd() { shouldPauseNextAdd = true } - func pendingNotificationRequests() -> [UNNotificationRequest] { - Array(requests.values) + func pauseNextPendingRequests() { + shouldPauseNextPendingRequests = true + } + + func pendingNotificationRequests() async -> [UNNotificationRequest] { + if shouldPauseNextPendingRequests { + shouldPauseNextPendingRequests = false + await withCheckedContinuation { pendingRequestsContinuation = $0 } + } + return Array(requests.values) } func add(_ request: UNNotificationRequest) async throws { @@ -3225,6 +3295,11 @@ private actor PaykitSubscriptionNotificationCenterMock: PaykitSubscriptionNotifi addContinuation?.resume() addContinuation = nil } + + func resumePendingRequests() { + pendingRequestsContinuation?.resume() + pendingRequestsContinuation = nil + } } @MainActor diff --git a/journeys/payment-requests/README.md b/journeys/payment-requests/README.md index a072ae905..e9a6ba6ec 100644 --- a/journeys/payment-requests/README.md +++ b/journeys/payment-requests/README.md @@ -43,7 +43,7 @@ correctly prevents it from entering the presentation queue. ## Identifiers used - Screen: `PaymentRequestsScreen`. -- Request row: `PaymentRequestRow-`. +- Request row: `PaymentRequestRow----one-time`. - Pay action: `PaymentRequestPay-`. - Terminal feedback: `PaymentRequestUnavailableToast`. - Expiration feedback: `PaymentRequestExpiredToast`. diff --git a/journeys/payment-requests/requested-resolution-failure.xml b/journeys/payment-requests/requested-resolution-failure.xml index ec091fb2c..0f3924870 100644 --- a/journeys/payment-requests/requested-resolution-failure.xml +++ b/journeys/payment-requests/requested-resolution-failure.xml @@ -11,11 +11,11 @@ "PaymentRequestsScreen"). - Verify the incoming request row (id "PaymentRequestRow-<payment-request-id>") is visible + Verify the incoming request row (id "PaymentRequestRow-<payment-request-id>-<counterparty>-<receiver-path>-one-time") is visible Tap Pay (id "PaymentRequestPay-<payment-request-id>") Wait up to 35 seconds for the terminal error toast (id "PaymentRequestUnavailableToast") Verify the toast title is "Payment Request" and its description is "The payment request is no longer available." Verify Payment Requests (id "PaymentRequestsScreen") remains visible - Verify the incoming request row (id "PaymentRequestRow-<payment-request-id>") remains visible for a later retry + Verify the incoming request row (id "PaymentRequestRow-<payment-request-id>-<counterparty>-<receiver-path>-one-time") remains visible for a later retry From 55d0548c62d2f52fd8678edbf605ec9c58e92d67 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 10 Sep 2026 13:36:55 +0200 Subject: [PATCH 13/13] fix: retain retried presentation state --- .../PaykitPaymentRequestService.swift | 15 ++++++----- .../PaykitPaymentRequestServiceTests.swift | 27 +++++++++++++++++++ 2 files changed, 35 insertions(+), 7 deletions(-) diff --git a/Bitkit/Services/PaykitPaymentRequestService.swift b/Bitkit/Services/PaykitPaymentRequestService.swift index d99494535..30710eec5 100644 --- a/Bitkit/Services/PaykitPaymentRequestService.swift +++ b/Bitkit/Services/PaykitPaymentRequestService.swift @@ -1569,8 +1569,8 @@ final class PaykitPaymentRequestManager { historyRequests = (oneTimeHistory + recurringHistory).sorted { ($0.createdAt ?? .distantPast) > ($1.createdAt ?? .distantPast) } - let requestIds = Set(pendingRequests.map(\.id)) - if let requestedId = requestedPresentationId, !requestIds.contains(requestedId) { + let synchronizedRequestIds = Set(pendingRequests.map(\.id)) + if let requestedId = requestedPresentationId, !synchronizedRequestIds.contains(requestedId) { presentationGeneration += 1 if requestedId != handledRequestedExpirationId, let request = previousPending.first(where: { $0.id == requestedId }) @@ -1583,15 +1583,16 @@ final class PaykitPaymentRequestManager { await subscriptionNotificationScheduler.synchronize( subscriptions, acceptedAt: subscriptionAcceptedAt, - pendingRequestIds: Set(pendingRequests.map(\.id)), + pendingRequestIds: synchronizedRequestIds, payerIdentity: activeIdentity, notificationsEnabled: SettingsViewModel.shared.enableNotifications, now: refreshDate ) - presentedRequestIds.formIntersection(requestIds) - presentationRetryAttempts = presentationRetryAttempts.filter { requestIds.contains($0.key) } - presentationRetryDates = presentationRetryDates.filter { requestIds.contains($0.key) } - automaticPresentationDiagnosticReasons = automaticPresentationDiagnosticReasons.filter { requestIds.contains($0.key) } + let currentRequestIds = Set(pendingRequests.map(\.id)) + presentedRequestIds.formIntersection(currentRequestIds) + presentationRetryAttempts = presentationRetryAttempts.filter { currentRequestIds.contains($0.key) } + presentationRetryDates = presentationRetryDates.filter { currentRequestIds.contains($0.key) } + automaticPresentationDiagnosticReasons = automaticPresentationDiagnosticReasons.filter { currentRequestIds.contains($0.key) } persistPresentedRequestIds() discardExpiredRequests(handledRequestedExpirationId: handledRequestedExpirationId) schedulePresentationRetry() diff --git a/BitkitTests/PaykitPaymentRequestServiceTests.swift b/BitkitTests/PaykitPaymentRequestServiceTests.swift index a6bf78fd3..4779a4da1 100644 --- a/BitkitTests/PaykitPaymentRequestServiceTests.swift +++ b/BitkitTests/PaykitPaymentRequestServiceTests.swift @@ -1807,6 +1807,33 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { XCTAssertNil(manager.consumeUnavailableRequestedPresentation()) } + func testRetryDuringSuspendedNotificationSynchronizationStaysPresented() async throws { + let sdk = try PaymentRequestSdkMock(records: [paymentRequestRecord(state: .accepted)]) + let notificationCenter = PaykitSubscriptionNotificationCenterMock() + let notificationScheduler = PaykitSubscriptionNotificationScheduler(center: notificationCenter) + let manager = paymentRequestManager( + sdk: sdk, + subscriptionNotificationScheduler: notificationScheduler + ) + await manager.refresh() + let acceptedRequest = try XCTUnwrap(manager.historyRequests.first) + + await notificationCenter.pauseNextPendingRequests() + let refreshTask = Task { await manager.refresh() } + try await waitUntil { await notificationCenter.isPendingRequestsPaused } + + let retriedRequest = try XCTUnwrap(manager.paymentRequestForRetry(acceptedRequest.id)) + XCTAssertTrue(manager.markPresentedIfPending(retriedRequest)) + XCTAssertEqual(manager.pendingRequests, [retriedRequest]) + XCTAssertTrue(manager.requestsForPresentation().isEmpty) + + await notificationCenter.resumePendingRequests() + await refreshTask.value + + XCTAssertEqual(manager.pendingRequests, [retriedRequest]) + XCTAssertTrue(manager.requestsForPresentation().isEmpty) + } + func testPreparationConsumesBeforeAccepting() async throws { let sdk = try PaymentRequestSdkMock(records: [paymentRequestRecord()]) let manager = paymentRequestManager(sdk: sdk)