From 8e624107911acd7b7aded541e1fdbfe136d857cf Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 2 Sep 2026 02:16:30 +0200 Subject: [PATCH 01/11] 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/11] 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/11] 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/11] 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/11] 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/11] 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/11] 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/11] 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/11] 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/11] 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/11] 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 {