diff --git a/Bitkit/AppScene.swift b/Bitkit/AppScene.swift index ab8f05a7f..14b80a18a 100644 --- a/Bitkit/AppScene.swift +++ b/Bitkit/AppScene.swift @@ -3,6 +3,131 @@ 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 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 || + current.expirationTrigger != previous.expirationTrigger + { + dispatches.append(.presentNext) + } + return dispatches + } +} + 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) @@ -249,12 +374,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, @@ -837,8 +958,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 +992,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 +1014,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 +1028,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 +1037,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 +1052,62 @@ struct AppScene: View { await presentNextIncomingPaykitPaymentRequest() } + private func deferIncomingPaykitPaymentRequestPresentation( + _ request: PaykitPaymentRequest, + reason: IncomingPaykitPaymentRequestFailureReason + ) { + presentIncomingPaykitPaymentRequestFeedback( + IncomingPaykitPaymentRequestPresentationDispatcher.feedback( + deferring: request, + reason: reason, + with: paykitPaymentRequestManager + ), + for: request + ) + } + + private var incomingPaykitPaymentRequestPresentationState: IncomingPaykitPaymentRequestPresentationState { + IncomingPaykitPaymentRequestPresentationState(paykitPaymentRequestManager) + } + + private func handleIncomingPaykitPaymentRequestPresentationStateChange( + from previous: IncomingPaykitPaymentRequestPresentationState, + to current: IncomingPaykitPaymentRequestPresentationState + ) { + 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() } + } + } + } + + private func presentIncomingPaykitPaymentRequestFeedback( + _ 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" + ) + + 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 { if PublicPaykitService.isCleanupPending { do { diff --git a/Bitkit/Services/PaykitPaymentRequestService.swift b/Bitkit/Services/PaykitPaymentRequestService.swift index c492a16b0..3e43db4fe 100644 --- a/Bitkit/Services/PaykitPaymentRequestService.swift +++ b/Bitkit/Services/PaykitPaymentRequestService.swift @@ -2,6 +2,25 @@ import Foundation 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" + 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 != .outgoingRequest && self != .nonActionableState + } + } + enum Direction: Hashable { case incoming case outgoing @@ -40,69 +59,85 @@ 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 { + if expectedRole == .payer, localRole == .payee { + return .failure(.outgoingRequest) + } + 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 +308,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 +391,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 +595,13 @@ protocol PaykitPaymentRequestPresentationStoring { func save(_ ids: Set, identity: String) throws } +enum PaykitPaymentRequestPresentationDeferral: Equatable { + case retryScheduled + case requestedPresentationEnded + case requestExpired(wasRequested: Bool) + case ignored +} + struct PaykitPaymentRequestPresentationStore: PaykitPaymentRequestPresentationStoring { private struct State: Codable { var idsByIdentity: [String: [PaykitPaymentRequest.ID]] @@ -578,6 +638,8 @@ 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(set) var requestedPresentationUnavailableTrigger = 0 private let service: PaykitPaymentRequestService private let presentationStore: any PaykitPaymentRequestPresentationStoring @@ -589,6 +651,8 @@ 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 unavailableRequestedPresentations: [PaykitPaymentRequest] = [] private var isPresentingRequests = false private var refreshTask: Task? private var expirationTask: Task? @@ -798,6 +862,8 @@ final class PaykitPaymentRequestManager { persistedPresentedRequestIds = [] presentationRetryAttempts = [:] presentationRetryDates = [:] + expiredRequestedPresentations = [] + unavailableRequestedPresentations = [] requestedPresentationId = nil isCreatingRequest = false } @@ -854,9 +920,35 @@ final class PaykitPaymentRequestManager { approvedPaymentRequestIds.remove(request.id) } - func deferPresentation(_ request: PaykitPaymentRequest) { + func consumeExpiredRequestedPresentation() -> PaykitPaymentRequest? { + guard !expiredRequestedPresentations.isEmpty else { return nil } + return expiredRequestedPresentations.removeFirst() + } + + func consumeUnavailableRequestedPresentation() -> PaykitPaymentRequest? { + guard !unavailableRequestedPresentations.isEmpty else { return nil } + return unavailableRequestedPresentations.removeFirst() + } + + func reconcileExpiredRequests() { discardExpiredRequests() - guard pendingRequests.contains(where: { $0.id == request.id }) else { return } + } + + @discardableResult + func deferPresentation(_ request: PaykitPaymentRequest) -> PaykitPaymentRequestPresentationDeferral { + let wasRequestedPresentation = requestedPresentationId == request.id + let presentationDate = now() + let requestExpired = request.isExpired(at: presentationDate) + discardExpiredRequests( + at: presentationDate, + handledRequestedExpirationId: requestExpired && wasRequestedPresentation ? request.id : nil + ) + if requestExpired { + return .requestExpired(wasRequested: wasRequestedPresentation) + } + guard pendingRequests.contains(where: { $0.id == request.id }) else { + return wasRequestedPresentation ? .requestedPresentationEnded : .ignored + } let isRequestedPresentation = requestedPresentationId == request.id presentationGeneration += 1 @@ -875,12 +967,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 { @@ -905,6 +998,8 @@ final class PaykitPaymentRequestManager { do { 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 } @@ -919,12 +1014,18 @@ 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() - discardExpiredRequests() + discardExpiredRequests(handledRequestedExpirationId: handledRequestedExpirationId) schedulePresentationRetry() } catch is CancellationError { return @@ -995,8 +1096,13 @@ final class PaykitPaymentRequestManager { refreshTask = nil } - private func discardExpiredRequests() { - pendingRequests.removeAll { $0.isExpired(at: 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)) presentedRequestIds.formIntersection(requestIds) presentationRetryAttempts = presentationRetryAttempts.filter { requestIds.contains($0.key) } @@ -1010,6 +1116,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 @@ -1041,7 +1164,7 @@ final class PaykitPaymentRequestManager { return } guard !Task.isCancelled else { return } - self?.discardExpiredRequests() + self?.reconcileExpiredRequests() } } 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..ec235bf0d 100644 --- a/Bitkit/Services/PublicPaykitService.swift +++ b/Bitkit/Services/PublicPaykitService.swift @@ -31,6 +31,25 @@ 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 requestExpired = "request_expired" + case resolutionFailed = "resolution_failed" + + var category: String { + switch self { + case .noSupportedEndpoint, .endpointNotPayable, .paymentDetailsPending, .resolutionFailed: + "resolution" + case .invalidPaymentTarget, .paymentTargetNotRoutable, .requestExpired: + "presentation" + } + } +} + enum PublicPaykitPaymentLaunchResult { case opened(paymentRequest: String, privatePaymentContext: PrivatePaykitPaymentContext?) case noEndpoint @@ -47,6 +66,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..f00a91aec 100644 --- a/BitkitTests/PaykitPaymentRequestServiceTests.swift +++ b/BitkitTests/PaykitPaymentRequestServiceTests.swift @@ -68,6 +68,97 @@ 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), + (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 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, + 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("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")) + } + func testRefreshDropsExpiredAndUnsupportedRequests() async throws { let now = Date(timeIntervalSince1970: 1_800_000_000) let recurrence = PaymentRequestRecurrence( @@ -249,13 +340,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,19 +367,251 @@ 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) 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) + 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 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 + ), + .presentNext, + ] + ) + 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 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) + 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) @@ -1196,6 +1519,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..54c090585 100644 --- a/BitkitTests/PublicPaykitServiceTests.swift +++ b/BitkitTests/PublicPaykitServiceTests.swift @@ -166,6 +166,44 @@ 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 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 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/changelog.d/next/721.fixed.md b/changelog.d/next/721.fixed.md new file mode 100644 index 000000000..6aa6afd97 --- /dev/null +++ b/changelog.d/next/721.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/README.md b/journeys/README.md index 905af7d60..afc397e61 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 | Ported — matches Android's resolution-failure journey | ## Not ported diff --git a/journeys/payment-requests/README.md b/journeys/payment-requests/README.md new file mode 100644 index 000000000..a072ae905 --- /dev/null +++ b/journeys/payment-requests/README.md @@ -0,0 +1,49 @@ +# Payment-request journeys + +These journeys cover incoming Paykit Payment Requests that Bitkit can receive but cannot open. +This suite is ported alongside Android's matching `requested-resolution-failure.xml` journey. + +## 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. +- 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`, `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`, `request_expired`. + +`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 + +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`. +- Expiration feedback: `PaymentRequestExpiredToast`. 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 + +