diff --git a/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt b/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt index 7ceb500cf5..04611f6809 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt @@ -8,6 +8,7 @@ import com.synonym.paykit.OutboundPrivateMessageStatus import com.synonym.paykit.PaymentRequestLifecycleState import com.synonym.paykit.PaymentRequestLocalRole import com.synonym.paykit.PaymentRequestRecord +import com.synonym.paykit.PaymentRequestTerms import com.synonym.paykit.PrivateJsonObject import com.synonym.paykit.PrivateStreamCounterpartyIntakeReport import kotlinx.coroutines.CoroutineDispatcher @@ -80,6 +81,25 @@ data class PaykitPaymentRequest( val billingPeriod: PaykitBillingPeriod? = null, val paymentProofKind: PaykitPaymentProofKind? = null, ) { + enum class ParseFailure( + val logValue: String, + val shouldLogIncomingRejection: Boolean = true, + ) { + MissingLocalRole("missing_local_role"), + OutgoingRequest("outgoing_request", shouldLogIncomingRejection = false), + UnsupportedLocalRole("unsupported_local_role"), + NonActionableState("non_actionable_state", shouldLogIncomingRejection = false), + MissingTerms("missing_terms"), + RecurringRequest("recurring_request", shouldLogIncomingRejection = false), + UnsupportedRecurrence("unsupported_recurrence"), + UnsupportedAsset("unsupported_asset"), + InvalidAmount("invalid_amount"), + AmountOutOfRange("amount_out_of_range"), + NoSupportedEndpoint("no_supported_endpoint"), + InvalidExpiration("invalid_expiration"), + Expired("expired", shouldLogIncomingRejection = false), + } + val id: PaykitPaymentRequestId get() = PaykitPaymentRequestId( paymentRequestId, @@ -109,6 +129,63 @@ data class PaykitPaymentRequest( counterpartyReceiverPath == subscription.counterpartyReceiverPath } +internal sealed interface PaykitPaymentRequestParseResult { + data class Parsed(val request: PaykitPaymentRequest) : PaykitPaymentRequestParseResult + data class Rejected(val reason: PaykitPaymentRequest.ParseFailure) : PaykitPaymentRequestParseResult +} + +@Singleton +class PaykitPaymentRequestDiagnostics @Inject constructor() { + companion object { + private const val TAG = "PaykitPaymentRequestDiagnostics" + } + + internal fun logParseRejection( + counterparty: String, + reason: PaykitPaymentRequest.ParseFailure, + ) { + Logger.warn( + "Rejected incoming Paykit payment request: category='parse' reason='${reason.logValue}' " + + "counterparty='${counterparty.redactedForPaymentRequestDiagnostics()}'", + context = TAG, + ) + } + + internal fun logPresentationRejection( + counterparty: String, + reason: IncomingPaykitPaymentRequestFailureReason, + ) { + Logger.warn( + "Rejected incoming Paykit payment request presentation: category='${reason.category}' " + + "reason='${reason.logValue}' " + + "counterparty='${counterparty.redactedForPaymentRequestDiagnostics()}'", + context = TAG, + ) + } + + internal fun logPresentationFailure( + counterparty: String, + error: Throwable, + ) { + Logger.warn( + "Failed to resolve incoming Paykit payment request: " + + "category='resolution' errorType='${error::class.simpleName ?: "Unknown"}' " + + "counterparty='${counterparty.redactedForPaymentRequestDiagnostics()}'", + context = TAG, + ) + } +} + +private fun String.redactedForPaymentRequestDiagnostics(): String = + PubkyPublicKeyFormat.normalized(this)?.let(PubkyPublicKeyFormat::redacted) ?: "" + +private data class ParsedPaykitPaymentRequestTerms( + val terms: PaymentRequestTerms, + val amountSats: ULong, + val endpoints: List, + val expiresAt: Instant?, +) + enum class PaykitPaymentRequestDeliveryStatus { Queued, Sent } enum class PaykitPaymentRequestDirection { Incoming, Outgoing } @@ -153,6 +230,7 @@ class PaykitPaymentRequestRepo @Inject constructor( private val paykitSdkService: PaykitSdkService, private val settingsStore: SettingsStore, private val presentationStore: PaykitPaymentRequestPresentationStore, + private val diagnostics: PaykitPaymentRequestDiagnostics, private val paymentProofStore: PaykitPaymentProofStore, private val paymentProofRepo: PaykitPaymentProofRepo, private val subscriptionNotificationScheduler: PaykitSubscriptionNotificationScheduler, @@ -538,6 +616,8 @@ class PaykitPaymentRequestRepo @Inject constructor( fun isPending(request: PaykitPaymentRequest): Boolean = !request.isExpired(clock.now()) && _pendingRequests.value.any { it.id == request.id } + fun isExpired(request: PaykitPaymentRequest): Boolean = request.isExpired(clock.now()) + fun isProcessing(request: PaykitPaymentRequest): Boolean = synchronized(processingLock) { request.id in processingRequestIds } @@ -618,8 +698,16 @@ class PaykitPaymentRequestRepo @Inject constructor( else -> null } } - val oneTimeIncoming = records.mapNotNull { - it.toPaykitPaymentRequest(PaymentRequestLocalRole.PAYER, now) + val oneTimeIncoming = records.mapNotNull { record -> + when (val result = record.parseIncomingPaykitPaymentRequest(now)) { + is PaykitPaymentRequestParseResult.Parsed -> result.request + is PaykitPaymentRequestParseResult.Rejected -> { + if (result.reason.shouldLogIncomingRejection) { + diagnostics.logParseRejection(record.counterparty, result.reason) + } + null + } + } }.filter { it.id !in locallyCompletedRequestIds && it.id !in locallyInFlightRequestIds } val incoming = (dueRequests + oneTimeIncoming).sortedBy { it.createdAt } val oneTimeHistory = records.mapNotNull { it.toPaykitPaymentRequestHistory(now) }.map { request -> @@ -971,79 +1059,141 @@ private fun List.withExpiredLifecycle(now: Instant): List< private val bitcoinAmountPattern = Regex("(?:[0-9]+(?:\\.[0-9]*)?|\\.[0-9]+)") -@Suppress("CyclomaticComplexMethod", "ReturnCount", "LongMethod") +internal fun PaymentRequestRecord.parseIncomingPaykitPaymentRequest( + now: Instant, +): PaykitPaymentRequestParseResult = parsePaykitPaymentRequest( + expectedRole = PaymentRequestLocalRole.PAYER, + now = now, + requiresActionableRequest = true, +) + internal fun PaymentRequestRecord.toPaykitPaymentRequest( expectedRole: PaymentRequestLocalRole, now: Instant, requiresActionableRequest: Boolean = true, network: Network = Env.network, -): PaykitPaymentRequest? { - if (localRole != expectedRole || state == PaymentRequestLifecycleState.ACTIVE_RECURRING) return null +): PaykitPaymentRequest? = when ( + val result = parsePaykitPaymentRequest(expectedRole, now, requiresActionableRequest, network) +) { + is PaykitPaymentRequestParseResult.Parsed -> result.request + is PaykitPaymentRequestParseResult.Rejected -> null +} + +@Suppress("CyclomaticComplexMethod", "ReturnCount", "LongMethod") +private fun PaymentRequestRecord.parsePaykitPaymentRequest( + expectedRole: PaymentRequestLocalRole, + now: Instant, + requiresActionableRequest: Boolean = true, + network: Network = Env.network, +): PaykitPaymentRequestParseResult { + val role = localRole + ?: return PaykitPaymentRequestParseResult.Rejected(PaykitPaymentRequest.ParseFailure.MissingLocalRole) + if (role != expectedRole) { + return PaykitPaymentRequestParseResult.Rejected( + if (expectedRole == PaymentRequestLocalRole.PAYER && role == PaymentRequestLocalRole.PAYEE) { + PaykitPaymentRequest.ParseFailure.OutgoingRequest + } else { + PaykitPaymentRequest.ParseFailure.UnsupportedLocalRole + }, + ) + } if ( requiresActionableRequest && state != PaymentRequestLifecycleState.PROPOSED && state != PaymentRequestLifecycleState.ACCEPTED ) { - return null + return PaykitPaymentRequestParseResult.Rejected(PaykitPaymentRequest.ParseFailure.NonActionableState) + } + if (state == PaymentRequestLifecycleState.ACTIVE_RECURRING) { + return PaykitPaymentRequestParseResult.Rejected(PaykitPaymentRequest.ParseFailure.RecurringRequest) + } + val requestTerms = terms + ?: return PaykitPaymentRequestParseResult.Rejected(PaykitPaymentRequest.ParseFailure.MissingTerms) + if (requestTerms.recurrence != null) { + return PaykitPaymentRequestParseResult.Rejected( + if (toPaykitSubscription() != null) { + PaykitPaymentRequest.ParseFailure.RecurringRequest + } else { + PaykitPaymentRequest.ParseFailure.UnsupportedRecurrence + }, + ) + } + if (requestTerms.amount.asset != PaykitIssuerInterop.BITCOIN_ASSET) { + return PaykitPaymentRequestParseResult.Rejected(PaykitPaymentRequest.ParseFailure.UnsupportedAsset) } - val requestTerms = terms ?: return null - if (requestTerms.recurrence != null || requestTerms.amount.asset != PaykitIssuerInterop.BITCOIN_ASSET) return null val amountSats = requestTerms.amount.value.toPaykitSats() - ?.takeIf { it <= ULong.MAX_VALUE / 1000uL } - ?: return null + ?: return PaykitPaymentRequestParseResult.Rejected(PaykitPaymentRequest.ParseFailure.InvalidAmount) + if (amountSats > ULong.MAX_VALUE / 1000uL) { + return PaykitPaymentRequestParseResult.Rejected(PaykitPaymentRequest.ParseFailure.AmountOutOfRange) + } val endpoints = PaykitIssuerInterop.supportedEndpointIdentifiers( requestTerms.acceptedPaymentEndpointIdentifiers, network, ) - if (requiresActionableRequest && endpoints.isEmpty()) return null + if (requiresActionableRequest && endpoints.isEmpty()) { + return PaykitPaymentRequestParseResult.Rejected(PaykitPaymentRequest.ParseFailure.NoSupportedEndpoint) + } val expiresAt = requestTerms.proposalExpiresAt?.let { - runCatching { Instant.parse(it) }.getOrNull() ?: return null + runCatching { Instant.parse(it) }.getOrNull() + ?: return PaykitPaymentRequestParseResult.Rejected(PaykitPaymentRequest.ParseFailure.InvalidExpiration) } val isExpiredProposal = state == PaymentRequestLifecycleState.PROPOSED && expiresAt?.let { it <= now } == true if (requiresActionableRequest && isExpiredProposal) { - return null + return PaykitPaymentRequestParseResult.Rejected(PaykitPaymentRequest.ParseFailure.Expired) } - return PaykitPaymentRequest( - paymentRequestId = paymentRequestId, - counterparty = counterparty, - counterpartyReceiverPath = counterpartyReceiverPath, - amountValue = requestTerms.amount.value, - amountSats = amountSats, - note = requestTerms.metadata.note(), - createdAt = lastEventAt?.let { runCatching { Instant.parse(it) }.getOrNull() }, - expiresAt = expiresAt, - acceptedPaymentEndpointIdentifiers = endpoints, - deliveryStatus = if (expectedRole == PaymentRequestLocalRole.PAYEE) { - if (proposalOutboundStatus == OutboundPrivateMessageStatus.SENT) { - PaykitPaymentRequestDeliveryStatus.Sent - } else { - PaykitPaymentRequestDeliveryStatus.Queued - } - } else { - null - }, - direction = if (expectedRole == PaymentRequestLocalRole.PAYER) { - PaykitPaymentRequestDirection.Incoming - } else { - PaykitPaymentRequestDirection.Outgoing - }, - lifecycleState = if (state == PaymentRequestLifecycleState.PROPOSED && expiresAt?.let { it <= now } == true) { - PaymentRequestLifecycleState.PROPOSAL_EXPIRED - } else { - state - }, - paymentProofKind = paymentProofs.lastOrNull()?.let { - PaykitPaymentProofKind.fromPaymentEndpointIdentifier(it.paymentEndpointIdentifier) - }, - ) + val parsedTerms = ParsedPaykitPaymentRequestTerms(requestTerms, amountSats, endpoints, expiresAt) + return PaykitPaymentRequestParseResult.Parsed(toPaykitPaymentRequest(expectedRole, parsedTerms, now)) } +private fun PaymentRequestRecord.toPaykitPaymentRequest( + expectedRole: PaymentRequestLocalRole, + parsedTerms: ParsedPaykitPaymentRequestTerms, + now: Instant, +) = PaykitPaymentRequest( + paymentRequestId = paymentRequestId, + counterparty = counterparty, + counterpartyReceiverPath = counterpartyReceiverPath, + amountValue = parsedTerms.terms.amount.value, + amountSats = parsedTerms.amountSats, + note = parsedTerms.terms.metadata.note(), + createdAt = lastEventAt?.let { runCatching { Instant.parse(it) }.getOrNull() }, + expiresAt = parsedTerms.expiresAt, + acceptedPaymentEndpointIdentifiers = parsedTerms.endpoints, + deliveryStatus = if (expectedRole == PaymentRequestLocalRole.PAYEE) { + if (proposalOutboundStatus == OutboundPrivateMessageStatus.SENT) { + PaykitPaymentRequestDeliveryStatus.Sent + } else { + PaykitPaymentRequestDeliveryStatus.Queued + } + } else { + null + }, + direction = if (expectedRole == PaymentRequestLocalRole.PAYER) { + PaykitPaymentRequestDirection.Incoming + } else { + PaykitPaymentRequestDirection.Outgoing + }, + lifecycleState = if ( + state == PaymentRequestLifecycleState.PROPOSED && parsedTerms.expiresAt?.let { it <= now } == true + ) { + PaymentRequestLifecycleState.PROPOSAL_EXPIRED + } else { + state + }, + paymentProofKind = paymentProofs.lastOrNull()?.let { + PaykitPaymentProofKind.fromPaymentEndpointIdentifier(it.paymentEndpointIdentifier) + }, +) + private fun PaymentRequestRecord.toPaykitPaymentRequestHistory(now: Instant): PaykitPaymentRequest? { val role = localRole ?: return null if (role == PaymentRequestLocalRole.UNKNOWN) return null - return toPaykitPaymentRequest(role, now, requiresActionableRequest = false) + return when (val result = parsePaykitPaymentRequest(role, now, requiresActionableRequest = false)) { + is PaykitPaymentRequestParseResult.Parsed -> result.request + is PaykitPaymentRequestParseResult.Rejected -> null + } } private fun PaymentRequestRecord.toCreatedPaykitPaymentRequest( diff --git a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt index ffcc94f0bc..83d8e0f582 100644 --- a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt @@ -371,8 +371,6 @@ class PrivatePaykitRepo @Inject constructor( val publicKey = normalizedPublicKey(request.counterparty) ?: throw PrivatePaykitError.InvalidPublicKey beginContactPayment(publicKey, request).getOrThrow() } - }.onFailure { - Logger.warn("Failed to present incoming Paykit payment request", it, context = TAG) } suspend fun beginPaymentRequestWaitingForUpdatedList( diff --git a/app/src/main/java/to/bitkit/repositories/PublicPaykitRepo.kt b/app/src/main/java/to/bitkit/repositories/PublicPaykitRepo.kt index c4a2af123b..b55fff7326 100644 --- a/app/src/main/java/to/bitkit/repositories/PublicPaykitRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PublicPaykitRepo.kt @@ -52,6 +52,35 @@ sealed interface PublicPaykitPaymentResult { data object WaitingForUpdatedPaymentList : PublicPaykitPaymentResult } +internal enum class IncomingPaykitPaymentRequestFailureReason( + val logValue: String, +) { + NoSupportedEndpoint("no_supported_endpoint"), + EndpointNotPayable("endpoint_not_payable"), + PaymentDetailsPending("payment_details_pending"), + InvalidPaymentTarget("invalid_payment_target"), + PaymentTargetNotRoutable("payment_target_not_routable"), + RequestExpired("request_expired"), + ResolutionFailed("resolution_failed"), + ; + + val category: String + get() = when (this) { + NoSupportedEndpoint, EndpointNotPayable, PaymentDetailsPending, ResolutionFailed -> "resolution" + InvalidPaymentTarget, PaymentTargetNotRoutable, RequestExpired -> "presentation" + } +} + +internal val PublicPaykitPaymentResult.incomingPaymentRequestFailureReason: + IncomingPaykitPaymentRequestFailureReason? + get() = when (this) { + is PublicPaykitPaymentResult.Opened -> null + PublicPaykitPaymentResult.NoEndpoint -> IncomingPaykitPaymentRequestFailureReason.NoSupportedEndpoint + PublicPaykitPaymentResult.NotOpened -> IncomingPaykitPaymentRequestFailureReason.EndpointNotPayable + PublicPaykitPaymentResult.WaitingForUpdatedPaymentList -> + IncomingPaykitPaymentRequestFailureReason.PaymentDetailsPending + } + data class PrivatePaykitPaymentContext( val receiverPath: String, val paymentListVersion: ULong, diff --git a/app/src/main/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestsScreen.kt b/app/src/main/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestsScreen.kt index 60fcd674aa..3b0e34781f 100644 --- a/app/src/main/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestsScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestsScreen.kt @@ -522,7 +522,7 @@ internal fun PaymentRequestCard( } ) .clickableAlpha(enabled = onClick != null) { onClick?.invoke() } - .testTag("PaymentRequestRow-${request.paymentRequestId}"), + .testTag("PaymentRequestRow-${request.paymentRequestId}") ) { Row( verticalAlignment = Alignment.CenterVertically, diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index b80600e2b9..15c183c8b1 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -143,6 +143,7 @@ import to.bitkit.repositories.ConnectivityState import to.bitkit.repositories.CurrencyRepo import to.bitkit.repositories.HealthRepo import to.bitkit.repositories.HwWalletRepo +import to.bitkit.repositories.IncomingPaykitPaymentRequestFailureReason import to.bitkit.repositories.LightningRepo import to.bitkit.repositories.LnurlPayInvoiceMismatchError import to.bitkit.repositories.MethodId @@ -152,6 +153,7 @@ import to.bitkit.repositories.PaykitPaymentProofKind import to.bitkit.repositories.PaykitPaymentProofRepo import to.bitkit.repositories.PaykitPaymentRequest import to.bitkit.repositories.PaykitPaymentRequestCreation +import to.bitkit.repositories.PaykitPaymentRequestDiagnostics import to.bitkit.repositories.PaykitPaymentRequestDraft import to.bitkit.repositories.PaykitPaymentRequestError import to.bitkit.repositories.PaykitPaymentRequestId @@ -175,6 +177,7 @@ import to.bitkit.repositories.SamRockRepo import to.bitkit.repositories.TransferRepo import to.bitkit.repositories.WalletRepo import to.bitkit.repositories.WidgetsRepo +import to.bitkit.repositories.incomingPaymentRequestFailureReason import to.bitkit.services.AppUpdaterService import to.bitkit.services.CoreService import to.bitkit.services.MigrationService @@ -248,6 +251,7 @@ class AppViewModel @Inject constructor( private val privatePaykitRepo: PrivatePaykitRepo, private val paykitPaymentRequestRepo: PaykitPaymentRequestRepo, private val paykitPaymentProofRepo: PaykitPaymentProofRepo, + private val paykitPaymentRequestDiagnostics: PaykitPaymentRequestDiagnostics, private val refreshContactPaykitReceivers: RefreshContactPaykitReceiversUseCase, private val samRockRepo: SamRockRepo, private val appUpdateSheet: AppUpdateTimedSheet, @@ -357,6 +361,8 @@ class AppViewModel @Inject constructor( private var activeContactPaymentContext: ContactPaymentContext? = null private val pendingContactPaymentContexts = mutableMapOf() private var requestedPaymentRequestId: PaykitPaymentRequestId? = null + private var requestedPaymentRequest: PaykitPaymentRequest? = null + private var shouldRestorePaymentRequestSheet = false private var preparedContactPaymentContext: ContactPaymentContext? = null private var requestedPaymentRequestIdentity: String? = null private var requestedPaymentRequestTags: ImmutableList = persistentListOf() @@ -851,6 +857,8 @@ class AppViewModel @Inject constructor( if (currentIdentity != null && !PubkyPublicKeyFormat.matches(currentIdentity, payerIdentity)) return invalidatePaymentRequestPresentation() requestedPaymentRequestId = requestId + requestedPaymentRequest = null + shouldRestorePaymentRequestSheet = false requestedPaymentRequestIdentity = payerIdentity requestedPaymentRequestTags = persistentListOf() } @@ -974,10 +982,24 @@ class AppViewModel @Inject constructor( null } paymentRequestPresentationRetryJobs[requestedId]?.isActive == true -> null - else -> paykitPaymentRequestRepo.pendingRequest(requestedId)?.let(::listOf) ?: run { - invalidatePaymentRequestPresentation() - clearRequestedPaymentRequest() - null + else -> { + val request = paykitPaymentRequestRepo.pendingRequest(requestedId) + if (request != null) { + requestedPaymentRequest = request + listOf(request) + } else { + requestedPaymentRequest?.takeIf { it.id == requestedId }?.let { + if (paykitPaymentRequestRepo.isExpired(it)) { + finishExpiredPaymentRequestPresentation(it) + } else { + finishUnavailablePaymentRequestPresentation(it) + } + } ?: run { + invalidatePaymentRequestPresentation() + clearRequestedPaymentRequest() + } + null + } } } } @@ -993,10 +1015,17 @@ class AppViewModel @Inject constructor( request: PaykitPaymentRequest, generation: Long, ): Boolean { - val result = privatePaykitRepo.beginPaymentRequest(request).getOrNull() + val presentationResult = privatePaykitRepo.beginPaymentRequest(request) + val result = presentationResult.getOrNull() if (!isCurrentPaymentRequestPresentation(request, generation) || isPaymentRequestPresentationBlocked()) { return true } + val error = presentationResult.exceptionOrNull() + if (error is PaykitPaymentRequestError.RequestExpired) { + finishExpiredPaymentRequestPresentation(request) + return false + } + if (error != null) paykitPaymentRequestDiagnostics.logPresentationFailure(request.counterparty, error) if (!paykitPaymentRequestRepo.isPending(request)) { if (requestedPaymentRequestId == request.id) { invalidatePaymentRequestPresentation() @@ -1005,7 +1034,11 @@ class AppViewModel @Inject constructor( return false } if (result !is PublicPaykitPaymentResult.Opened) { - deferPaymentRequestPresentation(request) + deferPaymentRequestPresentation( + request = request, + reason = result?.incomingPaymentRequestFailureReason + ?: IncomingPaykitPaymentRequestFailureReason.ResolutionFailed, + ) return false } @@ -1026,17 +1059,29 @@ class AppViewModel @Inject constructor( !paykitPaymentRequestRepo.isProcessing(request) && (requestedPaymentRequestId?.let { it == request.id } ?: true) - private fun deferPaymentRequestPresentation(request: PaykitPaymentRequest) { + private fun deferPaymentRequestPresentation( + request: PaykitPaymentRequest, + reason: IncomingPaykitPaymentRequestFailureReason, + ) { + paykitPaymentRequestDiagnostics.logPresentationRejection(request.counterparty, reason) val attempt = paymentRequestPresentationRetryAttempts[request.id] ?: 0 val retryDelay = PAYKIT_PAYMENT_REQUEST_PRESENTATION_RETRY_DELAYS.getOrNull(attempt) ?: if (requestedPaymentRequestId == request.id) { Logger.warn( - "Giving up requested payment request presentation after '${attempt + 1}' attempts", + "Stopped retrying requested incoming Paykit payment request after " + + "'${attempt + 1}' presentation attempts", context = TAG, ) + val restorePaymentRequestSheet = shouldRestorePaymentRequestSheet paymentRequestPresentationGeneration++ clearRequestedPaymentRequest() - showSheet(Sheet.PaymentRequests) + toast( + type = Toast.ToastType.ERROR, + title = context.getString(R.string.wallet__payment_request), + description = context.getString(R.string.wallet__payment_request_unavailable), + testTag = "PaymentRequestUnavailableToast", + ) + if (restorePaymentRequestSheet) showSheet(Sheet.PaymentRequests) viewModelScope.launch { paykitPaymentRequestRepo.markPresented(request) } @@ -1061,13 +1106,67 @@ class AppViewModel @Inject constructor( } } + private fun finishExpiredPaymentRequestPresentation(request: PaykitPaymentRequest) { + paykitPaymentRequestDiagnostics.logPresentationRejection( + request.counterparty, + IncomingPaykitPaymentRequestFailureReason.RequestExpired, + ) + val restorePaymentRequestSheet = + requestedPaymentRequestId == request.id && shouldRestorePaymentRequestSheet + val showExpiredToast = requestedPaymentRequestId == request.id + if (requestedPaymentRequestId == request.id) { + val hideExpiredRequestSendSheet = invalidatePaymentRequestPresentation(requestId = request.id) + clearRequestedPaymentRequest() + if (hideExpiredRequestSendSheet) hideSheet() + } + clearPaymentRequestPresentationRetry(request.id) + if (!showExpiredToast) return + toast( + type = Toast.ToastType.ERROR, + title = context.getString(R.string.wallet__payment_request), + description = context.getString(R.string.wallet__payment_request_expired), + testTag = "PaymentRequestExpiredToast", + ) + if (restorePaymentRequestSheet && currentSheet.value == null) showSheet(Sheet.PaymentRequests) + } + + private fun finishUnavailablePaymentRequestPresentation(request: PaykitPaymentRequest) { + paykitPaymentRequestDiagnostics.logPresentationRejection( + request.counterparty, + IncomingPaykitPaymentRequestFailureReason.ResolutionFailed, + ) + val restorePaymentRequestSheet = + requestedPaymentRequestId == request.id && shouldRestorePaymentRequestSheet + val showUnavailableToast = requestedPaymentRequestId == request.id + if (requestedPaymentRequestId == request.id) { + invalidatePaymentRequestPresentation() + clearRequestedPaymentRequest() + } + clearPaymentRequestPresentationRetry(request.id) + if (!showUnavailableToast) return + toast( + type = Toast.ToastType.ERROR, + title = context.getString(R.string.wallet__payment_request), + description = context.getString(R.string.wallet__payment_request_unavailable), + testTag = "PaymentRequestUnavailableToast", + ) + if (restorePaymentRequestSheet && currentSheet.value == null) showSheet(Sheet.PaymentRequests) + } + private fun retainPaymentRequestPresentationState(requests: List) { val requestIds = requests.mapTo(mutableSetOf()) { it.id } paymentRequestPresentationRetryAttempts.keys.retainAll(requestIds) paymentRequestPresentationRetryJobs.keys.filter { it !in requestIds }.forEach { paymentRequestPresentationRetryJobs.remove(it)?.cancel() } - if (requestedPaymentRequestId?.let { it !in requestIds } == true) { + val requestedRequest = requestedPaymentRequest + if (requestedRequest != null && requestedRequest.id !in requestIds) { + if (paykitPaymentRequestRepo.isExpired(requestedRequest)) { + finishExpiredPaymentRequestPresentation(requestedRequest) + return + } + finishUnavailablePaymentRequestPresentation(requestedRequest) + } else if (requestedPaymentRequestId?.let { it !in requestIds } == true) { invalidatePaymentRequestPresentation() clearRequestedPaymentRequest() } @@ -1097,21 +1196,45 @@ class AppViewModel @Inject constructor( private fun clearRequestedPaymentRequest() { requestedPaymentRequestId = null + requestedPaymentRequest = null + shouldRestorePaymentRequestSheet = false requestedPaymentRequestIdentity = null requestedPaymentRequestTags = persistentListOf() } - private fun invalidatePaymentRequestPresentation(dismissActiveRequest: Boolean = false) { + private fun invalidatePaymentRequestPresentation( + dismissActiveRequest: Boolean = false, + requestId: PaykitPaymentRequestId? = null, + ): Boolean { + fun targetsRequest(context: ContactPaymentContext?): Boolean { + val scanRequestId = context?.incomingPaymentRequest?.id ?: return false + return requestId == null || scanRequestId == requestId + } + paymentRequestPresentationGeneration++ scheduledScan - ?.takeIf { it.contactPaymentContext?.incomingPaymentRequest != null } + ?.takeIf { targetsRequest(it.contactPaymentContext) } ?.job ?.cancel() synchronized(deferredScanLock) { - if (deferredScan?.contactPaymentContext?.incomingPaymentRequest != null) { + if (targetsRequest(deferredScan?.contactPaymentContext)) { deferredScan = null } } + val shouldHideRequestSendSheet = if (requestId != null) { + synchronized(contactPaymentContextLock) { + val ownsActiveContext = activeContactPaymentContext?.incomingPaymentRequest?.id == requestId + if (ownsActiveContext) { + activeContactPaymentContext = null + } + if (preparedContactPaymentContext?.incomingPaymentRequest?.id == requestId) { + preparedContactPaymentContext = null + } + ownsActiveContext && currentSheet.value is Sheet.Send + } + } else { + false + } if (dismissActiveRequest && activeIncomingPaymentRequest() != null) { if (currentSheet.value is Sheet.Send) { hideSheet(shouldFlushDeferredScan = false) @@ -1119,6 +1242,7 @@ class AppViewModel @Inject constructor( clearActiveContactPaymentContext() } } + return shouldHideRequestSendSheet } private suspend fun refreshPrivateOnlyPaykitReceiverMarker(reason: String) { @@ -2079,9 +2203,8 @@ class AppViewModel @Inject constructor( } val normalized = data.removeLightningSchemes() - val scanId = scanLogId(data) - val scheduled = scheduledScan + val scanId = scanLogId(data, contactPaymentContext ?: scheduled?.contactPaymentContext) val isSameActiveScan = normalized == scheduled?.normalizedInput && scheduled.job.isActive && (scheduled.contactPaymentContext == contactPaymentContext || contactPaymentContext == null) @@ -2126,7 +2249,8 @@ class AppViewModel @Inject constructor( return nextJob } - private fun scanLogId(data: String): String { + private fun scanLogId(data: String, contactPaymentContext: ContactPaymentContext? = null): String { + if (contactPaymentContext?.incomingPaymentRequest != null) return "incoming payment request target" val scanLogInput = SamRockSetupRequest.sanitizedDescription(data.removeLightningSchemes()) ?: data return if (scanLogInput.length > SCAN_LOG_ID_MAX_LENGTH) { "${scanLogInput.take(SCAN_LOG_ID_AFFIX_LENGTH)}…${scanLogInput.takeLast(SCAN_LOG_ID_AFFIX_LENGTH)}" @@ -2142,7 +2266,7 @@ class AppViewModel @Inject constructor( routePubkyKeys: Boolean, contactPaymentContext: ContactPaymentContext?, ) { - val scanId = scanLogId(data) + val scanId = scanLogId(data, contactPaymentContext) val normalized = data.removeLightningSchemes() synchronized(deferredScanLock) { val queued = deferredScan @@ -2162,7 +2286,8 @@ class AppViewModel @Inject constructor( } if (queued != null) { Logger.warn( - "Replacing deferred scan from '${queued.source.label}': '${scanLogId(queued.data)}'", + "Replacing deferred scan from '${queued.source.label}': " + + "'${scanLogId(queued.data, queued.contactPaymentContext)}'", context = TAG, ) } @@ -2631,14 +2756,13 @@ class AppViewModel @Inject constructor( // TODO Workaround for https://github.com/synonymdev/bitkit-core/issues/63 if (Bip21Utils.isDuplicatedBip21(input)) { - hideSheet() + if (clearIncomingPaymentRequestTarget()) return@withContext toast( type = Toast.ToastType.ERROR, title = context.getString(R.string.other__scan_err_decoding), description = context.getString(R.string.other__scan__error__generic), testTag = "DuplicatedBip21Toast", ) - clearActiveContactPaymentContext() return@withContext } @@ -2653,7 +2777,9 @@ class AppViewModel @Inject constructor( } if (input.startsWith("$PUBKYAUTH_SCHEME://", ignoreCase = true)) { - clearActiveContactPaymentContext() + clearActiveContactPaymentContext( + failureReason = IncomingPaykitPaymentRequestFailureReason.InvalidPaymentTarget, + ) if (!fromMainScanner) { hideSheet() toast( @@ -2683,7 +2809,9 @@ class AppViewModel @Inject constructor( ) if (route != null) { - clearActiveContactPaymentContext() + clearActiveContactPaymentContext( + failureReason = IncomingPaykitPaymentRequestFailureReason.InvalidPaymentTarget, + ) if (currentSheet.value is Sheet.Send) hideSheet() mainScreenEffect(MainScreenEffect.Navigate(route)) if (route is Routes.ContactDetail) { @@ -2693,15 +2821,29 @@ class AppViewModel @Inject constructor( } } - val safeLogInput = SamRockSetupRequest.sanitizedDescription(input) ?: input val scan = runSuspendCatching { coreService.decode(input) } - .onFailure { Logger.error("Failed to decode scan data: '$safeLogInput'", it, context = TAG) } - .onSuccess { Logger.info("Handling decoded scan data: $it", context = TAG) } + .onFailure { + if (isPaymentRequest) { + Logger.error("Failed to decode incoming Paykit payment request target", context = TAG) + } else { + val safeLogInput = SamRockSetupRequest.sanitizedDescription(input) ?: input + Logger.error("Failed to decode scan data: '$safeLogInput'", it, context = TAG) + } + } + .onSuccess { logDecodedScan(it, isPaymentRequest) } .getOrNull() handleDecodedScan(scan, input, fromMainScanner) } + private fun logDecodedScan(scan: Scanner, isPaymentRequest: Boolean) { + if (isPaymentRequest) { + Logger.info("Decoded incoming Paykit payment request target", context = TAG) + } else { + Logger.info("Handling decoded scan data: $scan", context = TAG) + } + } + @Suppress("CyclomaticComplexMethod") private suspend fun handleDecodedScan( scan: Scanner?, @@ -2709,12 +2851,20 @@ class AppViewModel @Inject constructor( fromMainScanner: Boolean, ) { if (activeHardwareWalletId != null && scan != null && scan !is Scanner.OnChain) { + if (activeIncomingPaymentRequest() != null) { + clearActiveContactPaymentContext( + failureReason = IncomingPaykitPaymentRequestFailureReason.PaymentTargetNotRoutable, + ) + return + } toast( type = Toast.ToastType.WARNING, title = context.getString(R.string.hardware__send_onchain_only_title), description = context.getString(R.string.hardware__send_onchain_only_text), ) - clearActiveContactPaymentContext() + clearActiveContactPaymentContext( + failureReason = IncomingPaykitPaymentRequestFailureReason.PaymentTargetNotRoutable, + ) return } @@ -2728,23 +2878,32 @@ class AppViewModel @Inject constructor( is Scanner.NodeId -> handleNonPaymentScan { onScanNodeId(scan) } is Scanner.Gift -> handleNonPaymentScan { onScanGift(scan.code, scan.amount) } else -> { - hideSheet() + val hasIncomingPaymentRequest = clearIncomingPaymentRequestTarget() + val logMessage = if (hasIncomingPaymentRequest) { + "Received unhandled incoming Paykit payment request target" + } else if (scan == null) { + "Failed to decode scan data" + } else { + "Received unhandled scan data '$scan'" + } Logger.warn( - if (scan == null) "Failed to decode scan data" else "Received unhandled scan data '$scan'", + logMessage, context = TAG, ) + if (hasIncomingPaymentRequest) return toast( type = Toast.ToastType.WARNING, title = context.getString(R.string.other__qr_error_header), description = context.getString(R.string.other__qr_error_text), ) - clearActiveContactPaymentContext() } } } private suspend fun handleSamRockSetup(setup: SamRockSetupRequest) { - clearActiveContactPaymentContext() + clearActiveContactPaymentContext( + failureReason = IncomingPaykitPaymentRequestFailureReason.InvalidPaymentTarget, + ) if (!setup.requestsBitcoinOnchain) { hideSheet() @@ -2761,7 +2920,9 @@ class AppViewModel @Inject constructor( } private suspend fun handleInvalidSamRockSetup(input: String) { - clearActiveContactPaymentContext() + clearActiveContactPaymentContext( + failureReason = IncomingPaykitPaymentRequestFailureReason.InvalidPaymentTarget, + ) hideSheet() val descriptionRes = when { SamRockSetupRequest.isPublicHttpProtocolUrl(input) -> R.string.btcpay__unsupported_http_text @@ -2776,11 +2937,33 @@ class AppViewModel @Inject constructor( } private suspend fun handleNonPaymentScan(action: suspend () -> Unit) { - clearActiveContactPaymentContext() + clearActiveContactPaymentContext( + failureReason = IncomingPaykitPaymentRequestFailureReason.InvalidPaymentTarget, + ) action() } - fun clearActiveContactPaymentContext(retryIncomingRequest: Boolean = true) { + fun clearActiveContactPaymentContext(retryIncomingRequest: Boolean = true) = + clearActiveContactPaymentContext( + failureReason = IncomingPaykitPaymentRequestFailureReason.ResolutionFailed, + retryIncomingRequest = retryIncomingRequest, + ) + + private fun clearIncomingPaymentRequestTarget( + failureReason: IncomingPaykitPaymentRequestFailureReason = + IncomingPaykitPaymentRequestFailureReason.InvalidPaymentTarget, + ): Boolean { + val hasIncomingPaymentRequest = activeIncomingPaymentRequest() != null + val shouldHideSheet = !hasIncomingPaymentRequest || currentSheet.value is Sheet.Send + clearActiveContactPaymentContext(failureReason = failureReason) + if (shouldHideSheet) hideSheet() + return hasIncomingPaymentRequest + } + + private fun clearActiveContactPaymentContext( + failureReason: IncomingPaykitPaymentRequestFailureReason, + retryIncomingRequest: Boolean = true, + ) { uncertainOnchainPaymentRequestId = null val interruptedRequest = synchronized(contactPaymentContextLock) { val request = activeContactPaymentContext?.incomingPaymentRequest @@ -2793,7 +2976,7 @@ class AppViewModel @Inject constructor( if (!retryIncomingRequest) { paymentRequestPresentationGeneration++ if (requestedPaymentRequestId == interruptedRequest.id) { - requestedPaymentRequestId = null + clearRequestedPaymentRequest() } clearPaymentRequestPresentationRetry(interruptedRequest.id) viewModelScope.launch { paykitPaymentRequestRepo.markPresented(interruptedRequest) } @@ -2804,7 +2987,7 @@ class AppViewModel @Inject constructor( requestedPaymentRequestId == interruptedRequest.id || paykitPaymentRequestRepo.automaticPendingRequests().any { it.id == interruptedRequest.id } ) { - deferPaymentRequestPresentation(interruptedRequest) + deferPaymentRequestPresentation(interruptedRequest, failureReason) } isSubmittingPaymentRequest = false } @@ -2849,26 +3032,24 @@ class AppViewModel @Inject constructor( ) { val validatedAddress = runCatching { coreService.validateBitcoinAddress(invoice.address) } .getOrElse { - hideSheet() + if (clearIncomingPaymentRequestTarget()) return toast( type = Toast.ToastType.ERROR, title = context.getString(R.string.other__scan_err_decoding), description = context.getString(R.string.wallet__error_invalid_bitcoin_address), testTag = "InvalidAddressToast", ) - clearActiveContactPaymentContext() return } if (NetworkValidationHelper.isNetworkMismatch(validatedAddress.network.toLdkNetwork(), Env.network)) { - hideSheet() + if (clearIncomingPaymentRequestTarget()) return toast( type = Toast.ToastType.ERROR, title = context.getString(R.string.other__scan_err_decoding), description = context.getString(R.string.other__scan__error__generic), testTag = "InvalidAddressToast", ) - clearActiveContactPaymentContext() return } val hardwareWalletId = activeHardwareWalletId @@ -3081,20 +3262,20 @@ class AppViewModel @Inject constructor( else -> SendFundingSource.Savings } + @Suppress("ReturnCount") private suspend fun onScanLightning( invoice: LightningInvoice, scanResult: String, fromMainScanner: Boolean, ) { if (invoice.isExpired) { - hideSheet() + if (clearIncomingPaymentRequestTarget()) return toast( type = Toast.ToastType.ERROR, title = context.getString(R.string.other__scan_err_decoding), description = context.getString(R.string.other__scan__error__expired), testTag = "ExpiredLightningToast", ) - clearActiveContactPaymentContext() return } @@ -3157,12 +3338,15 @@ class AppViewModel @Inject constructor( val displaySats = data.minSendableSat() val incomingAmount = activeIncomingPaymentRequest()?.amountSats if (incomingAmount != null && incomingAmount !in displaySats..data.maxSendableSat()) { + if (clearIncomingPaymentRequestTarget()) return toast( type = Toast.ToastType.ERROR, title = context.getString(R.string.other__lnurl_pay_error), description = context.getString(R.string.other__scan__error__generic), ) - clearActiveContactPaymentContext() + clearActiveContactPaymentContext( + failureReason = IncomingPaykitPaymentRequestFailureReason.InvalidPaymentTarget, + ) return } val paymentAmount = incomingAmount ?: displaySats @@ -3871,7 +4055,7 @@ class AppViewModel @Inject constructor( description = context.getString(R.string.wallet__payment_request_mismatch), testTag = "PaymentFailedToast", ) - hideSheet() + clearIncomingPaymentRequestTarget() } private fun getLnurlInvoiceFetchErrorMessage(error: Throwable): String = when (error) { @@ -4865,6 +5049,8 @@ class AppViewModel @Inject constructor( invalidatePaymentRequestPresentation() clearPaymentRequestPresentationRetry(id) requestedPaymentRequestId = id + requestedPaymentRequest = request + shouldRestorePaymentRequestSheet = _currentSheet.value is Sheet.PaymentRequests requestedPaymentRequestTags = tags.filter(String::isNotBlank).distinct().toImmutableList() if ( diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 4b8d50eb58..6130ebb6f1 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1248,6 +1248,7 @@ Date Dismiss Enter pubky + The payment request has expired. Expires in 1 day 1 hour @@ -1281,6 +1282,7 @@ Rejected Unavailable Time + The payment request is no longer available. Waiting for payment Waiting for updated private payment details. Bitkit will retry automatically. Waiting for %1$s to pay diff --git a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestDiagnosticsTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestDiagnosticsTest.kt new file mode 100644 index 0000000000..1647ebbf5b --- /dev/null +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestDiagnosticsTest.kt @@ -0,0 +1,69 @@ +package to.bitkit.repositories + +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.shadows.ShadowLog +import to.bitkit.utils.Logger +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class PaykitPaymentRequestDiagnosticsTest { + private val sut = PaykitPaymentRequestDiagnostics() + + @Before + fun setUp() { + Logger.reset() + ShadowLog.clear() + } + + @Test + fun `parse rejection logs a safe reason and redacted counterparty`() { + val counterparty = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" + sut.logParseRejection(counterparty, PaykitPaymentRequest.ParseFailure.UnsupportedAsset) + + val output = paymentRequestDiagnostic() + + assertTrue(output.contains("category='parse' reason='unsupported_asset'")) + assertTrue(output.contains("counterparty='pubky3r…k8yw5xg'")) + assertFalse(output.contains(counterparty)) + } + + @Test + fun `presentation rejection logs a safe reason and invalid counterparty placeholder`() { + sut.logPresentationRejection( + "secret", + IncomingPaykitPaymentRequestFailureReason.NoSupportedEndpoint, + ) + + val output = paymentRequestDiagnostic() + + assertTrue(output.contains("category='resolution' reason='no_supported_endpoint'")) + assertTrue(output.contains("counterparty=''")) + assertFalse(output.contains("secret")) + } + + @Test + fun `presentation failure logs error type without throwable message`() { + val counterparty = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" + val secret = "private payment payload" + sut.logPresentationFailure(counterparty, IllegalStateException(secret)) + + val output = paymentRequestDiagnostic("Failed to resolve incoming Paykit payment request") + + assertTrue(output.contains("category='resolution' errorType='IllegalStateException'")) + assertTrue(output.contains("counterparty='pubky3r…k8yw5xg'")) + assertFalse(output.contains(secret)) + assertFalse(output.contains(counterparty)) + } + + private fun paymentRequestDiagnostic( + message: String = "Rejected incoming Paykit payment request", + ): String = ShadowLog.getLogsForTag("APP") + .single { it.msg.contains(message) } + .msg +} diff --git a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoSubscriptionTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoSubscriptionTest.kt index c8b87c99a8..122177122d 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoSubscriptionTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoSubscriptionTest.kt @@ -33,6 +33,7 @@ import org.mockito.kotlin.doSuspendableAnswer import org.mockito.kotlin.eq import org.mockito.kotlin.mock import org.mockito.kotlin.never +import org.mockito.kotlin.verify import org.mockito.kotlin.verifyBlocking import org.mockito.kotlin.whenever import to.bitkit.data.SettingsData @@ -67,6 +68,7 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat private val paykitSdkService = mock() private val settingsStore = mock() private val presentationStore = mock() + private val diagnostics = mock() private val paymentProofStore = mock() private val paymentProofRepo = mock() private val notificationScheduler = mock() @@ -99,6 +101,7 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat paykitSdkService, settingsStore, presentationStore, + diagnostics, paymentProofStore, paymentProofRepo, notificationScheduler, @@ -143,6 +146,40 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat assertEquals(Instant.parse("2027-02-01T08:00:00Z"), request.billingPeriod?.endsAt) } + @Test + fun `refresh does not report subscription proposals as one time parse failures`() = test { + whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(paymentRequestRecord())) + + sut.refresh().getOrThrow() + + assertTrue(sut.pendingRequests.value.isEmpty()) + assertEquals(1, sut.subscriptions.value.size) + verify(diagnostics, never()).logParseRejection(any(), any()) + } + + @Test + fun `refresh reports unsupported subscription recurrence`() = test { + val unsupportedRecurrence = PaymentRequestRecurrence( + every = 1u, + unit = "fortnight", + startsAt = "2027-01-01T08:00:00Z", + anchor = "2027-01-01T08:00:00Z", + endsAt = null, + ) + whenever(paykitSdkService.paymentRequests()).thenReturn( + listOf(paymentRequestRecord(recurrence = unsupportedRecurrence)), + ) + + sut.refresh().getOrThrow() + + assertTrue(sut.pendingRequests.value.isEmpty()) + assertTrue(sut.subscriptions.value.isEmpty()) + verify(diagnostics).logParseRejection( + COUNTERPARTY, + PaykitPaymentRequest.ParseFailure.UnsupportedRecurrence, + ) + } + @Test fun `accepting subscription returns current period and preserves payment targets`() = test { val proposal = paymentRequestRecord() diff --git a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt index 239141325c..f5ca61de66 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt @@ -33,6 +33,7 @@ import org.mockito.kotlin.eq import org.mockito.kotlin.mock import org.mockito.kotlin.never import org.mockito.kotlin.times +import org.mockito.kotlin.verify import org.mockito.kotlin.verifyBlocking import org.mockito.kotlin.whenever import to.bitkit.data.SettingsData @@ -69,6 +70,7 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { private val paykitSdkService = mock() private val settingsStore = mock() private val presentationStore = mock() + private val diagnostics = mock() private val paymentProofStore = mock() private val paymentProofRepo = mock() private val subscriptionNotificationScheduler = mock() @@ -101,6 +103,7 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { paykitSdkService, settingsStore, presentationStore, + diagnostics, paymentProofStore, paymentProofRepo, subscriptionNotificationScheduler, @@ -126,6 +129,85 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { assertEquals(listOf(MethodId.Bolt11.rawValue), request.acceptedPaymentEndpointIdentifiers) } + @Test + fun `incoming parse failures are reason specific`() { + val cases = listOf( + paymentRequestRecord(role = null) to PaykitPaymentRequest.ParseFailure.MissingLocalRole, + paymentRequestRecord(role = PaymentRequestLocalRole.PAYEE) to + PaykitPaymentRequest.ParseFailure.OutgoingRequest, + paymentRequestRecord(role = PaymentRequestLocalRole.UNKNOWN) to + PaykitPaymentRequest.ParseFailure.UnsupportedLocalRole, + paymentRequestRecord(state = PaymentRequestLifecycleState.REJECTED) to + PaykitPaymentRequest.ParseFailure.NonActionableState, + paymentRequestRecord().copy(terms = null) to PaykitPaymentRequest.ParseFailure.MissingTerms, + paymentRequestRecord(asset = "BTC") to PaykitPaymentRequest.ParseFailure.UnsupportedAsset, + paymentRequestRecord(amount = "not-bitcoin") to PaykitPaymentRequest.ParseFailure.InvalidAmount, + paymentRequestRecord(amount = "184467440737.09551615") to + PaykitPaymentRequest.ParseFailure.AmountOutOfRange, + paymentRequestRecord(endpoints = listOf("btc-unsupported-method")) to + PaykitPaymentRequest.ParseFailure.NoSupportedEndpoint, + paymentRequestRecord(expiresAt = "not-a-timestamp") to + PaykitPaymentRequest.ParseFailure.InvalidExpiration, + paymentRequestRecord(expiresAt = clock.now().toString()) to PaykitPaymentRequest.ParseFailure.Expired, + ) + + cases.forEach { (record, expectedReason) -> + val result = record.parseIncomingPaykitPaymentRequest(clock.now()) + as PaykitPaymentRequestParseResult.Rejected + + assertEquals(expectedReason, result.reason) + } + } + + @Test + fun `refresh emits reason specific parse rejection diagnostic`() = test { + val record = paymentRequestRecord( + asset = "BTC", + counterparty = "secret", + ) + whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(record)) + + sut.refresh().getOrThrow() + + verify(diagnostics).logParseRejection("secret", PaykitPaymentRequest.ParseFailure.UnsupportedAsset) + } + + @Test + fun `refresh logs unknown local role as unsupported_local_role`() = test { + val record = paymentRequestRecord( + role = PaymentRequestLocalRole.UNKNOWN, + counterparty = "secret", + ) + whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(record)) + + sut.refresh().getOrThrow() + + verify(diagnostics).logParseRejection("secret", PaykitPaymentRequest.ParseFailure.UnsupportedLocalRole) + assertTrue(sut.pendingRequests.value.isEmpty()) + } + + @Test + fun `refresh does not log outgoing payee requests`() = test { + whenever(paykitSdkService.paymentRequests()).thenReturn( + listOf(paymentRequestRecord(role = PaymentRequestLocalRole.PAYEE, counterparty = "secret")), + ) + + sut.refresh().getOrThrow() + + verify(diagnostics, never()).logParseRejection(any(), any()) + } + + @Test + fun `refresh does not log expired requests`() = test { + whenever(paykitSdkService.paymentRequests()).thenReturn( + listOf(paymentRequestRecord(expiresAt = clock.now().toString())), + ) + + sut.refresh().getOrThrow() + + verify(diagnostics, never()).logParseRejection(any(), any()) + } + @Test fun `refresh rejects amounts outside the app payment range`() = test { whenever(paykitSdkService.paymentRequests()).thenReturn( @@ -713,6 +795,7 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { role: PaymentRequestLocalRole? = PaymentRequestLocalRole.PAYER, state: PaymentRequestLifecycleState = PaymentRequestLifecycleState.PROPOSED, amount: String = "0.001", + asset: String = "btc", expiresAt: String? = null, endpoints: List = listOf(MethodId.Bolt11.rawValue), counterparty: String = COUNTERPARTY, @@ -731,7 +814,7 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { proposalOutboundStatus = null, proposalEventId = "proposal-event", terms = PaymentRequestTerms( - amount = PaymentRequestAmount(value = amount, asset = "btc"), + amount = PaymentRequestAmount(value = amount, asset = asset), paymentReference = PAYMENT_REFERENCE, proposalExpiresAt = expiresAt, recurrence = recurrence, diff --git a/app/src/test/java/to/bitkit/repositories/PublicPaykitRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PublicPaykitRepoTest.kt index 612a5d8205..faed638630 100644 --- a/app/src/test/java/to/bitkit/repositories/PublicPaykitRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PublicPaykitRepoTest.kt @@ -231,6 +231,28 @@ class PublicPaykitRepoTest : BaseUnitTest() { assertEquals(PublicPaykitPaymentResult.Opened(PUBLIC_BOLT11), result) } + @Test + fun `payment launch results have reason specific incoming request failures`() { + assertEquals( + null, + PublicPaykitPaymentResult.Opened(PUBLIC_BOLT11).incomingPaymentRequestFailureReason, + ) + assertEquals( + IncomingPaykitPaymentRequestFailureReason.NoSupportedEndpoint, + PublicPaykitPaymentResult.NoEndpoint.incomingPaymentRequestFailureReason, + ) + assertEquals( + IncomingPaykitPaymentRequestFailureReason.EndpointNotPayable, + PublicPaykitPaymentResult.NotOpened.incomingPaymentRequestFailureReason, + ) + assertEquals( + IncomingPaykitPaymentRequestFailureReason.PaymentDetailsPending, + PublicPaykitPaymentResult.WaitingForUpdatedPaymentList.incomingPaymentRequestFailureReason, + ) + assertEquals("presentation", IncomingPaykitPaymentRequestFailureReason.RequestExpired.category) + assertEquals("request_expired", IncomingPaykitPaymentRequestFailureReason.RequestExpired.logValue) + } + @Suppress("LongParameterList") private fun createRepo( pubkyRepo: PubkyRepo = this.pubkyRepo, diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index cb443125b8..a232485bfe 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -13,6 +13,7 @@ import com.synonym.bitkitcore.AddressType import com.synonym.bitkitcore.FeeRates import com.synonym.bitkitcore.LightningActivity import com.synonym.bitkitcore.LightningInvoice +import com.synonym.bitkitcore.LnurlAddressData import com.synonym.bitkitcore.LnurlPayData import com.synonym.bitkitcore.NetworkType import com.synonym.bitkitcore.OnChainInvoice @@ -49,6 +50,7 @@ import org.lightningdevkit.ldknode.SpendableUtxo import org.lightningdevkit.ldknode.TransactionDetails import org.mockito.kotlin.any import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.argumentCaptor import org.mockito.kotlin.atLeast import org.mockito.kotlin.check import org.mockito.kotlin.clearInvocations @@ -62,6 +64,7 @@ import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config +import org.robolectric.shadows.ShadowLog import to.bitkit.App import to.bitkit.CurrentActivity import to.bitkit.R @@ -86,6 +89,7 @@ import to.bitkit.models.PubkyProfile import to.bitkit.models.SamRockPaymentMethod import to.bitkit.models.SamRockSetupRequest import to.bitkit.models.SendFailureDetails +import to.bitkit.models.Toast import to.bitkit.models.TransactionSpeed import to.bitkit.models.TransportType import to.bitkit.models.USD @@ -98,6 +102,7 @@ import to.bitkit.repositories.ConnectivityState import to.bitkit.repositories.CurrencyRepo import to.bitkit.repositories.HealthRepo import to.bitkit.repositories.HwWalletRepo +import to.bitkit.repositories.IncomingPaykitPaymentRequestFailureReason import to.bitkit.repositories.LightningRepo import to.bitkit.repositories.LightningState import to.bitkit.repositories.MethodId @@ -108,6 +113,7 @@ import to.bitkit.repositories.PaykitPaymentProofKind import to.bitkit.repositories.PaykitPaymentProofRepo import to.bitkit.repositories.PaykitPaymentRequest import to.bitkit.repositories.PaykitPaymentRequestCreation +import to.bitkit.repositories.PaykitPaymentRequestDiagnostics import to.bitkit.repositories.PaykitPaymentRequestDraft import to.bitkit.repositories.PaykitPaymentRequestError import to.bitkit.repositories.PaykitPaymentRequestId @@ -210,6 +216,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { private val privatePaykitRepo = mock() private val paykitPaymentRequestRepo = mock() private val paykitPaymentProofRepo = mock() + private val paykitPaymentRequestDiagnostics = mock() private val samRockRepo = mock() private val widgetsRepo = mock() private val formatMoneyValue = mock() @@ -320,6 +327,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { true } whenever(paykitPaymentRequestRepo.isPending(any())).thenReturn(true) + whenever(paykitPaymentRequestRepo.isExpired(any())).thenReturn(false) whenever(paykitPaymentRequestRepo.isProcessing(any())).thenReturn(false) whenever(paykitPaymentProofRepo.onchainPaymentResolutions).thenReturn(onchainPaymentResolutions) whenever { paykitPaymentProofRepo.prepare(any(), any(), any()) }.thenReturn(Result.success(Unit)) @@ -419,6 +427,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { privatePaykitRepo = privatePaykitRepo, paykitPaymentRequestRepo = paykitPaymentRequestRepo, paykitPaymentProofRepo = paykitPaymentProofRepo, + paykitPaymentRequestDiagnostics = paykitPaymentRequestDiagnostics, refreshContactPaykitReceivers = refreshContactPaykitReceivers, samRockRepo = samRockRepo, appUpdateSheet = mock(), @@ -757,21 +766,224 @@ class AppViewModelSendFlowTest : BaseUnitTest() { } @Test - fun `failed manual request presentation returns to the request queue`() = test { + fun `failed manual request resolution returns to the request sheet with terminal feedback`() = test { sut.setIsAuthenticated(true) val request = paymentRequest() + whenever(context.getString(R.string.wallet__payment_request)).thenReturn("Payment Request") + whenever(context.getString(R.string.wallet__payment_request_waiting_for_details)).thenReturn("Waiting") + whenever(context.getString(R.string.wallet__payment_request_unavailable)).thenReturn( + "The payment request is no longer available." + ) whenever(privatePaykitRepo.beginPaymentRequest(request)).thenReturn( - Result.success( - PublicPaykitPaymentResult.Opened( - paymentRequest = "bitcoin:first?lightning=bitcoin:second", - privatePaymentContext = PrivatePaykitPaymentContext("bitkit/server", 8uL), + Result.success(PublicPaykitPaymentResult.WaitingForUpdatedPaymentList) + ) + pendingPaykitPaymentRequests.value = listOf(request) + enablePaykitUi() + pubkyPublicKey.value = testPublicKey + runCurrent() + clearInvocations(toastManager) + + sut.showPaymentRequests() + sut.openIncomingPaymentRequest(request.id) + advanceTimeBy(TRANSITION_SCREEN_MS) + runCurrent() + advanceTimeBy(30.seconds.inWholeMilliseconds) + runCurrent() + + assertEquals(Sheet.PaymentRequests, sut.currentSheet.value) + verify(paykitPaymentRequestRepo).markPresented(request) + verify(privatePaykitRepo, times(15)).beginPaymentRequest(request) + verify(paykitPaymentRequestDiagnostics, times(15)).logPresentationRejection( + request.counterparty, + IncomingPaykitPaymentRequestFailureReason.PaymentDetailsPending, + ) + val toastCaptor = argumentCaptor() + verify(toastManager, times(2)).enqueue(toastCaptor.capture()) + val (waitingToast, terminalToast) = toastCaptor.allValues + assertEquals("Payment Request", waitingToast.title) + assertEquals("Waiting", waitingToast.description) + assertEquals("PaymentRequestUnavailableToast", terminalToast.testTag) + assertEquals("Payment Request", terminalToast.title) + assertEquals("The payment request is no longer available.", terminalToast.description) + } + + @Test + fun `opened request with an invalid target returns to the request sheet`() = test { + sut.setIsAuthenticated(true) + val request = paymentRequest() + val invalidTarget = "private-payment-invoice" + whenever(context.getString(R.string.wallet__payment_request)).thenReturn("Payment Request") + whenever(context.getString(R.string.wallet__payment_request_waiting_for_details)).thenReturn("Waiting") + whenever(context.getString(R.string.wallet__payment_request_unavailable)).thenReturn( + "The payment request is no longer available." + ) + stubOpenedPaymentRequest(request, invalidTarget) + whenever(coreService.decode(invalidTarget)).thenThrow(IllegalStateException(invalidTarget)) + pendingPaykitPaymentRequests.value = listOf(request) + enablePaykitUi() + pubkyPublicKey.value = testPublicKey + runCurrent() + clearInvocations(toastManager) + ShadowLog.clear() + + sut.showPaymentRequests() + sut.openIncomingPaymentRequest(request.id) + advanceTimeBy(TRANSITION_SCREEN_MS) + runCurrent() + advanceTimeBy(30.seconds.inWholeMilliseconds) + runCurrent() + + assertEquals(Sheet.PaymentRequests, sut.currentSheet.value) + verify(paykitPaymentRequestRepo).markPresented(request) + verify(privatePaykitRepo, times(15)).beginPaymentRequest(request) + verify(paykitPaymentRequestDiagnostics, times(15)).logPresentationRejection( + request.counterparty, + IncomingPaykitPaymentRequestFailureReason.InvalidPaymentTarget, + ) + val toastCaptor = argumentCaptor() + verify(toastManager, atLeast(2)).enqueue(toastCaptor.capture()) + val terminalToast = toastCaptor.allValues.last() + assertEquals("PaymentRequestUnavailableToast", terminalToast.testTag) + assertEquals("Payment Request", terminalToast.title) + assertEquals("The payment request is no longer available.", terminalToast.description) + val logs = ShadowLog.getLogsForTag("APP").map { it.msg } + assertTrue(logs.any { it.contains("Failed to decode incoming Paykit payment request target") }) + assertFalse(logs.any { it.contains(invalidTarget) }) + } + + @Test + fun `opened request with an unhandled target redacts logs`() = test { + sut.setIsAuthenticated(true) + val request = paymentRequest() + val unhandledTarget = "alice@example.com" + stubOpenedPaymentRequest(request, unhandledTarget) + whenever(coreService.decode(unhandledTarget)).thenReturn( + Scanner.LnurlAddress( + LnurlAddressData( + uri = unhandledTarget, + domain = "example.com", + username = "alice", ), - ) + ), + ) + pendingPaykitPaymentRequests.value = listOf(request) + enablePaykitUi() + pubkyPublicKey.value = testPublicKey + runCurrent() + ShadowLog.clear() + + sut.showPaymentRequests() + sut.openIncomingPaymentRequest(request.id) + advanceTimeBy(TRANSITION_SCREEN_MS) + runCurrent() + + val logs = ShadowLog.getLogsForTag("APP").map { it.msg } + assertTrue(logs.any { it.contains("Decoded incoming Paykit payment request target") }) + assertTrue( + logs.any { it.contains("Received unhandled incoming Paykit payment request target") }, + ) + assertFalse(logs.any { it.contains(unhandledTarget) }) + } + + @Test + fun `out of range lnurl request target leaves terminal feedback visible`() = test { + sut.setIsAuthenticated(true) + val request = paymentRequest() + val lnurl = "lnurl1outofrangerequest" + whenever(context.getString(R.string.wallet__payment_request)).thenReturn("Payment Request") + whenever(context.getString(R.string.wallet__payment_request_waiting_for_details)).thenReturn("Waiting") + whenever(context.getString(R.string.wallet__payment_request_unavailable)).thenReturn( + "The payment request is no longer available." + ) + stubOpenedPaymentRequest(request, lnurl) + whenever { coreService.decode(lnurl) }.thenReturn( + Scanner.LnurlPay( + LnurlPayData( + uri = lnurl, + callback = "https://example.com/callback", + minSendable = 1_000_000uL, + maxSendable = 2_000_000uL, + metadataStr = "[]", + commentAllowed = null, + allowsNostr = false, + nostrPubkey = null, + ), + ), + ) + pendingPaykitPaymentRequests.value = listOf(request) + enablePaykitUi() + pubkyPublicKey.value = testPublicKey + runCurrent() + clearInvocations(toastManager) + + sut.showPaymentRequests() + sut.openIncomingPaymentRequest(request.id) + advanceTimeBy(TRANSITION_SCREEN_MS) + runCurrent() + advanceTimeBy(30.seconds.inWholeMilliseconds) + runCurrent() + + assertEquals(Sheet.PaymentRequests, sut.currentSheet.value) + verify(privatePaykitRepo, times(15)).beginPaymentRequest(request) + val toastCaptor = argumentCaptor() + verify(toastManager, times(2)).enqueue(toastCaptor.capture()) + assertEquals("PaymentRequestUnavailableToast", toastCaptor.lastValue.testTag) + } + + @Test + fun `duplicated bip21 request target leaves terminal feedback visible`() = test { + sut.setIsAuthenticated(true) + val request = paymentRequest() + val first = "bitcoin:bcrt1qfirst?amount=0.00000001" + val duplicatedBip21 = first + "bitcoin:bcrt1qsecond?amount=0.00000001" + whenever(context.getString(R.string.wallet__payment_request)).thenReturn("Payment Request") + whenever(context.getString(R.string.wallet__payment_request_waiting_for_details)).thenReturn("Waiting") + whenever(context.getString(R.string.wallet__payment_request_unavailable)).thenReturn( + "The payment request is no longer available." + ) + stubOpenedPaymentRequest(request, duplicatedBip21) + pendingPaykitPaymentRequests.value = listOf(request) + enablePaykitUi() + pubkyPublicKey.value = testPublicKey + runCurrent() + clearInvocations(toastManager) + + sut.showPaymentRequests() + sut.openIncomingPaymentRequest(request.id) + advanceTimeBy(TRANSITION_SCREEN_MS) + runCurrent() + advanceTimeBy(30.seconds.inWholeMilliseconds) + runCurrent() + + assertEquals(Sheet.PaymentRequests, sut.currentSheet.value) + verify(paykitPaymentRequestRepo).markPresented(request) + verify(privatePaykitRepo, times(15)).beginPaymentRequest(request) + verify(paykitPaymentRequestDiagnostics, times(15)).logPresentationRejection( + request.counterparty, + IncomingPaykitPaymentRequestFailureReason.InvalidPaymentTarget, + ) + val toastCaptor = argumentCaptor() + verify(toastManager, times(2)).enqueue(toastCaptor.capture()) + assertEquals("PaymentRequestUnavailableToast", toastCaptor.lastValue.testTag) + } + + @Test + fun `mismatched bolt11 from a request sheet returns to the request sheet`() = test { + sut.setIsAuthenticated(true) + val request = paymentRequest() + val bolt11 = "lnbcrt1mismatchedrequest" + whenever(context.getString(R.string.wallet__payment_request)).thenReturn("Payment Request") + whenever(context.getString(R.string.wallet__payment_request_waiting_for_details)).thenReturn("Waiting") + whenever(context.getString(R.string.wallet__payment_request_unavailable)).thenReturn( + "The payment request is no longer available." ) + stubOpenedPaymentRequest(request, bolt11) + stubLightningScan(bolt11 = bolt11, amountSats = 1_000u) pendingPaykitPaymentRequests.value = listOf(request) enablePaykitUi() pubkyPublicKey.value = testPublicKey runCurrent() + clearInvocations(toastManager) sut.showPaymentRequests() sut.openIncomingPaymentRequest(request.id) @@ -783,6 +995,342 @@ class AppViewModelSendFlowTest : BaseUnitTest() { assertEquals(Sheet.PaymentRequests, sut.currentSheet.value) verify(paykitPaymentRequestRepo).markPresented(request) verify(privatePaykitRepo, times(15)).beginPaymentRequest(request) + verify(paykitPaymentRequestDiagnostics, times(15)).logPresentationRejection( + request.counterparty, + IncomingPaykitPaymentRequestFailureReason.InvalidPaymentTarget, + ) + } + + @Test + fun `expired explicit request shows the expired toast once`() = test { + sut.setIsAuthenticated(true) + val request = paymentRequest() + whenever(context.getString(R.string.wallet__payment_request)).thenReturn("Payment Request") + whenever(context.getString(R.string.wallet__payment_request_expired)).thenReturn( + "The payment request has expired." + ) + whenever(privatePaykitRepo.beginPaymentRequest(request)).thenReturn( + Result.failure(PaykitPaymentRequestError.RequestExpired) + ) + pendingPaykitPaymentRequests.value = listOf(request) + enablePaykitUi() + pubkyPublicKey.value = testPublicKey + runCurrent() + clearInvocations(toastManager) + + sut.showPaymentRequests() + sut.openIncomingPaymentRequest(request.id) + advanceTimeBy(TRANSITION_SCREEN_MS) + runCurrent() + + assertEquals(Sheet.PaymentRequests, sut.currentSheet.value) + verify(privatePaykitRepo).beginPaymentRequest(request) + verify(paykitPaymentRequestDiagnostics).logPresentationRejection( + request.counterparty, + IncomingPaykitPaymentRequestFailureReason.RequestExpired, + ) + val toastCaptor = argumentCaptor() + verify(toastManager).enqueue(toastCaptor.capture()) + assertEquals("PaymentRequestExpiredToast", toastCaptor.lastValue.testTag) + assertEquals("Payment Request", toastCaptor.lastValue.title) + assertEquals("The payment request has expired.", toastCaptor.lastValue.description) + } + + @Test + fun `explicit request expiring during backoff shows the expired toast once`() = test { + sut.setIsAuthenticated(true) + val request = paymentRequest() + whenever(context.getString(R.string.wallet__payment_request)).thenReturn("Payment Request") + whenever(context.getString(R.string.wallet__payment_request_waiting_for_details)).thenReturn("Waiting") + whenever(context.getString(R.string.wallet__payment_request_expired)).thenReturn( + "The payment request has expired." + ) + whenever(privatePaykitRepo.beginPaymentRequest(request)).thenReturn( + Result.success(PublicPaykitPaymentResult.WaitingForUpdatedPaymentList), + ) + pendingPaykitPaymentRequests.value = listOf(request) + enablePaykitUi() + pubkyPublicKey.value = testPublicKey + runCurrent() + clearInvocations(toastManager) + + sut.showPaymentRequests() + sut.openIncomingPaymentRequest(request.id) + advanceTimeBy(TRANSITION_SCREEN_MS) + runCurrent() + advanceTimeBy(1.seconds.inWholeMilliseconds) + whenever(paykitPaymentRequestRepo.isExpired(request)).thenReturn(true) + pendingPaykitPaymentRequests.value = emptyList() + runCurrent() + advanceTimeBy(2.seconds.inWholeMilliseconds) + runCurrent() + + assertEquals(Sheet.PaymentRequests, sut.currentSheet.value) + verify(privatePaykitRepo).beginPaymentRequest(request) + verify(paykitPaymentRequestDiagnostics).logPresentationRejection( + request.counterparty, + IncomingPaykitPaymentRequestFailureReason.RequestExpired, + ) + val toastCaptor = argumentCaptor() + verify(toastManager, times(2)).enqueue(toastCaptor.capture()) + assertEquals("PaymentRequestExpiredToast", toastCaptor.lastValue.testTag) + } + + @Test + fun `explicit request becoming unavailable during backoff shows terminal feedback`() = test { + sut.setIsAuthenticated(true) + val request = paymentRequest() + whenever(context.getString(R.string.wallet__payment_request)).thenReturn("Payment Request") + whenever(context.getString(R.string.wallet__payment_request_waiting_for_details)).thenReturn("Waiting") + whenever(context.getString(R.string.wallet__payment_request_unavailable)).thenReturn( + "The payment request is no longer available." + ) + whenever(privatePaykitRepo.beginPaymentRequest(request)).thenReturn( + Result.success(PublicPaykitPaymentResult.WaitingForUpdatedPaymentList), + ) + pendingPaykitPaymentRequests.value = listOf(request) + enablePaykitUi() + pubkyPublicKey.value = testPublicKey + runCurrent() + clearInvocations(toastManager) + + sut.showPaymentRequests() + sut.openIncomingPaymentRequest(request.id) + advanceTimeBy(TRANSITION_SCREEN_MS) + runCurrent() + pendingPaykitPaymentRequests.value = emptyList() + runCurrent() + advanceTimeBy(2.seconds.inWholeMilliseconds) + runCurrent() + + assertEquals(Sheet.PaymentRequests, sut.currentSheet.value) + verify(privatePaykitRepo).beginPaymentRequest(request) + verify(paykitPaymentRequestDiagnostics).logPresentationRejection( + request.counterparty, + IncomingPaykitPaymentRequestFailureReason.ResolutionFailed, + ) + val toastCaptor = argumentCaptor() + verify(toastManager, times(2)).enqueue(toastCaptor.capture()) + assertEquals("PaymentRequestUnavailableToast", toastCaptor.lastValue.testTag) + } + + @Test + fun `explicit request expiring during backoff keeps an unrelated send sheet open`() = test { + sut.setIsAuthenticated(true) + val request = paymentRequest() + whenever(context.getString(R.string.wallet__payment_request)).thenReturn("Payment Request") + whenever(context.getString(R.string.wallet__payment_request_waiting_for_details)).thenReturn("Waiting") + whenever(context.getString(R.string.wallet__payment_request_expired)).thenReturn( + "The payment request has expired." + ) + whenever(privatePaykitRepo.beginPaymentRequest(request)).thenReturn( + Result.success(PublicPaykitPaymentResult.WaitingForUpdatedPaymentList), + ) + pendingPaykitPaymentRequests.value = listOf(request) + enablePaykitUi() + pubkyPublicKey.value = testPublicKey + runCurrent() + clearInvocations(toastManager) + + sut.showPaymentRequests() + sut.openIncomingPaymentRequest(request.id) + advanceTimeBy(TRANSITION_SCREEN_MS) + runCurrent() + sut.showSheet(Sheet.Send(SendRoute.Confirm)) + runCurrent() + whenever(paykitPaymentRequestRepo.isExpired(request)).thenReturn(true) + pendingPaykitPaymentRequests.value = emptyList() + runCurrent() + + assertEquals(Sheet.Send(SendRoute.Confirm), sut.currentSheet.value) + val toastCaptor = argumentCaptor() + verify(toastManager, times(2)).enqueue(toastCaptor.capture()) + assertEquals("PaymentRequestExpiredToast", toastCaptor.lastValue.testTag) + } + + @Test + fun `explicit request expiring during resolution shows the expired toast once`() = test { + sut.setIsAuthenticated(true) + val request = paymentRequest() + val resolutionStarted = CompletableDeferred() + val finishResolution = CompletableDeferred() + whenever(context.getString(R.string.wallet__payment_request)).thenReturn("Payment Request") + whenever(context.getString(R.string.wallet__payment_request_expired)).thenReturn( + "The payment request has expired." + ) + whenever(privatePaykitRepo.beginPaymentRequest(request)).doSuspendableAnswer { + resolutionStarted.complete(Unit) + finishResolution.await() + Result.failure(PaykitPaymentRequestError.RequestExpired) + } + pendingPaykitPaymentRequests.value = listOf(request) + enablePaykitUi() + pubkyPublicKey.value = testPublicKey + runCurrent() + clearInvocations(toastManager) + + sut.showPaymentRequests() + sut.openIncomingPaymentRequest(request.id) + advanceTimeBy(TRANSITION_SCREEN_MS) + resolutionStarted.await() + whenever(paykitPaymentRequestRepo.isExpired(request)).thenReturn(true) + pendingPaykitPaymentRequests.value = emptyList() + runCurrent() + finishResolution.complete(Unit) + runCurrent() + + assertEquals(Sheet.PaymentRequests, sut.currentSheet.value) + verify(paykitPaymentRequestDiagnostics).logPresentationRejection( + request.counterparty, + IncomingPaykitPaymentRequestFailureReason.RequestExpired, + ) + val toastCaptor = argumentCaptor() + verify(toastManager).enqueue(toastCaptor.capture()) + assertEquals("PaymentRequestExpiredToast", toastCaptor.lastValue.testTag) + } + + @Test + fun `expiring request cancels its scan and preserves a queued request`() = test { + sut.setIsAuthenticated(true) + val expiredRequest = paymentRequest() + val queuedRequest = paymentRequest().copy(paymentRequestId = "queued-request") + val expiredBolt11 = "lnbcrt1expiredrequestscan" + val queuedBolt11 = "lnbcrt1queuedrequestscan" + val scanStarted = CompletableDeferred() + whenever(context.getString(R.string.wallet__payment_request)).thenReturn("Payment Request") + whenever(context.getString(R.string.wallet__payment_request_expired)).thenReturn( + "The payment request has expired." + ) + stubOpenedPaymentRequest(expiredRequest, expiredBolt11) + whenever(coreService.decode(expiredBolt11)).doSuspendableAnswer { + scanStarted.complete(Unit) + awaitCancellation() + } + stubLightningScan(bolt11 = queuedBolt11, amountSats = queuedRequest.amountSats) + balanceState.value = BalanceState(maxSendLightningSats = 100_000u) + pendingPaykitPaymentRequests.value = listOf(expiredRequest, queuedRequest) + enablePaykitUi() + pubkyPublicKey.value = testPublicKey + runCurrent() + clearInvocations(toastManager) + + sut.openIncomingPaymentRequest(expiredRequest.id) + scanStarted.await() + sut.setIsAuthenticated(false) + sut.openContactPayment( + paymentRequest = queuedBolt11, + publicKey = queuedRequest.counterparty, + incomingPaymentRequest = queuedRequest, + ) + whenever(paykitPaymentRequestRepo.isExpired(expiredRequest)).thenReturn(true) + pendingPaykitPaymentRequests.value = listOf(queuedRequest) + runCurrent() + assertNull(activeContactPaymentContext()) + + sut.setIsAuthenticated(true) + runCurrent() + + assertEquals(Sheet.Send(SendRoute.Confirm), sut.currentSheet.value) + assertEquals(queuedRequest, activeContactPaymentContext()?.incomingPaymentRequest) + verify(paykitPaymentRequestDiagnostics).logPresentationRejection( + expiredRequest.counterparty, + IncomingPaykitPaymentRequestFailureReason.RequestExpired, + ) + val toastCaptor = argumentCaptor() + verify(toastManager).enqueue(toastCaptor.capture()) + assertEquals("PaymentRequestExpiredToast", toastCaptor.lastValue.testTag) + } + + @Test + fun `explicit request expiring before sheet visible closes its send sheet`() = test { + sut.setIsAuthenticated(true) + val request = paymentRequest() + val bolt11 = "lnbcrt1expiredrequestsend" + whenever(context.getString(R.string.wallet__payment_request)).thenReturn("Payment Request") + whenever(context.getString(R.string.wallet__payment_request_expired)).thenReturn( + "The payment request has expired." + ) + stubOpenedPaymentRequest(request, bolt11) + stubLightningScan(bolt11 = bolt11, amountSats = request.amountSats) + balanceState.value = BalanceState(maxSendLightningSats = 100_000u) + pendingPaykitPaymentRequests.value = listOf(request) + enablePaykitUi() + pubkyPublicKey.value = testPublicKey + runCurrent() + clearInvocations(toastManager) + + sut.openIncomingPaymentRequest(request.id) + sut.currentSheet.first { it is Sheet.Send } + whenever(paykitPaymentRequestRepo.isExpired(request)).thenReturn(true) + pendingPaykitPaymentRequests.value = emptyList() + runCurrent() + + assertNull(sut.currentSheet.value) + assertNull(activeContactPaymentContext()) + verify(paykitPaymentRequestDiagnostics).logPresentationRejection( + request.counterparty, + IncomingPaykitPaymentRequestFailureReason.RequestExpired, + ) + val toastCaptor = argumentCaptor() + verify(toastManager).enqueue(toastCaptor.capture()) + assertEquals("PaymentRequestExpiredToast", toastCaptor.lastValue.testTag) + } + + @Test + fun `failed explicit request logs a redacted resolution error`() = test { + sut.setIsAuthenticated(true) + val request = paymentRequest() + val failure = IllegalStateException("private payment payload") + whenever(privatePaykitRepo.beginPaymentRequest(request)).thenReturn(Result.failure(failure)) + pendingPaykitPaymentRequests.value = listOf(request) + enablePaykitUi() + pubkyPublicKey.value = testPublicKey + runCurrent() + + sut.openIncomingPaymentRequest(request.id) + runCurrent() + + verify(paykitPaymentRequestDiagnostics).logPresentationFailure(request.counterparty, failure) + verify(paykitPaymentRequestDiagnostics).logPresentationRejection( + request.counterparty, + IncomingPaykitPaymentRequestFailureReason.ResolutionFailed, + ) + } + + @Test + fun `failed request opened from the full screen does not replace it with the request sheet`() = test { + sut.setIsAuthenticated(true) + val request = paymentRequest() + whenever(context.getString(R.string.wallet__payment_request)).thenReturn("Payment Request") + whenever(context.getString(R.string.wallet__payment_request_waiting_for_details)).thenReturn("Waiting") + whenever(context.getString(R.string.wallet__payment_request_unavailable)).thenReturn( + "The payment request is no longer available." + ) + whenever(privatePaykitRepo.beginPaymentRequest(request)).thenReturn( + Result.success(PublicPaykitPaymentResult.NoEndpoint) + ) + pendingPaykitPaymentRequests.value = listOf(request) + enablePaykitUi() + pubkyPublicKey.value = testPublicKey + runCurrent() + clearInvocations(toastManager) + + sut.openIncomingPaymentRequest(request.id) + advanceTimeBy(30.seconds.inWholeMilliseconds) + runCurrent() + + assertNull(sut.currentSheet.value) + verify(paykitPaymentRequestRepo).markPresented(request) + verify(privatePaykitRepo, times(15)).beginPaymentRequest(request) + verify(paykitPaymentRequestDiagnostics, times(15)).logPresentationRejection( + request.counterparty, + IncomingPaykitPaymentRequestFailureReason.NoSupportedEndpoint, + ) + val toastCaptor = argumentCaptor() + verify(toastManager, times(2)).enqueue(toastCaptor.capture()) + val (waitingToast, terminalToast) = toastCaptor.allValues + assertNull(waitingToast.testTag) + assertEquals("PaymentRequestUnavailableToast", terminalToast.testTag) } @Test @@ -1426,6 +1974,49 @@ class AppViewModelSendFlowTest : BaseUnitTest() { assertEquals(Sheet.Send(SendRoute.Confirm), sut.currentSheet.value) } + @Test + fun `expired request does not discard a later payable request`() = test { + val expiredRequest = paymentRequest() + val payableRequest = expiredRequest.copy(paymentRequestId = "payable-request") + val bolt11 = "lnbcrt1payableafterexpired" + val privateContext = PrivatePaykitPaymentContext("bitkit/server", 7uL) + var payableAttempts = 0 + whenever(privatePaykitRepo.beginPaymentRequest(expiredRequest)) + .thenReturn(Result.failure(PaykitPaymentRequestError.RequestExpired)) + whenever(privatePaykitRepo.beginPaymentRequest(payableRequest)).doSuspendableAnswer { + payableAttempts++ + if (payableAttempts > 1) awaitCancellation() + Result.success( + PublicPaykitPaymentResult.Opened( + paymentRequest = bolt11, + privatePaymentContext = privateContext, + ), + ) + } + stubLightningScan(bolt11 = bolt11, amountSats = 0u) + balanceState.value = BalanceState(maxSendLightningSats = 100_000u) + pendingPaykitPaymentRequests.value = listOf(expiredRequest, payableRequest) + isPaykitEnabled.value = true + pubkyPublicKey.value = testPublicKey + whenever(paykitPaymentRequestRepo.refresh()).thenReturn(Result.success(Unit)) + + sut.startPaykitPaymentRequestPolling() + advanceTimeBy(30.seconds.inWholeMilliseconds) + runCurrent() + sut.stopPaykitPaymentRequestPolling() + + assertEquals( + expected = 1, + actual = payableAttempts, + message = "expired request invalidated the automatic presentation, so the payable request " + + "was resolved again instead of being shown", + ) + verify(privatePaykitRepo).beginPaymentRequest(expiredRequest) + verify(privatePaykitRepo).beginPaymentRequest(payableRequest) + assertEquals(payableRequest, activeContactPaymentContext()?.incomingPaymentRequest) + assertEquals(Sheet.Send(SendRoute.Confirm), sut.currentSheet.value) + } + @Test fun `cancelled request resolution releases the presentation guard`() = test { val request = paymentRequest() @@ -5121,9 +5712,12 @@ class AppViewModelSendFlowTest : BaseUnitTest() { isPaymentRequest = true, ), ) + sut.showSheet(Sheet.Send(SendRoute.Confirm)) + advanceUntilIdle() confirmCurrentPayment() + assertNull(sut.currentSheet.value) verify(paykitPaymentRequestRepo, never()).accept(any()) verify(privatePaykitRepo, never()).consumePrivatePaymentList(any(), any()) verify(lightningRepo, never()).payInvoice(any(), anyOrNull()) diff --git a/changelog.d/next/1217.fixed.md b/changelog.d/next/1217.fixed.md new file mode 100644 index 0000000000..2d5de1f2aa --- /dev/null +++ b/changelog.d/next/1217.fixed.md @@ -0,0 +1 @@ +Incoming Payment Requests now report safe failure reasons and show an error when an opened request cannot be resolved. diff --git a/docs/payment-requests.md b/docs/payment-requests.md new file mode 100644 index 0000000000..622c103b3b --- /dev/null +++ b/docs/payment-requests.md @@ -0,0 +1,46 @@ +# Incoming Payment Request failures + +Bitkit distinguishes Payment Requests rejected while reading a Paykit record from requests that +parse successfully but cannot be opened. + +## Failure contract + +- Parse-time rejection emits a warning with category `parse`, a stable reason, and only the + redacted counterparty. It excludes the request id, amount, note, endpoint identifier, and + endpoint payload. +- Open-time rejection emits a warning with category `resolution` or `presentation`, a stable + reason, 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 a localized error and 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 exactly once. +- Automatic presentation uses the same initial retries, then continues every 120 seconds without + showing terminal feedback. + +The parse reasons are `missing_local_role`, `outgoing_request`, `unsupported_local_role`, +`non_actionable_state`, `missing_terms`, `recurring_request`, `unsupported_recurrence`, +`unsupported_asset`, `invalid_amount`, `amount_out_of_range`, `no_supported_endpoint`, +`invalid_expiration`, and `expired`. + +The resolution reasons are `no_supported_endpoint`, `endpoint_not_payable`, +`payment_details_pending`, and `resolution_failed`. The presentation reasons are +`invalid_payment_target`, `payment_target_not_routable`, and `request_expired`. + +`outgoing_request`, `non_actionable_state`, `recurring_request`, and `expired` are expected filtering +of outgoing, completed, valid subscription, or elapsed records, so they do not emit +incoming-rejection warnings. +`unsupported_recurrence` identifies recurring records that cannot be represented as subscriptions +and emits a privacy-safe warning with only the redacted counterparty. +`unsupported_local_role` identifies an unknown role and emits a privacy-safe warning with only the +redacted counterparty. + +## Accessibility identifiers + +- Payment Requests screen: `PaymentRequestsScreen`. +- Incoming request row: `PaymentRequestRow-`. +- Dismiss action: `PaymentRequestDismiss-`. +- 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 0000000000..4f42853c2e --- /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 (testTag + "PaymentRequestsScreen"). + + + Verify the incoming request row (testTag "PaymentRequestRow-<payment-request-id>") is visible + Tap Pay (testTag "PaymentRequestPay-<payment-request-id>") + Wait up to 35 seconds for the terminal error toast (testTag "PaymentRequestUnavailableToast") + Verify the toast title is "Payment Request" and its description is "The payment request is no longer available." + Verify Payment Requests (testTag "PaymentRequestsScreen") remains visible + Verify the incoming request row (testTag "PaymentRequestRow-<payment-request-id>") remains visible for a later retry + +