From 6cfa710fc30ecc704ef7bb23bd1b867d32ec74cc Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 2 Sep 2026 02:16:30 +0200 Subject: [PATCH 1/8] 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 74f8c23b5ed5fc02255bf0c6a734ff9f5c748f97 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 2 Sep 2026 02:17:49 +0200 Subject: [PATCH 2/8] 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 51f39dcac2149291c73b1117816ecc9bd7e3e6f9 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 2 Sep 2026 03:00:33 +0200 Subject: [PATCH 3/8] 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 61c898c197ba7e1f843475be8cc907ef3e0e767a Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 2 Sep 2026 03:11:35 +0200 Subject: [PATCH 4/8] 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 2911964c3db75c55a217fc2a6925fcc06f7495d5 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 2 Sep 2026 03:43:10 +0200 Subject: [PATCH 5/8] 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 e674fa100929e51d3cae9599e91d4517cc0b57da Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 2 Sep 2026 23:43:27 +0200 Subject: [PATCH 6/8] 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 4b7f0b598c4aab9673a96d55f11f8f73a9780de8 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 3 Sep 2026 23:50:41 +0200 Subject: [PATCH 7/8] 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 2f3ef1835e5194a6c4f82621653c21a8382662cc Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Sat, 5 Sep 2026 04:18:42 +0200 Subject: [PATCH 8/8] 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